From c988f6508b8879c209a306a9aa8de8e13ce7598f Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 09:35:21 -0400 Subject: [PATCH 01/42] Rename task_* tables to quest_* with legacy-dev migration path (D-007) Per design/IMPLEMENTATION-ORDER.md 1.1 and decision D-007: the domain model takes the quest vocabulary pre-release. SCHEMA_V1 now creates quest_definitions/quest_instances directly; DAO classes, records and error messages follow (QuestDefinitionsDao, QuestInstancesDao). The migration runner gains migration 2, an async-callable step that detects surviving task_* tables on dev machines and ALTER TABLE RENAMES them inside the migration transaction, with FK clauses rewritten by SQLite. Migration 1's content rewrite is legal because nothing was ever shipped; dev databases stamped version 1 ride migration 2 up to 2. --- custom_components/nestquest/dao_instances.py | 56 ++-- custom_components/nestquest/dao_rules.py | 46 +-- custom_components/nestquest/migrations.py | 97 +++++- custom_components/nestquest/schema.py | 22 +- tests/test_dao_instances.py | 30 +- tests/test_dao_matrix.py | 26 +- tests/test_dao_rules.py | 50 +-- tests/test_lifecycle.py | 4 +- tests/test_migrations.py | 315 +++++++++++++++++-- tests/test_schema.py | 112 +++---- tests/test_startup_db.py | 10 +- 11 files changed, 546 insertions(+), 222 deletions(-) diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py index e4aa0b6..4c78a49 100644 --- a/custom_components/nestquest/dao_instances.py +++ b/custom_components/nestquest/dao_instances.py @@ -1,4 +1,4 @@ -"""Typed DAO layer for task_instances and the append-only completion_events. +"""Typed DAO layer for quest_instances and the append-only completion_events. All SQL for these tables lives in this module per the feature guardrails: callers get typed dataclasses back and never see raw rows @@ -14,7 +14,7 @@ separate reversal event, and instance state derives from the latest event. A package-wide guard test (bottom of the test module) keeps mutation SQL for this table out of every other module. -Upsert semantics: ``TaskInstancesDao.upsert`` is idempotent on +Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on (definition_id, due_date) — re-running materialization for a date whose instance already exists updates nothing that matters (child_id/due_time are refreshed only in the degenerate re-generation @@ -82,8 +82,8 @@ def _next_day_start(date_str: str) -> str: @dataclass(frozen=True) -class TaskInstanceRecord: - """One row of ``task_instances``.""" +class QuestInstanceRecord: + """One row of ``quest_instances``.""" id: int definition_id: int @@ -121,8 +121,8 @@ EVENT_COMPLETED = "completed" EVENT_UNCOMPLETED = "uncompleted" -def _instance_from_row(row: tuple) -> TaskInstanceRecord: - return TaskInstanceRecord( +def _instance_from_row(row: tuple) -> QuestInstanceRecord: + return QuestInstanceRecord( id=row[0], definition_id=row[1], child_id=row[2], @@ -145,8 +145,8 @@ def _event_from_row(row: tuple) -> CompletionEventRecord: ) -class TaskInstancesDao: - """Typed async access to the ``task_instances`` table.""" +class QuestInstancesDao: + """Typed async access to the ``quest_instances`` table.""" def __init__(self, database: NestQuestDatabase) -> None: self._database = database @@ -159,7 +159,7 @@ class TaskInstancesDao: generated_at: str, *, due_time: str | None = None, - ) -> TaskInstanceRecord: + ) -> QuestInstanceRecord: """Create the dated instance for (definition_id, due_date) idempotently. Running materialization twice for the same definition/date @@ -207,16 +207,16 @@ class TaskInstancesDao: f"{today!r}" ) definition = await self._database.fetch_one( - "SELECT child_id FROM task_definitions WHERE id = ?", + "SELECT child_id FROM quest_definitions WHERE id = ?", (definition_id,), ) if definition is None: raise ValueError( - f"task definition {definition_id} does not exist" + f"quest definition {definition_id} does not exist" ) if definition[0] != child_id: raise ValueError( - f"task definition {definition_id} is assigned to " + f"quest definition {definition_id} is assigned to " f"child {definition[0]}, not {child_id}; instance " "must carry the definition's assignee" ) @@ -227,7 +227,7 @@ class TaskInstancesDao: raise ValueError(f"child {child_id} does not exist") existing = await self._database.fetch_one( "SELECT 1 FROM completion_events " - "WHERE instance_id = (SELECT id FROM task_instances " + "WHERE instance_id = (SELECT id FROM quest_instances " "WHERE definition_id = ? AND due_date = ?)", (definition_id, due_date), ) @@ -238,7 +238,7 @@ class TaskInstancesDao: "event and is immutable; it cannot be regenerated" ) await self._database.execute( - "INSERT INTO task_instances (definition_id, child_id, " + "INSERT INTO quest_instances (definition_id, child_id, " "due_date, due_time, generated_at) " "VALUES (?, ?, ?, ?, ?) " "ON CONFLICT (definition_id, due_date) DO UPDATE SET " @@ -259,30 +259,30 @@ class TaskInstancesDao: async def get( self, definition_id: int, due_date: str - ) -> TaskInstanceRecord | None: + ) -> QuestInstanceRecord | None: """Return the instance for (definition_id, due_date), or None.""" row = await self._database.fetch_one( - f"SELECT {_INSTANCE_COLUMNS} FROM task_instances " + f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances " "WHERE definition_id = ? AND due_date = ?", (definition_id, due_date), ) return _instance_from_row(row) if row is not None else None - async def get_by_id(self, instance_id: int) -> TaskInstanceRecord | None: + async def get_by_id(self, instance_id: int) -> QuestInstanceRecord | None: """Return the instance with ``instance_id``, or None.""" row = await self._database.fetch_one( - f"SELECT {_INSTANCE_COLUMNS} FROM task_instances WHERE id = ?", + f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances WHERE id = ?", (instance_id,), ) return _instance_from_row(row) if row is not None else None async def list_by_child_and_date( self, child_id: int, due_date: str - ) -> list[TaskInstanceRecord]: + ) -> list[QuestInstanceRecord]: """Return the child's instances due exactly on ``due_date``.""" _validate_date(due_date, "due_date") rows = await self._database.fetch_all( - f"SELECT {_INSTANCE_COLUMNS} FROM task_instances " + f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances " "WHERE child_id = ? AND due_date = ? ORDER BY id", (child_id, due_date), ) @@ -293,7 +293,7 @@ class TaskInstancesDao: child_id: int, range_start: str, range_end: str, - ) -> list[TaskInstanceRecord]: + ) -> list[QuestInstanceRecord]: """Return the child's instances with due_date in the closed range.""" _validate_date(range_start, "range_start") _validate_date(range_end, "range_end") @@ -303,7 +303,7 @@ class TaskInstancesDao: f"{range_end!r} < {range_start!r}" ) rows = await self._database.fetch_all( - f"SELECT {_INSTANCE_COLUMNS} FROM task_instances " + f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances " "WHERE child_id = ? AND due_date >= ? AND due_date <= ? " "ORDER BY due_date, id", (child_id, range_start, range_end), @@ -338,10 +338,10 @@ class TaskInstancesDao: today = datetime.date.today().isoformat() effective_cutoff = max(cutoff_date, today) result = await self._database.execute( - "DELETE FROM task_instances WHERE definition_id = ? " + "DELETE FROM quest_instances WHERE definition_id = ? " "AND due_date >= ? AND NOT EXISTS (" " SELECT 1 FROM completion_events " - " WHERE instance_id = task_instances.id" + " WHERE instance_id = quest_instances.id" ")", (definition_id, effective_cutoff), ) @@ -418,16 +418,16 @@ class CompletionEventsDao: async with _connection_lock(self._database): async with self._database.transaction(): instance = await self._database.fetch_one( - "SELECT child_id FROM task_instances WHERE id = ?", + "SELECT child_id FROM quest_instances WHERE id = ?", (instance_id,), ) if instance is None: raise ValueError( - f"task instance {instance_id} does not exist" + f"quest instance {instance_id} does not exist" ) if instance[0] != child_id: raise ValueError( - f"task instance {instance_id} belongs to child " + f"quest instance {instance_id} belongs to child " f"{instance[0]}, not {child_id}" ) child = await self._database.fetch_one( @@ -493,7 +493,7 @@ class CompletionEventsDao: ) if due_date_scope: predicate = ( - "SELECT id FROM task_instances WHERE child_id = ? " + "SELECT id FROM quest_instances WHERE child_id = ? " "AND due_date >= ? AND due_date <= ?" ) parameters: list[object] = [child_id, range_start, range_end] diff --git a/custom_components/nestquest/dao_rules.py b/custom_components/nestquest/dao_rules.py index 51ba457..494dd03 100644 --- a/custom_components/nestquest/dao_rules.py +++ b/custom_components/nestquest/dao_rules.py @@ -1,6 +1,6 @@ -"""Typed DAO layer for the schedule_rules and task_definitions tables. +"""Typed DAO layer for the schedule_rules and quest_definitions tables. -All SQL for ``schedule_rules`` and ``task_definitions`` lives in this +All SQL for ``schedule_rules`` and ``quest_definitions`` lives in this module per the feature guardrails: callers get typed dataclasses back and never see raw rows or SQL. Every method is async and runs through :class:`~.db.NestQuestDatabase`, so each statement executes on the HA @@ -9,16 +9,16 @@ executor and the event loop never blocks. Guardrail mapping: - ``schedule_rules`` deletion is delete-if-unreferenced: a rule still - referenced by any task definition is rejected (the done-condition), + referenced by any quest definition is rejected (the done-condition), not cascaded. -- ``task_definitions`` has no delete path at all: definitions are +- ``quest_definitions`` has no delete path at all: definitions are deactivated, never hard-deleted, and history must survive. - ``set_assignee`` changes future instances only — the DAO writes the definition row and nothing else; reassignment never rewrites existing instances or completion history (Feature 06 guardrail). - Validation of rule well-formedness and child activeness lives partly in the schema CHECKs (rule-type coherence) and partly in - :meth:`TaskDefinitionsDao.create` (active child, rule existence); + :meth:`QuestDefinitionsDao.create` (active child, rule existence); no permission checks here, that is the Feature 09 gate's job. Concurrency: ``delete_rule_if_unreferenced`` performs its reference @@ -81,8 +81,8 @@ class ScheduleRuleRecord: @dataclass(frozen=True) -class TaskDefinitionRecord: - """One row of ``task_definitions``.""" +class QuestDefinitionRecord: + """One row of ``quest_definitions``.""" id: int title: str @@ -119,8 +119,8 @@ def _rule_from_row(row: tuple) -> ScheduleRuleRecord: ) -def _definition_from_row(row: tuple) -> TaskDefinitionRecord: - return TaskDefinitionRecord( +def _definition_from_row(row: tuple) -> QuestDefinitionRecord: + return QuestDefinitionRecord( id=row[0], title=row[1], description=row[2], @@ -258,7 +258,7 @@ class ScheduleRulesDao: async with _connection_lock(self._database): async with self._database.transaction(): row = await self._database.fetch_one( - "SELECT 1 FROM task_definitions " + "SELECT 1 FROM quest_definitions " "WHERE schedule_rule_id = ? LIMIT 1", (rule_id,), ) @@ -271,8 +271,8 @@ class ScheduleRulesDao: return result.rowcount > 0 -class TaskDefinitionsDao: - """Typed async access to the ``task_definitions`` table. +class QuestDefinitionsDao: + """Typed async access to the ``quest_definitions`` table. No delete method exists: definitions are deactivated via :meth:`set_active`, never hard-deleted, so completion history keeps @@ -293,7 +293,7 @@ class TaskDefinitionsDao: icon: str | None = None, due_time: str | None = None, is_active: bool = True, - ) -> TaskDefinitionRecord: + ) -> QuestDefinitionRecord: """Insert one definition and return the record as stored. Validates the assignment target as part of the same serialized @@ -312,7 +312,7 @@ class TaskDefinitionsDao: child_id, schedule_rule_id ) result = await self._database.execute( - "INSERT INTO task_definitions (title, description, " + "INSERT INTO quest_definitions (title, description, " "icon, child_id, schedule_rule_id, due_time, " "is_active, created_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", @@ -349,28 +349,28 @@ class TaskDefinitionsDao: f"schedule rule {schedule_rule_id} does not exist" ) - async def get(self, definition_id: int) -> TaskDefinitionRecord | None: + async def get(self, definition_id: int) -> QuestDefinitionRecord | None: """Return the definition with ``definition_id``, or None.""" row = await self._database.fetch_one( - f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions " + f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions " "WHERE id = ?", (definition_id,), ) return _definition_from_row(row) if row is not None else None - async def list_by_child(self, child_id: int) -> list[TaskDefinitionRecord]: + async def list_by_child(self, child_id: int) -> list[QuestDefinitionRecord]: """Return all definitions assigned to ``child_id``, newest first.""" rows = await self._database.fetch_all( - f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions " + f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions " "WHERE child_id = ? ORDER BY id DESC", (child_id,), ) return [_definition_from_row(row) for row in rows] - async def list_active(self) -> list[TaskDefinitionRecord]: + async def list_active(self) -> list[QuestDefinitionRecord]: """Return all active definitions, oldest first (stable order).""" rows = await self._database.fetch_all( - f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions " + f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions " "WHERE is_active = 1 ORDER BY id" ) return [_definition_from_row(row) for row in rows] @@ -409,7 +409,7 @@ class TaskDefinitionsDao: return 0 parameters.append(definition_id) result = await self._database.execute( - f"UPDATE task_definitions SET {', '.join(assignments)} " + f"UPDATE quest_definitions SET {', '.join(assignments)} " "WHERE id = ?", tuple(parameters), ) @@ -422,7 +422,7 @@ class TaskDefinitionsDao: existing instances and completion history untouched. """ result = await self._database.execute( - "UPDATE task_definitions SET is_active = ? WHERE id = ?", + "UPDATE quest_definitions SET is_active = ? WHERE id = ?", (int(is_active), definition_id), ) return result.rowcount @@ -442,7 +442,7 @@ class TaskDefinitionsDao: async with self._database.transaction(): await self._validate_assignable_child(child_id) result = await self._database.execute( - "UPDATE task_definitions SET child_id = ? " + "UPDATE quest_definitions SET child_id = ? " "WHERE id = ?", (child_id, definition_id), ) diff --git a/custom_components/nestquest/migrations.py b/custom_components/nestquest/migrations.py index 0cc4377..1571739 100644 --- a/custom_components/nestquest/migrations.py +++ b/custom_components/nestquest/migrations.py @@ -14,6 +14,22 @@ Version 0 means "the file exists but carries no NestQuest schema yet" the runner against version 0 applies migration 1, which applies the full v1 DDL from :mod:`.schema` and stamps version 1. +Migration 1 was rewritten pre-release (D-007): nothing was ever +shipped, so the v1 DDL now creates the ``quest_*`` tables directly +instead of the legacy ``task_*`` spellings, and no compatibility shim +exists. Dev machines that already hold a version-1 database with +``task_*`` tables are handled by migration 2, which detects the legacy +table names and ``ALTER TABLE ... RENAME TO``s them to their current +names before anything else assumes the new model. The rename step runs +inside the same transaction as its version stamp, so a crash mid-rename +rolls both back together. + +Migration entries are either an ordered sequence of SQL statements or +an async callable taking the open database. Callables exist for steps +whose SQL depends on the database's current state (the legacy rename +only fires when a legacy table is actually present) and run under the +same per-migration transaction as statement lists. + 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. @@ -43,13 +59,18 @@ from __future__ import annotations import asyncio import logging -from collections.abc import Sequence +from collections.abc import Awaitable, Callable, Sequence +from typing import Union from .db import NestQuestDatabase from .schema import SCHEMA_V1_STATEMENTS LOGGER = logging.getLogger(__name__) +#: One migration entry: either an ordered sequence of SQL statements or +#: an async callable run under the entry's migration transaction. +MigrationStep = Union[Sequence[str], Callable[[NestQuestDatabase], Awaitable[None]]] + #: 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. @@ -73,10 +94,48 @@ VERSION_TABLE_DDL = f""" #: version ``n-1`` to version ``n``. MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS) +#: Legacy (pre-D-007) table name -> current table name. Order matters: +#: the parent table renames first so the child's foreign key reference +#: is rewritten while its parent's new name is already in place. +LEGACY_TASK_TABLE_RENAMES: tuple[tuple[str, str], ...] = ( + ("task_definitions", "quest_definitions"), + ("task_instances", "quest_instances"), +) + + +async def _rename_legacy_task_tables(database: NestQuestDatabase) -> None: + """Rename any surviving ``task_*`` tables to their ``quest_*`` names. + + Dev-machines-only path (D-007): a database stamped version 1 by the + pre-rename runner holds ``task_definitions``/``task_instances``. + Each legacy name still present is renamed; fresh databases find + nothing and this step is a no-op. Modern SQLite rewrites foreign + key clauses in other tables to follow the rename, so the renamed + schema is indistinguishable from a freshly created one apart from + internal autoindex names. Runs inside the migration transaction, + so the renames and the version stamp commit or roll back together. + """ + for legacy_name, current_name in LEGACY_TASK_TABLE_RENAMES: + row = await database.fetch_one( + "SELECT 1 FROM sqlite_master WHERE type = 'table' " + "AND name = ?", + (legacy_name,), + ) + if row is not None: + await database.execute( + f'ALTER TABLE "{legacy_name}" RENAME TO "{current_name}"' + ) + LOGGER.info( + "Renamed legacy table %s to %s", legacy_name, current_name + ) + + #: The ordered migration list. Append-only: never edit an applied -#: entry, add the next one instead. -MIGRATIONS: Sequence[Sequence[str]] = [ +#: entry, add the next one instead. (Migration 1's content was +#: rewritten pre-release per D-007 — no version 1 database shipped.) +MIGRATIONS: Sequence[MigrationStep] = [ MIGRATION_1_V1_DDL, + _rename_legacy_task_tables, ] @@ -140,7 +199,7 @@ def _migration_lock(database: NestQuestDatabase) -> asyncio.Lock: async def apply_migrations( database: NestQuestDatabase, - migrations: Sequence[Sequence[str]] = MIGRATIONS, + migrations: Sequence[MigrationStep] = MIGRATIONS, ) -> int: """Bring the database schema up to the latest version. @@ -208,8 +267,14 @@ async def apply_migrations( f"{latest}); downgrades are not supported" ) if in_tx_version < target: - for sql in migrations[target - 1]: - await database.execute(sql) + step = migrations[target - 1] + if callable(step): + await step(database) + applied_statements = 0 + else: + for sql in step: + await database.execute(sql) + applied_statements = len(step) # 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 @@ -225,12 +290,18 @@ async def apply_migrations( ) 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 "", - ) + if applied_statements == 0: + LOGGER.info( + "NestQuest schema migrated to version %d", + target, + ) + else: + LOGGER.info( + "NestQuest schema migrated to version %d " + "(%d statement%s)", + target, + applied_statements, + "s" if applied_statements != 1 else "", + ) return latest \ No newline at end of file diff --git a/custom_components/nestquest/schema.py b/custom_components/nestquest/schema.py index f76cf6f..d497358 100644 --- a/custom_components/nestquest/schema.py +++ b/custom_components/nestquest/schema.py @@ -86,7 +86,7 @@ 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 +Quest instances: ``quest_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 @@ -103,7 +103,7 @@ 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 (``sqlite_autoindex_quest_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 @@ -128,7 +128,7 @@ 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 +``quest_instances``): history reporting (Feature 13) filters and rates per child directly against the log, and the log should stay self-describing without joins. @@ -219,9 +219,9 @@ SCHEMA_V1_SCHEDULE_RULES_DDL: list[str] = [ """, ] -SCHEMA_V1_TASK_DEFINITIONS_DDL: list[str] = [ +SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [ """ - CREATE TABLE IF NOT EXISTS task_definitions ( + CREATE TABLE IF NOT EXISTS quest_definitions ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT, @@ -271,11 +271,11 @@ SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [ """, ] -SCHEMA_V1_TASK_INSTANCES_DDL: list[str] = [ +SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [ """ - CREATE TABLE IF NOT EXISTS task_instances ( + CREATE TABLE IF NOT EXISTS quest_instances ( id INTEGER PRIMARY KEY AUTOINCREMENT, - definition_id INTEGER NOT NULL REFERENCES task_definitions(id), + definition_id INTEGER NOT NULL REFERENCES quest_definitions(id), child_id INTEGER NOT NULL REFERENCES children(id), due_date TEXT NOT NULL, due_time TEXT, @@ -289,7 +289,7 @@ 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), + instance_id INTEGER NOT NULL REFERENCES quest_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')), @@ -315,10 +315,10 @@ 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_QUEST_DEFINITIONS_DDL, *SCHEMA_V1_PRESENCE_SCHEDULES_DDL, *SCHEMA_V1_PRESENCE_OVERRIDES_DDL, - *SCHEMA_V1_TASK_INSTANCES_DDL, + *SCHEMA_V1_QUEST_INSTANCES_DDL, *SCHEMA_V1_COMPLETION_EVENTS_DDL, ] diff --git a/tests/test_dao_instances.py b/tests/test_dao_instances.py index f0dad52..03ee03f 100644 --- a/tests/test_dao_instances.py +++ b/tests/test_dao_instances.py @@ -1,4 +1,4 @@ -"""Tests for dao_instances.py: task_instances + append-only completion_events.""" +"""Tests for dao_instances.py: quest_instances + append-only completion_events.""" from __future__ import annotations import asyncio @@ -11,10 +11,10 @@ from custom_components.nestquest.dao_children import ChildrenDao from custom_components.nestquest.dao_instances import ( CompletionEventRecord, CompletionEventsDao, - TaskInstanceRecord, - TaskInstancesDao, + QuestInstanceRecord, + QuestInstancesDao, ) -from custom_components.nestquest.dao_rules import ScheduleRulesDao, TaskDefinitionsDao +from custom_components.nestquest.dao_rules import ScheduleRulesDao, QuestDefinitionsDao from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.migrations import apply_migrations @@ -76,8 +76,8 @@ async def _prepare(path) -> tuple: await apply_migrations(database) children = ChildrenDao(database) rules = ScheduleRulesDao(database) - definitions = TaskDefinitionsDao(database) - instances = TaskInstancesDao(database) + definitions = QuestDefinitionsDao(database) + instances = QuestInstancesDao(database) events = CompletionEventsDao(database) child = await children.create("Ada", _now_stamp()) rule = await rules.create("daily", D1) @@ -111,7 +111,7 @@ def _with_db(tmp_path, name): # --------------------------------------------------------------------------- -# task_instances: upsert idempotency +# quest_instances: upsert idempotency # --------------------------------------------------------------------------- @@ -121,13 +121,13 @@ def test_upsert_creates_then_is_idempotent(tmp_path) -> None: first = await instances.upsert( definition.id, child.id, D1, _now_stamp() ) - assert isinstance(first, TaskInstanceRecord) + assert isinstance(first, QuestInstanceRecord) second = await instances.upsert( definition.id, child.id, D1, _now_stamp() ) assert second.id == first.id rows = await database.fetch_one( - "SELECT COUNT(*) FROM task_instances" + "SELECT COUNT(*) FROM quest_instances" ) assert rows == (1,) return second @@ -323,8 +323,8 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date( await apply_migrations(database) children = ChildrenDao(database) rules = ScheduleRulesDao(database) - definitions = TaskDefinitionsDao(database) - instances = TaskInstancesDao(database) + definitions = QuestDefinitionsDao(database) + instances = QuestInstancesDao(database) events = CompletionEventsDao(database) child = await children.create("Ada", _now_stamp()) rule = await rules.create("daily", D1) @@ -369,7 +369,7 @@ def test_upsert_refuses_immutable_completed_instance(tmp_path) -> None: # --------------------------------------------------------------------------- -# task_instances: get / list +# quest_instances: get / list # --------------------------------------------------------------------------- @@ -453,7 +453,7 @@ def test_list_by_date_range_rejects_inverted_range(tmp_path) -> None: # --------------------------------------------------------------------------- -# task_instances: delete_future_uncompleted +# quest_instances: delete_future_uncompleted # --------------------------------------------------------------------------- @@ -1084,8 +1084,8 @@ def test_append_concurrent_events_serialize(tmp_path) -> None: async def _prepare_in(database): children = ChildrenDao(database) rules = ScheduleRulesDao(database) - definitions = TaskDefinitionsDao(database) - instances = TaskInstancesDao(database) + definitions = QuestDefinitionsDao(database) + instances = QuestInstancesDao(database) events = CompletionEventsDao(database) child = await children.create("Ada", _now_stamp()) rule = await rules.create("daily", D1) diff --git a/tests/test_dao_matrix.py b/tests/test_dao_matrix.py index c68b7a1..c14e65b 100644 --- a/tests/test_dao_matrix.py +++ b/tests/test_dao_matrix.py @@ -27,7 +27,7 @@ from custom_components.nestquest.dao_children import ( ) from custom_components.nestquest.dao_instances import ( CompletionEventsDao, - TaskInstancesDao, + QuestInstancesDao, ) from custom_components.nestquest.dao_presence import ( PresenceOverridesDao, @@ -35,7 +35,7 @@ from custom_components.nestquest.dao_presence import ( ) from custom_components.nestquest.dao_rules import ( ScheduleRulesDao, - TaskDefinitionsDao, + QuestDefinitionsDao, ) from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.migrations import apply_migrations @@ -70,10 +70,10 @@ ALL_TABLES = ( "children", "admin_users", "schedule_rules", - "task_definitions", + "quest_definitions", "presence_schedules", "presence_overrides", - "task_instances", + "quest_instances", "completion_events", ) @@ -86,10 +86,10 @@ class World: self.children = ChildrenDao(database) self.admins = AdminUsersDao(database) self.rules = ScheduleRulesDao(database) - self.definitions = TaskDefinitionsDao(database) + self.definitions = QuestDefinitionsDao(database) self.schedules = PresenceSchedulesDao(database) self.overrides = PresenceOverridesDao(database) - self.instances = TaskInstancesDao(database) + self.instances = QuestInstancesDao(database) self.events = CompletionEventsDao(database) async def seed(self) -> None: @@ -201,7 +201,7 @@ def test_matrix_schedule_rules_crud_and_constraints(tmp_path) -> None: assert _with_world(tmp_path / "m-rules.db")(_body) is True -def test_matrix_task_definitions_crud_and_constraints(tmp_path) -> None: +def test_matrix_quest_definitions_crud_and_constraints(tmp_path) -> None: async def _body(w: World): # insert (seeded) + read fetched = await w.definitions.get(w.definition.id) @@ -271,7 +271,7 @@ def test_matrix_presence_overrides_crud_and_constraints(tmp_path) -> None: assert _with_world(tmp_path / "m-overrides.db")(_body) is True -def test_matrix_task_instances_crud_and_constraints(tmp_path) -> None: +def test_matrix_quest_instances_crud_and_constraints(tmp_path) -> None: async def _body(w: World): # insert (seeded) + read fetched = await w.instances.get(w.definition.id, D1) @@ -292,20 +292,20 @@ def test_matrix_task_instances_crud_and_constraints(tmp_path) -> None: assert _with_world(tmp_path / "m-instances.db")(_body) is True -def test_matrix_task_instances_constraints(tmp_path) -> None: +def test_matrix_quest_instances_constraints(tmp_path) -> None: async def _body(w: World): # D20 has no instance yet: a raw insert succeeds, then an # identical second insert must fail on the UNIQUE constraint — # proving the schema backs the DAO's idempotency. await w.database.execute( - "INSERT INTO task_instances (definition_id, child_id, " + "INSERT INTO quest_instances (definition_id, child_id, " "due_date, due_time, generated_at) " "VALUES (?, ?, ?, NULL, ?)", (w.definition.id, w.child.id, D20, _stamp()), ) with pytest.raises(sqlite3.IntegrityError): await w.database.execute( - "INSERT INTO task_instances (definition_id, child_id, " + "INSERT INTO quest_instances (definition_id, child_id, " "due_date, due_time, generated_at) " "VALUES (?, ?, ?, NULL, ?)", (w.definition.id, w.child.id, D20, _stamp()), @@ -320,7 +320,7 @@ def test_matrix_task_instances_constraints(tmp_path) -> None: assert _with_world(tmp_path / "m-instances-constraints.db")(_body) is True -def test_matrix_task_instances_upsert_idempotency(tmp_path) -> None: +def test_matrix_quest_instances_upsert_idempotency(tmp_path) -> None: async def _body(w: World): first = await w.instances.upsert( w.definition.id, w.child.id, D2, _stamp() @@ -330,7 +330,7 @@ def test_matrix_task_instances_upsert_idempotency(tmp_path) -> None: ) assert second.id == first.id count = await w.database.fetch_one( - "SELECT COUNT(*) FROM task_instances" + "SELECT COUNT(*) FROM quest_instances" ) # Seed created one (D1) plus this one (D2) — no duplicate. assert count == (2,) diff --git a/tests/test_dao_rules.py b/tests/test_dao_rules.py index a27966e..4d3bf04 100644 --- a/tests/test_dao_rules.py +++ b/tests/test_dao_rules.py @@ -1,4 +1,4 @@ -"""Tests for dao_rules.py: typed DAO for schedule_rules and task_definitions.""" +"""Tests for dao_rules.py: typed DAO for schedule_rules and quest_definitions.""" from __future__ import annotations import asyncio @@ -10,8 +10,8 @@ from custom_components.nestquest.dao_children import ChildrenDao from custom_components.nestquest.dao_rules import ( ScheduleRuleRecord, ScheduleRulesDao, - TaskDefinitionRecord, - TaskDefinitionsDao, + QuestDefinitionRecord, + QuestDefinitionsDao, ) from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.migrations import apply_migrations @@ -37,7 +37,7 @@ async def _prepare(path) -> tuple: await database.open(path) await apply_migrations(database) rules = ScheduleRulesDao(database) - definitions = TaskDefinitionsDao(database) + definitions = QuestDefinitionsDao(database) children = ChildrenDao(database) child = await children.create("Ada", NOW) return database, rules, definitions, children, child @@ -322,7 +322,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( await apply_migrations(database) children = ChildrenDao(database) rules = ScheduleRulesDao(database) - definitions = TaskDefinitionsDao(database) + definitions = QuestDefinitionsDao(database) child = await children.create("Ada", NOW) rule_a = await rules.create("daily", "2026-09-14") @@ -335,7 +335,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( import custom_components.nestquest.dao_rules as dao_rules original_validate = ( - dao_rules.TaskDefinitionsDao._validate_assignable + dao_rules.QuestDefinitionsDao._validate_assignable ) release = asyncio.Event() started = asyncio.Event() @@ -353,7 +353,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( started.set() await release.wait() - dao_rules.TaskDefinitionsDao._validate_assignable = ( + dao_rules.QuestDefinitionsDao._validate_assignable = ( _pausing_validate ) try: @@ -373,7 +373,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( delete_task, create_task, return_exceptions=True ) finally: - dao_rules.TaskDefinitionsDao._validate_assignable = ( + dao_rules.QuestDefinitionsDao._validate_assignable = ( original_validate ) else: @@ -405,7 +405,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( assert rule_row is None else: # Create won: the delete was rejected, both rows exist. - assert isinstance(creation, TaskDefinitionRecord), ( + assert isinstance(creation, QuestDefinitionRecord), ( f"create must succeed, got {creation!r}" ) assert rule_row is not None @@ -425,7 +425,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( # --------------------------------------------------------------------------- -# task_definitions: create / get +# quest_definitions: create / get # --------------------------------------------------------------------------- @@ -441,7 +441,7 @@ def test_definition_create_returns_typed_record(tmp_path) -> None: icon="mdi:tooth", due_time="08:00", ) - assert isinstance(definition, TaskDefinitionRecord) + assert isinstance(definition, QuestDefinitionRecord) assert definition.title == "Brush teeth" assert definition.child_id == child.id assert definition.schedule_rule_id == rule.id @@ -497,7 +497,7 @@ def test_definition_create_unknown_rule_raises_value_error( # --------------------------------------------------------------------------- -# task_definitions: list by child / list active +# quest_definitions: list by child / list active # --------------------------------------------------------------------------- @@ -539,7 +539,7 @@ def test_definition_list_active_excludes_inactive(tmp_path) -> None: # --------------------------------------------------------------------------- -# task_definitions: update / set_active / set_assignee +# quest_definitions: update / set_active / set_assignee # --------------------------------------------------------------------------- @@ -673,7 +673,7 @@ def test_definition_no_delete_method_exists(tmp_path) -> None: methods = { name for name, _ in inspect.getmembers( - TaskDefinitionsDao, inspect.isfunction + QuestDefinitionsDao, inspect.isfunction ) } assert "delete" not in methods @@ -748,13 +748,13 @@ def test_definition_set_assignee_leaves_history_rows_alone( # Simulate an already-generated instance (materialization owns # this table, but its row shape proves reassignment isolation). await database.execute( - "INSERT INTO task_instances (definition_id, child_id, " + "INSERT INTO quest_instances (definition_id, child_id, " "due_date, generated_at) VALUES (?, ?, ?, ?)", (definition.id, child.id, "2026-09-15", NOW), ) await definitions.set_assignee(definition.id, other.id) row = await database.fetch_one( - "SELECT child_id FROM task_instances WHERE definition_id = ?", + "SELECT child_id FROM quest_instances WHERE definition_id = ?", (definition.id,), ) assert row == (child.id,) @@ -769,7 +769,7 @@ def test_definition_set_assignee_leaves_history_rows_alone( def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None: - """Guardrail: schedule_rules/task_definitions SQL may appear only + """Guardrail: schedule_rules/quest_definitions SQL may appear only in the DAO modules (and the schema DDL declarations). RECURSIVE scan of package and tests, FROM/INTO/UPDATE/DELETE FROM/JOIN pattern, with exact repo-relative exclusions justified by role: @@ -801,7 +801,7 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None: } sql_pattern = re.compile( r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?" - r"(schedule_rules|task_definitions)\b", + r"(schedule_rules|quest_definitions)\b", re.IGNORECASE, ) offenders: list[str] = [] @@ -813,16 +813,16 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None: if sql_pattern.search(py.read_text()): offenders.append(relative) assert offenders == [], ( - f"SQL touching schedule_rules/task_definitions leaked into: " + f"SQL touching schedule_rules/quest_definitions leaked into: " f"{offenders}" ) def test_dao_instances_test_file_uses_dao_not_raw_rules_sql() -> None: """Compensating self-scan for the test_dao_instances.py exemption: - that file may query task_instances/completion_events raw (its own + that file may query quest_instances/completion_events raw (its own guard's scope) but must go through the DAO for schedule_rules and - task_definitions, except its single sanctioned definition-create + quest_definitions, except its single sanctioned definition-create helper usage and guard spans. """ import ast @@ -853,12 +853,12 @@ def test_dao_instances_test_file_uses_dao_not_raw_rules_sql() -> None: ) sql_pattern = re.compile( r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|" - r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|task_definitions)\b", + r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions)\b", re.IGNORECASE, ) assert sql_pattern.search(remaining) is None, ( "test_dao_instances.py must go through the DAO, not raw SQL, " - "for schedule_rules/task_definitions" + "for schedule_rules/quest_definitions" ) @@ -866,7 +866,7 @@ def test_dao_rules_test_file_uses_dao_not_raw_table_sql() -> None: """This test module must exercise the DAO, not raw SQL, for these tables. Exceptions: the guard functions' own spans (their regexes mention the names), and the reassignment-isolation test, which - legitimately touches task_instances — a table this DAO does not + legitimately touches quest_instances — a table this DAO does not own — to prove the guardrail that reassignment leaves it alone. """ import ast @@ -898,7 +898,7 @@ def test_dao_rules_test_file_uses_dao_not_raw_table_sql() -> None: ) sql_pattern = re.compile( r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|" - r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|task_definitions)\b", + r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions)\b", re.IGNORECASE, ) assert sql_pattern.search(remaining) is None, ( diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 246f8b5..c3e0f17 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -247,12 +247,12 @@ async def test_setup_applies_all_v1_tables(hass, make_entry) -> None: 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" + "'quest_definitions') ORDER BY name" ) assert [row[0] for row in rows] == [ "admin_users", "children", + "quest_definitions", "schedule_rules", - "task_definitions", ] await async_unload_entry(hass, entry) \ No newline at end of file diff --git a/tests/test_migrations.py b/tests/test_migrations.py index a96226f..f8ac70d 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -62,10 +62,10 @@ def _counts(database) -> dict[str, int]: "children", "admin_users", "schedule_rules", - "task_definitions", + "quest_definitions", "presence_schedules", "presence_overrides", - "task_instances", + "quest_instances", "completion_events", ) counts = {} @@ -76,16 +76,16 @@ def _counts(database) -> dict[str, int]: # --------------------------------------------------------------------------- -# Fresh file: version 0 -> version 1 +# Fresh file: version 0 -> latest version # --------------------------------------------------------------------------- -def test_fresh_file_migrates_to_version_1(tmp_path) -> None: +def test_fresh_file_migrates_to_latest_version(tmp_path) -> None: database = _open_db(tmp_path / "fresh.db") try: final = _migrate(database) - assert final == 1 - assert _version(database) == 1 + assert final == len(MIGRATIONS) + assert _version(database) == len(MIGRATIONS) finally: _run(database.close()) @@ -98,10 +98,10 @@ def test_fresh_file_migration_creates_all_v1_tables(tmp_path) -> None: "children", "admin_users", "schedule_rules", - "task_definitions", + "quest_definitions", "presence_schedules", "presence_overrides", - "task_instances", + "quest_instances", "completion_events", VERSION_TABLE, } @@ -135,9 +135,249 @@ def test_empty_version_table_reads_as_version_0(tmp_path) -> None: _run(database.close()) -def test_migration_1_is_the_full_v1_ddl() -> None: +def test_migration_list_shape() -> None: + """Migration 1 is the v1 DDL; migration 2 is the legacy rename step. + + Nothing ever shipped, so migration 1 was rewritten pre-release + (D-007) to create the quest_* tables directly; migration 2 exists + for dev databases stamped version 1 by the pre-rename runner. + """ assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS - assert len(MIGRATIONS) == 1 + assert callable(MIGRATIONS[1]) + assert len(MIGRATIONS) == 2 + + +# --------------------------------------------------------------------------- +# Legacy dev database: task_* tables stamped version 1 -> quest_* +# --------------------------------------------------------------------------- + + +_LEGACY_DDL = [ + """ + 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, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS admin_users ( + ha_user_id TEXT PRIMARY KEY NOT NULL, + added_at TEXT NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS schedule_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_type TEXT NOT NULL, + interval INTEGER NOT NULL DEFAULT 1, + weekday_set TEXT, + day_of_month INTEGER, + nth_weekday INTEGER, + month INTEGER, + start_date TEXT NOT NULL, + end_date TEXT + ) + """, + """ + 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, + created_at TEXT NOT NULL + ) + """, + """ + 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, + anchor_date TEXT NOT NULL, + pattern TEXT NOT NULL + ) + """, + """ + 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, + note TEXT + ) + """, + """ + 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) + ) + """, + """ + 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, + actor_source TEXT NOT NULL, + actor_user_id TEXT, + occurred_at TEXT NOT NULL, + was_on_time INTEGER + ) + """, +] + + +def _seed_legacy_database(database) -> None: + """Create a database indistinguishable from a pre-rename dev file. + + Applies the legacy task_* DDL, seeds one row in every renamed + table, and stamps version 1 exactly the way the old runner did. + """ + for sql in _LEGACY_DDL: + _run(database.execute(sql)) + _run(database.execute(VERSION_TABLE_DDL)) + _run( + database.execute( + f"INSERT INTO {VERSION_TABLE} (id, version) VALUES (1, 1)" + ) + ) + _run( + database.execute( + "INSERT INTO children (display_name, created_at) " + "VALUES ('Ada', '2026-09-14T00:00:00+00:00')" + ) + ) + _run( + database.execute( + "INSERT INTO schedule_rules (rule_type, start_date) " + "VALUES ('daily', '2026-09-14')" + ) + ) + _run( + database.execute( + "INSERT INTO task_definitions (title, child_id, " + "schedule_rule_id, created_at) " + "VALUES ('Dishes', 1, 1, '2026-09-14T00:00:00+00:00')" + ) + ) + _run( + database.execute( + "INSERT INTO task_instances (definition_id, child_id, " + "due_date, generated_at) " + "VALUES (1, 1, '2026-09-15', '2026-09-14T00:00:00+00:00')" + ) + ) + + +def test_legacy_task_tables_are_renamed_and_keep_their_rows(tmp_path) -> None: + database = _open_db(tmp_path / "legacy.db") + try: + _seed_legacy_database(database) + final = _migrate(database) + assert final == len(MIGRATIONS) + assert _version(database) == len(MIGRATIONS) + tables = _tables(database) + assert "task_definitions" not in tables + assert "task_instances" not in tables + assert {"quest_definitions", "quest_instances"} <= tables + # Every seeded row survived the rename. + assert _run(database.fetch_one("SELECT COUNT(*) FROM children")) == (1,) + assert ( + _run( + database.fetch_one( + "SELECT title FROM quest_definitions WHERE id = 1" + ) + ) + == ("Dishes",) + ) + assert ( + _run( + database.fetch_one( + "SELECT due_date FROM quest_instances WHERE id = 1" + ) + ) + == ("2026-09-15",) + ) + # The untouched completion_events table still exists and is empty. + assert "completion_events" in tables + assert ( + _run(database.fetch_one("SELECT COUNT(*) FROM completion_events")) + == (0,) + ) + finally: + _run(database.close()) + + +def test_renamed_instances_foreign_key_follows_the_rename(tmp_path) -> None: + database = _open_db(tmp_path / "legacy-fk.db") + try: + _seed_legacy_database(database) + _migrate(database) + fks = _run(database.fetch_all("PRAGMA foreign_key_list(quest_instances)")) + referenced = {row[2] for row in fks} + assert "quest_definitions" in referenced + assert "task_definitions" not in referenced + # The renamed table is live: a new row validates its FKs. + _run( + database.execute( + "INSERT INTO children (display_name, created_at) " + "VALUES ('Ben', '2026-09-14T00:00:00+00:00')" + ) + ) + with pytest.raises(sqlite3.IntegrityError): + _run( + database.execute( + "INSERT INTO quest_instances (definition_id, child_id, " + "due_date, generated_at) " + "VALUES (999, 2, '2026-09-16', " + "'2026-09-14T00:00:00+00:00')" + ) + ) + finally: + _run(database.close()) + + +def test_legacy_rename_is_idempotent_across_restarts(tmp_path) -> None: + database = _open_db(tmp_path / "legacy-restart.db") + try: + _seed_legacy_database(database) + _migrate(database) + final = _migrate(database) + assert final == len(MIGRATIONS) + tables = _tables(database) + assert "quest_definitions" in tables + assert "task_definitions" not in tables + assert ( + _run(database.fetch_one("SELECT COUNT(*) FROM quest_definitions")) + == (1,) + ) + finally: + _run(database.close()) + + +def test_fresh_database_never_holds_legacy_table_names(tmp_path) -> None: + database = _open_db(tmp_path / "fresh-legacy.db") + try: + _migrate(database) + tables = _tables(database) + assert not any(name.startswith("task_") for name in tables) + assert _version(database) == len(MIGRATIONS) + finally: + _run(database.close()) # --------------------------------------------------------------------------- @@ -162,8 +402,8 @@ def test_second_run_applies_nothing_and_keeps_rows(tmp_path) -> None: ) ) final = _migrate(database) - assert final == 1 - assert _version(database) == 1 + assert final == len(MIGRATIONS) + assert _version(database) == len(MIGRATIONS) assert _counts(database)["children"] == 1 assert _counts(database)["admin_users"] == 1 finally: @@ -228,7 +468,7 @@ def test_noop_run_is_logged_at_debug_level(caplog, tmp_path) -> None: with caplog.at_level(logging.DEBUG, logger="custom_components.nestquest.migrations"): _migrate(database) assert any( - "already at version 1" in r.getMessage() + f"already at version {len(MIGRATIONS)}" in r.getMessage() for r in caplog.records ) finally: @@ -241,9 +481,17 @@ def test_noop_run_is_logged_at_debug_level(caplog, tmp_path) -> None: def _failing_migrations(prior_statements: list[str]) -> list[list[str]]: - """Two-migration list where migration 2 fails mid-application.""" + """Three-migration list where migration 3 fails mid-application. + + Migration 2 is a harmless no-op filler so the real runner — which + has already stamped version 2 by the time these tests re-run it — + still has a pending migration to fail on. + """ return [ prior_statements, + [ + "CREATE TABLE IF NOT EXISTS filler_table (id INTEGER PRIMARY KEY)" + ], [ "CREATE TABLE IF NOT EXISTS later_table (id INTEGER PRIMARY KEY)", "CREATE TABLE broken (", @@ -254,20 +502,20 @@ def _failing_migrations(prior_statements: list[str]) -> list[list[str]]: 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. + # Manually simulate a fully-migrated database: run the real + # migration list, which stamps the latest version. _migrate(database) version_row = _run( database.fetch_one(f"SELECT version FROM {VERSION_TABLE}") ) - assert version_row == (1,) + assert version_row == (len(MIGRATIONS),) 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 _version(database) == len(MIGRATIONS) assert "later_table" not in _tables(database) assert _counts(database)["children"] == 0 assert _counts(database)["completion_events"] == 0 @@ -275,21 +523,24 @@ def test_failing_migration_rolls_back_to_prior_version(tmp_path) -> None: _run(database.close()) -def test_failing_migration_on_fresh_file_leaves_version_0(tmp_path) -> None: +def test_failing_migration_on_fresh_file_leaves_last_good_version( + 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. + # The fresh file reaches the failing migration (the last one): + # earlier migrations commit and stamp before it fails and rolls + # back. The run aborts at the last fully-applied migration — + # not at version 0. + broken = _failing_migrations(SCHEMA_V1_STATEMENTS) 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. + _migrate(database, broken) + assert _version(database) == len(broken) - 1 + # The failing migration's tables must not survive the 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) + assert "quest_instances" in _tables(database) finally: _run(database.close()) @@ -302,12 +553,12 @@ def test_failed_migration_can_be_retried_after_fix(tmp_path) -> None: with pytest.raises(sqlite3.OperationalError): _migrate(database, broken) # "Fix" the migration and retry: it now applies cleanly. - broken[1] = [ + 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 final == len(broken) + assert _version(database) == len(broken) assert "later_table" in _tables(database) finally: _run(database.close()) @@ -575,7 +826,7 @@ async def test_setup_entry_migrates_fresh_database(hass, make_entry) -> None: entry = await _setup_entry(hass, make_entry(), hass.registry) database = entry.runtime_data.database - assert await read_schema_version(database) == 1 + assert await read_schema_version(database) == len(MIGRATIONS) # The v1 tables exist because the runner applied migration 1. names = { row[0] @@ -626,7 +877,7 @@ async def test_options_reload_reopens_and_rechecks_version( database_b = entry_reloaded.runtime_data.database assert database_b is not database_a try: - assert await read_schema_version(database_b) == 1 + assert await read_schema_version(database_b) == len(MIGRATIONS) row = await database_b.fetch_one("SELECT COUNT(*) FROM children") assert row == (1,) finally: diff --git a/tests/test_schema.py b/tests/test_schema.py index 245a87c..b95e68b 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -15,8 +15,8 @@ from custom_components.nestquest.schema import ( SCHEMA_V1_PRESENCE_SCHEDULES_DDL, SCHEMA_V1_SCHEDULE_RULES_DDL, SCHEMA_V1_STATEMENTS, - SCHEMA_V1_TASK_DEFINITIONS_DDL, - SCHEMA_V1_TASK_INSTANCES_DDL, + SCHEMA_V1_QUEST_DEFINITIONS_DDL, + SCHEMA_V1_QUEST_INSTANCES_DDL, async_apply_ddl, ) @@ -91,7 +91,7 @@ def _insert_definition(database, child_id=1, rule_id=1, **overrides): placeholders = ", ".join("?" for _ in values) result = _run( database.execute( - f"INSERT INTO task_definitions ({columns}) VALUES ({placeholders})", + f"INSERT INTO quest_definitions ({columns}) VALUES ({placeholders})", tuple(values.values()), ) ) @@ -161,10 +161,10 @@ def test_schema_v1_statements_compose_the_six_tables() -> None: *SCHEMA_V1_CHILDREN_DDL, *SCHEMA_V1_ADMIN_USERS_DDL, *SCHEMA_V1_SCHEDULE_RULES_DDL, - *SCHEMA_V1_TASK_DEFINITIONS_DDL, + *SCHEMA_V1_QUEST_DEFINITIONS_DDL, *SCHEMA_V1_PRESENCE_SCHEDULES_DDL, *SCHEMA_V1_PRESENCE_OVERRIDES_DDL, - *SCHEMA_V1_TASK_INSTANCES_DDL, + *SCHEMA_V1_QUEST_INSTANCES_DDL, *SCHEMA_V1_COMPLETION_EVENTS_DDL, ] @@ -256,17 +256,17 @@ def test_schedule_rules_table_exists_after_applying_ddl(tmp_path) -> None: _run(database.close()) -def test_task_definitions_table_exists_after_applying_ddl(tmp_path) -> None: +def test_quest_definitions_table_exists_after_applying_ddl(tmp_path) -> None: database = _open_db(tmp_path / "definitions.db") try: _apply(database) row = _run( database.fetch_one( "SELECT name FROM sqlite_master WHERE type = 'table' " - "AND name = 'task_definitions'" + "AND name = 'quest_definitions'" ) ) - assert row is not None and row[0] == "task_definitions" + assert row is not None and row[0] == "quest_definitions" finally: _run(database.close()) @@ -292,11 +292,11 @@ def test_schedule_rules_columns_types_and_constraints(tmp_path) -> None: _run(database.close()) -def test_task_definitions_columns_types_and_constraints(tmp_path) -> None: +def test_quest_definitions_columns_types_and_constraints(tmp_path) -> None: database = _open_db(tmp_path / "definitions-columns.db") try: _apply(database) - columns = _run(database.fetch_all("PRAGMA table_info(task_definitions)")) + columns = _run(database.fetch_all("PRAGMA table_info(quest_definitions)")) assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [ ("id", "INTEGER", 0, None, 1), ("title", "TEXT", 1, None, 0), @@ -312,11 +312,11 @@ def test_task_definitions_columns_types_and_constraints(tmp_path) -> None: _run(database.close()) -def test_task_definitions_foreign_keys_declared(tmp_path) -> None: +def test_quest_definitions_foreign_keys_declared(tmp_path) -> None: database = _open_db(tmp_path / "definitions-fks.db") try: _apply(database) - fks = _run(database.fetch_all("PRAGMA foreign_key_list(task_definitions)")) + fks = _run(database.fetch_all("PRAGMA foreign_key_list(quest_definitions)")) declared = {(row[2], row[3], row[4]) for row in fks} assert declared == { ("children", "child_id", "id"), @@ -519,7 +519,7 @@ def test_failed_statement_rolls_back_whole_application(tmp_path) -> None: # --------------------------------------------------------------------------- -# schedule_rules and task_definitions constraint enforcement +# schedule_rules and quest_definitions constraint enforcement # --------------------------------------------------------------------------- @@ -813,7 +813,7 @@ def test_schedule_rules_start_date_not_null_enforced(tmp_path) -> None: _run(database.close()) -def test_task_definitions_insert_requires_existing_child_and_rule( +def test_quest_definitions_insert_requires_existing_child_and_rule( tmp_path, ) -> None: database = _open_db(tmp_path / "definitions-fk-unknown.db") @@ -831,7 +831,7 @@ def test_task_definitions_insert_requires_existing_child_and_rule( ) row = _run( database.fetch_one( - "SELECT title, child_id, schedule_rule_id FROM task_definitions " + "SELECT title, child_id, schedule_rule_id FROM quest_definitions " "WHERE id = ?", (definition_id,), ) @@ -841,7 +841,7 @@ def test_task_definitions_insert_requires_existing_child_and_rule( _run(database.close()) -def test_task_definitions_unknown_child_id_fails(tmp_path) -> None: +def test_quest_definitions_unknown_child_id_fails(tmp_path) -> None: database = _open_db(tmp_path / "definitions-bad-child.db") try: _apply(database) @@ -852,7 +852,7 @@ def test_task_definitions_unknown_child_id_fails(tmp_path) -> None: _run(database.close()) -def test_task_definitions_unknown_schedule_rule_id_fails(tmp_path) -> None: +def test_quest_definitions_unknown_schedule_rule_id_fails(tmp_path) -> None: database = _open_db(tmp_path / "definitions-bad-rule.db") try: _apply(database) @@ -868,7 +868,7 @@ def test_task_definitions_unknown_schedule_rule_id_fails(tmp_path) -> None: _run(database.close()) -def test_task_definitions_is_active_check_enforced(tmp_path) -> None: +def test_quest_definitions_is_active_check_enforced(tmp_path) -> None: database = _open_db(tmp_path / "definitions-is-active.db") try: _apply(database) @@ -880,7 +880,7 @@ def test_task_definitions_is_active_check_enforced(tmp_path) -> None: @pytest.mark.parametrize("bad_is_active", ["abc", 1.5]) -def test_task_definitions_is_active_non_integer_fails( +def test_quest_definitions_is_active_non_integer_fails( tmp_path, bad_is_active ) -> None: database = _open_db(tmp_path / "definitions-is-active-typeof.db") @@ -901,7 +901,7 @@ def test_task_definitions_is_active_non_integer_fails( _run(database.close()) -def test_task_definitions_title_not_null_enforced(tmp_path) -> None: +def test_quest_definitions_title_not_null_enforced(tmp_path) -> None: database = _open_db(tmp_path / "definitions-title.db") try: _apply(database) @@ -912,7 +912,7 @@ def test_task_definitions_title_not_null_enforced(tmp_path) -> None: _run(database.close()) -def test_task_definitions_created_at_not_null_enforced(tmp_path) -> None: +def test_quest_definitions_created_at_not_null_enforced(tmp_path) -> None: database = _open_db(tmp_path / "definitions-created-at.db") try: _apply(database) @@ -925,7 +925,7 @@ def test_task_definitions_created_at_not_null_enforced(tmp_path) -> None: _run(database.close()) -def test_task_definitions_defaults_apply_on_insert(tmp_path) -> None: +def test_quest_definitions_defaults_apply_on_insert(tmp_path) -> None: database = _open_db(tmp_path / "definitions-defaults.db") try: _apply(database) @@ -941,7 +941,7 @@ def test_task_definitions_defaults_apply_on_insert(tmp_path) -> None: ) row = _run( database.fetch_one( - "SELECT is_active, due_time FROM task_definitions WHERE id = ?", + "SELECT is_active, due_time FROM quest_definitions WHERE id = ?", (definition_id,), ) ) @@ -969,7 +969,7 @@ def test_applying_extended_ddl_twice_keeps_new_table_rows(tmp_path) -> None: _insert_definition(database, child_id=1, rule_id=1) _apply(database) counts = [] - for table in ("children", "schedule_rules", "task_definitions"): + for table in ("children", "schedule_rules", "quest_definitions"): row = _run(database.fetch_one(f"SELECT COUNT(*) FROM {table}")) counts.append(row[0]) assert counts == [1, 1, 1] @@ -1406,7 +1406,7 @@ def test_applying_presence_ddl_twice_keeps_rows(tmp_path) -> None: # --------------------------------------------------------------------------- -# task_instances and completion_events: helpers +# quest_instances and completion_events: helpers # --------------------------------------------------------------------------- @@ -1428,7 +1428,7 @@ def _insert_instance(database, definition_id=1, **overrides): placeholders = ", ".join("?" for _ in values) result = _run( database.execute( - f"INSERT INTO task_instances ({columns}) VALUES ({placeholders})", + f"INSERT INTO quest_instances ({columns}) VALUES ({placeholders})", tuple(values.values()), ) ) @@ -1466,28 +1466,28 @@ def _insert_event(database, instance_id=1, **overrides): def _setup_definition(database) -> None: - """Create one child, one schedule rule and one task definition.""" + """Create one child, one schedule rule and one quest definition.""" _child(database) _insert_rule(database) _insert_definition(database, child_id=1, rule_id=1) # --------------------------------------------------------------------------- -# task_instances and completion_events: DDL shape +# quest_instances and completion_events: DDL shape # --------------------------------------------------------------------------- -def test_task_instances_table_exists_after_applying_ddl(tmp_path) -> None: +def test_quest_instances_table_exists_after_applying_ddl(tmp_path) -> None: database = _open_db(tmp_path / "task-instances.db") try: _apply(database) row = _run( database.fetch_one( "SELECT name FROM sqlite_master WHERE type = 'table' " - "AND name = 'task_instances'" + "AND name = 'quest_instances'" ) ) - assert row is not None and row[0] == "task_instances" + assert row is not None and row[0] == "quest_instances" finally: _run(database.close()) @@ -1507,11 +1507,11 @@ def test_completion_events_table_exists_after_applying_ddl(tmp_path) -> None: _run(database.close()) -def test_task_instances_columns_types_and_constraints(tmp_path) -> None: +def test_quest_instances_columns_types_and_constraints(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-columns.db") try: _apply(database) - columns = _run(database.fetch_all("PRAGMA table_info(task_instances)")) + columns = _run(database.fetch_all("PRAGMA table_info(quest_instances)")) # cid, name, type, notnull, dflt_value, pk assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [ ("id", "INTEGER", 0, None, 1), @@ -1544,14 +1544,14 @@ def test_completion_events_columns_types_and_constraints(tmp_path) -> None: _run(database.close()) -def test_task_instances_foreign_keys_declared(tmp_path) -> None: +def test_quest_instances_foreign_keys_declared(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-fks.db") try: _apply(database) - fks = _run(database.fetch_all("PRAGMA foreign_key_list(task_instances)")) + fks = _run(database.fetch_all("PRAGMA foreign_key_list(quest_instances)")) declared = {(row[2], row[3], row[4]) for row in fks} assert declared == { - ("task_definitions", "definition_id", "id"), + ("quest_definitions", "definition_id", "id"), ("children", "child_id", "id"), } finally: @@ -1565,14 +1565,14 @@ def test_completion_events_foreign_keys_declared(tmp_path) -> None: fks = _run(database.fetch_all("PRAGMA foreign_key_list(completion_events)")) declared = {(row[2], row[3], row[4]) for row in fks} assert declared == { - ("task_instances", "instance_id", "id"), + ("quest_instances", "instance_id", "id"), ("children", "child_id", "id"), } finally: _run(database.close()) -def test_task_instances_unique_index_on_definition_and_date(tmp_path) -> None: +def test_quest_instances_unique_index_on_definition_and_date(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-unique.db") try: _apply(database) @@ -1580,7 +1580,7 @@ def test_task_instances_unique_index_on_definition_and_date(tmp_path) -> None: rows = _run( database.fetch_all( "SELECT name, sql FROM sqlite_master WHERE type = 'index' " - "AND tbl_name = 'task_instances'" + "AND tbl_name = 'quest_instances'" ) ) implicit = [(name, sql) for name, sql in rows if sql is None] @@ -1599,7 +1599,7 @@ def test_task_instances_unique_index_on_definition_and_date(tmp_path) -> None: row = _run( database.fetch_one( "SELECT COUNT(DISTINCT name) FROM pragma_index_list" - "('task_instances') WHERE \"unique\" = 1" + "('quest_instances') WHERE \"unique\" = 1" ) ) assert row == (1,) @@ -1608,11 +1608,11 @@ def test_task_instances_unique_index_on_definition_and_date(tmp_path) -> None: # --------------------------------------------------------------------------- -# task_instances: constraint enforcement +# quest_instances: constraint enforcement # --------------------------------------------------------------------------- -def test_task_instances_valid_insert_round_trips(tmp_path) -> None: +def test_quest_instances_valid_insert_round_trips(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-valid.db") try: _apply(database) @@ -1621,7 +1621,7 @@ def test_task_instances_valid_insert_round_trips(tmp_path) -> None: row = _run( database.fetch_one( "SELECT definition_id, child_id, due_date, due_time, generated_at " - "FROM task_instances WHERE id = ?", + "FROM quest_instances WHERE id = ?", (instance_id,), ) ) @@ -1630,7 +1630,7 @@ def test_task_instances_valid_insert_round_trips(tmp_path) -> None: _run(database.close()) -def test_task_instances_due_time_round_trips(tmp_path) -> None: +def test_quest_instances_due_time_round_trips(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-due-time.db") try: _apply(database) @@ -1640,7 +1640,7 @@ def test_task_instances_due_time_round_trips(tmp_path) -> None: ) row = _run( database.fetch_one( - "SELECT due_time FROM task_instances WHERE id = ?", + "SELECT due_time FROM quest_instances WHERE id = ?", (instance_id,), ) ) @@ -1649,7 +1649,7 @@ def test_task_instances_due_time_round_trips(tmp_path) -> None: _run(database.close()) -def test_task_instances_duplicate_definition_date_fails(tmp_path) -> None: +def test_quest_instances_duplicate_definition_date_fails(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-duplicate.db") try: _apply(database) @@ -1661,7 +1661,7 @@ def test_task_instances_duplicate_definition_date_fails(tmp_path) -> None: _run(database.close()) -def test_task_instances_same_date_different_definitions_allowed( +def test_quest_instances_same_date_different_definitions_allowed( tmp_path, ) -> None: database = _open_db(tmp_path / "task-instances-multi-def.db") @@ -1678,26 +1678,26 @@ def test_task_instances_same_date_different_definitions_allowed( _insert_definition(database, child_id=2, rule_id=rule_id) _insert_instance(database, definition_id=1, due_date="2026-09-14") _insert_instance(database, definition_id=2, due_date="2026-09-14") - row = _run(database.fetch_one("SELECT COUNT(*) FROM task_instances")) + row = _run(database.fetch_one("SELECT COUNT(*) FROM quest_instances")) assert row == (2,) finally: _run(database.close()) -def test_task_instances_same_definition_different_dates_allowed(tmp_path) -> None: +def test_quest_instances_same_definition_different_dates_allowed(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-multi-date.db") try: _apply(database) _setup_definition(database) _insert_instance(database, definition_id=1, due_date="2026-09-14") _insert_instance(database, definition_id=1, due_date="2026-09-15") - row = _run(database.fetch_one("SELECT COUNT(*) FROM task_instances")) + row = _run(database.fetch_one("SELECT COUNT(*) FROM quest_instances")) assert row == (2,) finally: _run(database.close()) -def test_task_instances_unknown_definition_fails(tmp_path) -> None: +def test_quest_instances_unknown_definition_fails(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-bad-def.db") try: _apply(database) @@ -1708,7 +1708,7 @@ def test_task_instances_unknown_definition_fails(tmp_path) -> None: _run(database.close()) -def test_task_instances_unknown_child_fails(tmp_path) -> None: +def test_quest_instances_unknown_child_fails(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-bad-child.db") try: _apply(database) @@ -1719,7 +1719,7 @@ def test_task_instances_unknown_child_fails(tmp_path) -> None: _run(database.close()) -def test_task_instances_due_date_not_null_enforced(tmp_path) -> None: +def test_quest_instances_due_date_not_null_enforced(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-due-null.db") try: _apply(database) @@ -1730,7 +1730,7 @@ def test_task_instances_due_date_not_null_enforced(tmp_path) -> None: _run(database.close()) -def test_task_instances_generated_at_not_null_enforced(tmp_path) -> None: +def test_quest_instances_generated_at_not_null_enforced(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-generated-null.db") try: _apply(database) @@ -2026,8 +2026,8 @@ def test_applying_full_ddl_twice_keeps_rows(tmp_path) -> None: for table in ( "children", "schedule_rules", - "task_definitions", - "task_instances", + "quest_definitions", + "quest_instances", "completion_events", ): row = _run(database.fetch_one(f"SELECT COUNT(*) FROM {table}")) diff --git a/tests/test_startup_db.py b/tests/test_startup_db.py index f6620b5..da77d61 100644 --- a/tests/test_startup_db.py +++ b/tests/test_startup_db.py @@ -18,6 +18,7 @@ from custom_components.nestquest import ( ) from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.migrations import ( + MIGRATIONS, VERSION_TABLE, apply_migrations, ) @@ -114,12 +115,13 @@ def test_missing_file_opens_fresh(tmp_path) -> None: assert not db_path.exists() database = _run(_async_open_database(_make_hass_mock(), db_path)) try: - # The file was created fresh and migrations stamped version 1. + # The file was created fresh and migrations stamped the + # latest schema version. assert db_path.exists() row = _run( database.fetch_one(f"SELECT version FROM {VERSION_TABLE}") ) - assert row == (1,) + assert row == (len(MIGRATIONS),) finally: _run(database.close()) @@ -151,7 +153,7 @@ def test_valid_file_opens_and_migrations_noop(tmp_path) -> None: row = _run( database.fetch_one(f"SELECT version FROM {VERSION_TABLE}") ) - assert row == (1,) + assert row == (len(MIGRATIONS),) finally: _run(database.close()) # Opening a valid file must not rewrite it (the no-op path). @@ -401,7 +403,7 @@ async def test_setup_with_missing_file_creates_and_loads( row = await database.fetch_one( f"SELECT version FROM {VERSION_TABLE}" ) - assert row == (1,) + assert row == (len(MIGRATIONS),) finally: await database.close() assert await async_unload_entry(hass, entry) is True -- 2.55.0 From 25f0a43e16d662dc599a6b21aeecbf165f7936d6 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 09:59:29 -0400 Subject: [PATCH 02/42] Multi-assignee definitions via quest_definition_assignees (D-008) Per IMPLEMENTATION-ORDER.md 1.3: a quest_definition_assignees table (definition_id, child_id) with a composite primary key and FKs to quest_definitions and children replaces the single child_id column on quest_definitions. - schema.py: definitions DDL loses child_id; new assignees table joins SCHEMA_V1_STATEMENTS; the definitions table body is extracted to QUEST_DEFINITIONS_TABLE_SQL so the rebuild migration cannot drift from the canonical shape. - migrations.py: callable migrations now own their transaction and stamp via stamp_schema_version(database, version), because the rebuild must bracket itself with PRAGMA foreign_keys OFF/ON. New migration 3 creates the assignees table and, when a pre-D-008 quest_definitions still carries child_id, rebuilds the table via SQLite's canonical procedure (create, copy, move assignees, drop, rename), verifies PRAGMA foreign_key_check inside the transaction, and stamps. Migration 2 converted to the new callable contract. - dao_rules.py: QuestDefinitionsDao.create drops the child_id argument and applies assignee_child_ids atomically; set_assignee is replaced by add_assignee (idempotent, active-child validated under the connection lock), remove_assignee and list_assignees; list_by_child reads through the assignees table. - dao_instances.py: upsert validates the child against quest_definition_assignees instead of the definitions column. - tests: schema shape tests for the assignees table (columns, PK, FKs, duplicate/unknown constraints); DAO tests rewritten for the new assignment API; migration tests for the v2 -> v3 rebuild (rows and instances survive, child_id gone, foreign_key_check clean). --- custom_components/nestquest/dao_instances.py | 19 +- custom_components/nestquest/dao_rules.py | 187 +++++++++----- custom_components/nestquest/migrations.py | 255 ++++++++++++++----- custom_components/nestquest/schema.py | 44 +++- tests/test_dao_instances.py | 29 ++- tests/test_dao_matrix.py | 25 +- tests/test_dao_rules.py | 208 ++++++++++----- tests/test_migrations.py | 181 ++++++++++++- tests/test_schema.py | 196 ++++++++++++-- 9 files changed, 883 insertions(+), 261 deletions(-) diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py index 4c78a49..43c8997 100644 --- a/custom_components/nestquest/dao_instances.py +++ b/custom_components/nestquest/dao_instances.py @@ -19,7 +19,9 @@ Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on whose instance already exists updates nothing that matters (child_id/due_time are refreshed only in the degenerate re-generation case where they changed) and never duplicates a row. The schema's -UNIQUE(definition_id, due_date) is the last line of defense. +UNIQUE(definition_id, due_date) is the last line of defense. The +upsert refuses a child that is not an assignee of the definition +(D-008 multi-assignee model). ``delete_future_uncompleted`` implements the Feature 06/07 rule that reassignment and schedule edits regenerate future instances: it @@ -207,18 +209,23 @@ class QuestInstancesDao: f"{today!r}" ) definition = await self._database.fetch_one( - "SELECT child_id FROM quest_definitions WHERE id = ?", + "SELECT 1 FROM quest_definitions WHERE id = ?", (definition_id,), ) if definition is None: raise ValueError( f"quest definition {definition_id} does not exist" ) - if definition[0] != child_id: + assignee = await self._database.fetch_one( + "SELECT 1 FROM quest_definition_assignees " + "WHERE definition_id = ? AND child_id = ?", + (definition_id, child_id), + ) + if assignee is None: raise ValueError( - f"quest definition {definition_id} is assigned to " - f"child {definition[0]}, not {child_id}; instance " - "must carry the definition's assignee" + f"quest definition {definition_id} is not " + f"assigned to child {child_id}; instance must " + "carry an assigned child" ) child = await self._database.fetch_one( "SELECT 1 FROM children WHERE id = ?", (child_id,) diff --git a/custom_components/nestquest/dao_rules.py b/custom_components/nestquest/dao_rules.py index 494dd03..291febb 100644 --- a/custom_components/nestquest/dao_rules.py +++ b/custom_components/nestquest/dao_rules.py @@ -1,8 +1,10 @@ -"""Typed DAO layer for the schedule_rules and quest_definitions tables. +"""Typed DAO layer for the schedule_rules, quest_definitions and +quest_definition_assignees tables. -All SQL for ``schedule_rules`` and ``quest_definitions`` lives in this -module per the feature guardrails: callers get typed dataclasses back -and never see raw rows or SQL. Every method is async and runs through +All SQL for ``schedule_rules``, ``quest_definitions`` and +``quest_definition_assignees`` lives in this module per the feature +guardrails: callers get typed dataclasses back and never see raw rows +or SQL. Every method is async and runs through :class:`~.db.NestQuestDatabase`, so each statement executes on the HA executor and the event loop never blocks. @@ -13,25 +15,29 @@ Guardrail mapping: not cascaded. - ``quest_definitions`` has no delete path at all: definitions are deactivated, never hard-deleted, and history must survive. -- ``set_assignee`` changes future instances only — the DAO writes the - definition row and nothing else; reassignment never rewrites +- Assignment is multi-assignee (D-008): :meth:`add_assignee` and + :meth:`remove_assignee` change future instances only — the DAO writes + the assignee table and nothing else; assignment edits never rewrite existing instances or completion history (Feature 06 guardrail). - Validation of rule well-formedness and child activeness lives partly in the schema CHECKs (rule-type coherence) and partly in - :meth:`QuestDefinitionsDao.create` (active child, rule existence); - no permission checks here, that is the Feature 09 gate's job. + :meth:`QuestDefinitionsDao.create` (rule existence) and + :meth:`QuestDefinitionsDao.add_assignee` (active child); no + permission checks here, that is the Feature 09 gate's job. -Concurrency: ``delete_rule_if_unreferenced`` performs its reference -check and DELETE inside one transaction under the connection-scoped -lock shared with :mod:`.dao_children`, so a definition referencing the -rule cannot appear between check and delete on this connection. +Concurrency: ``delete_rule_if_unreferenced`` and the assignee +mutations perform their checks and writes inside one transaction under +the connection-scoped lock shared with :mod:`.dao_children`, so a +definition referencing the rule (or an assignee appearing) cannot slip +in between check and write on this connection. """ from __future__ import annotations import datetime from dataclasses import dataclass -from .dao_children import _connection_lock +from .dao_children import ChildRecord, _child_from_row, _connection_lock +from .dao_children import _CHILD_COLUMNS from .db import NestQuestDatabase #: Sentinel distinguishing "argument omitted" from "explicit SQL NULL" @@ -82,13 +88,12 @@ class ScheduleRuleRecord: @dataclass(frozen=True) class QuestDefinitionRecord: - """One row of ``quest_definitions``.""" + """One row of ``quest_definitions`` (without its assignees).""" id: int title: str description: str | None icon: str | None - child_id: int schedule_rule_id: int due_time: str | None is_active: bool @@ -100,7 +105,7 @@ _RULE_COLUMNS = ( "month, start_date, end_date" ) _DEFINITION_COLUMNS = ( - "id, title, description, icon, child_id, schedule_rule_id, due_time, " + "id, title, description, icon, schedule_rule_id, due_time, " "is_active, created_at" ) @@ -125,11 +130,10 @@ def _definition_from_row(row: tuple) -> QuestDefinitionRecord: title=row[1], description=row[2], icon=row[3], - child_id=row[4], - schedule_rule_id=row[5], - due_time=row[6], - is_active=bool(row[7]), - created_at=row[8], + schedule_rule_id=row[4], + due_time=row[5], + is_active=bool(row[6]), + created_at=row[7], ) @@ -272,11 +276,14 @@ class ScheduleRulesDao: class QuestDefinitionsDao: - """Typed async access to the ``quest_definitions`` table. + """Typed async access to ``quest_definitions`` and its assignees. No delete method exists: definitions are deactivated via :meth:`set_active`, never hard-deleted, so completion history keeps - its references (feature guardrail). + its references (feature guardrail). Assignment is multi-assignee + (D-008): the definition row carries no child; assignees live in + ``quest_definition_assignees`` and are managed explicitly so the + operations stay separately loggable and permission-gateable. """ def __init__(self, database: NestQuestDatabase) -> None: @@ -285,7 +292,6 @@ class QuestDefinitionsDao: async def create( self, title: str, - child_id: int, schedule_rule_id: int, created_at: str, *, @@ -293,53 +299,55 @@ class QuestDefinitionsDao: icon: str | None = None, due_time: str | None = None, is_active: bool = True, + assignee_child_ids: list[int] | None = None, ) -> QuestDefinitionRecord: """Insert one definition and return the record as stored. - Validates the assignment target as part of the same serialized - transaction as the insert: the child must exist and be active, - and the rule must exist, AT INSERT TIME — a child deactivated - or a rule deleted concurrently cannot slip between the checks - and the INSERT, which would otherwise create an invalid - assignment or surface as a raw FK IntegrityError instead of - the documented ValueError. An INACTIVE child would pass the - schema foreign keys, which is why the activeness check lives - here. + Validates the schedule rule as part of the same serialized + transaction as the insert: the rule must exist AT INSERT TIME — + a rule deleted concurrently cannot slip between the check and + the INSERT. ``assignee_child_ids`` (each child must exist and + be active) is applied in the same transaction, so a definition + can never be observed with a stale or missing assignee set; an + empty list or None creates an unassigned definition, which + callers may fill via :meth:`add_assignee`. """ async with _connection_lock(self._database): async with self._database.transaction(): - await self._validate_assignable( - child_id, schedule_rule_id - ) + await self._validate_rule_exists(schedule_rule_id) + for child_id in assignee_child_ids or []: + await self._validate_assignable_child(child_id) result = await self._database.execute( "INSERT INTO quest_definitions (title, description, " - "icon, child_id, schedule_rule_id, due_time, " - "is_active, created_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "icon, schedule_rule_id, due_time, is_active, " + "created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", ( title, description, icon, - child_id, schedule_rule_id, due_time, int(is_active), created_at, ), ) - definition = await self.get(result.lastrowid) + definition_id = result.lastrowid + for child_id in assignee_child_ids or []: + await self._database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (?, ?)", + (definition_id, child_id), + ) + definition = await self.get(definition_id) assert definition is not None return definition - async def _validate_assignable( - self, child_id: int, schedule_rule_id: int - ) -> None: - """Raise ValueError unless child (active) and rule both exist. + async def _validate_rule_exists(self, schedule_rule_id: int) -> None: + """Raise ValueError unless the rule exists. MUST be called inside the connection lock so the verdict cannot go stale before the caller's INSERT commits. """ - await self._validate_assignable_child(child_id) rule = await self._database.fetch_one( "SELECT 1 FROM schedule_rules WHERE id = ?", (schedule_rule_id,), @@ -359,10 +367,17 @@ class QuestDefinitionsDao: return _definition_from_row(row) if row is not None else None async def list_by_child(self, child_id: int) -> list[QuestDefinitionRecord]: - """Return all definitions assigned to ``child_id``, newest first.""" + """Return all definitions assigned to ``child_id``, newest first. + + Assignment is read through ``quest_definition_assignees`` + (D-008): a definition appears once per child regardless of how + many assignees share it. + """ rows = await self._database.fetch_all( f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions " - "WHERE child_id = ? ORDER BY id DESC", + "WHERE id IN (SELECT definition_id " + "FROM quest_definition_assignees WHERE child_id = ?) " + "ORDER BY id DESC", (child_id,), ) return [_definition_from_row(row) for row in rows] @@ -391,8 +406,9 @@ class QuestDefinitionsDao: SQL NULL, so callers can remove optional metadata (description, icon, due_time). Assignment and activation are deliberately NOT settable here: they have their own explicit - operations (:meth:`set_assignee`, :meth:`set_active`) so they - stay separately loggable and permission-gateable. + operations (:meth:`add_assignee`, :meth:`remove_assignee`, + :meth:`set_active`) so they stay separately loggable and + permission-gateable. """ assignments: list[str] = [] parameters: list[object] = [] @@ -427,32 +443,75 @@ class QuestDefinitionsDao: ) return result.rowcount - async def set_assignee( + async def add_assignee( self, definition_id: int, child_id: int - ) -> int: - """Reassign the definition to ``child_id``; rows updated (0 if absent). + ) -> None: + """Assign ``child_id`` to the definition (idempotent). - Validates the new assignee inside the same serialized - transaction as the UPDATE (existing and active AT ASSIGNMENT - TIME), so a child deactivated concurrently cannot become the - assignee. Changes future instances only: already-generated - instances and completion history are never rewritten here. + Validates the assignee inside the same serialized transaction + as the INSERT (existing and active AT ASSIGNMENT TIME), so a + child deactivated concurrently cannot become an assignee. + Assigning an already-assigned child is a no-op, mirroring the + composite primary key's storage-level guarantee. Changes + future instances only: already-generated instances and + completion history are never rewritten here. """ async with _connection_lock(self._database): async with self._database.transaction(): await self._validate_assignable_child(child_id) - result = await self._database.execute( - "UPDATE quest_definitions SET child_id = ? " - "WHERE id = ?", - (child_id, definition_id), + definition = await self._database.fetch_one( + "SELECT 1 FROM quest_definitions WHERE id = ?", + (definition_id,), ) - return result.rowcount + if definition is None: + raise ValueError( + f"quest definition {definition_id} does not exist" + ) + await self._database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (?, ?) " + "ON CONFLICT (definition_id, child_id) DO NOTHING", + (definition_id, child_id), + ) + + async def remove_assignee( + self, definition_id: int, child_id: int + ) -> bool: + """Remove the assignee link; True when a row was removed. + + Future materialization stops for this (definition, child) pair; + existing instances and completion history are never touched + here (Feature 06 guardrail). + """ + async with _connection_lock(self._database): + result = await self._database.execute( + "DELETE FROM quest_definition_assignees " + "WHERE definition_id = ? AND child_id = ?", + (definition_id, child_id), + ) + return result.rowcount > 0 + + async def list_assignees(self, definition_id: int) -> list[ChildRecord]: + """Return the definition's assignees as child records. + + Ordered by the children table's stable display order + (``sort_order``, then id) so callers render a deterministic + roster. A definition with no assignees returns []. + """ + rows = await self._database.fetch_all( + f"SELECT {_CHILD_COLUMNS} FROM children " + "WHERE id IN (SELECT child_id " + "FROM quest_definition_assignees WHERE definition_id = ?) " + "ORDER BY sort_order, id", + (definition_id,), + ) + return [_child_from_row(row) for row in rows] async def _validate_assignable_child(self, child_id: int) -> None: """Raise ValueError unless the child exists and is active. MUST be called inside the connection lock so the verdict cannot - go stale before the caller's UPDATE commits. + go stale before the caller's INSERT commits. """ child = await self._database.fetch_one( "SELECT is_active FROM children WHERE id = ?", (child_id,) diff --git a/custom_components/nestquest/migrations.py b/custom_components/nestquest/migrations.py index 1571739..0b000ef 100644 --- a/custom_components/nestquest/migrations.py +++ b/custom_components/nestquest/migrations.py @@ -20,15 +20,21 @@ instead of the legacy ``task_*`` spellings, and no compatibility shim exists. Dev machines that already hold a version-1 database with ``task_*`` tables are handled by migration 2, which detects the legacy table names and ``ALTER TABLE ... RENAME TO``s them to their current -names before anything else assumes the new model. The rename step runs -inside the same transaction as its version stamp, so a crash mid-rename -rolls both back together. +names before anything else assumes the new model. Migration 3 brings +pre-D-008 databases to the multi-assignee model: it creates +``quest_definition_assignees`` and, when ``quest_definitions`` still +carries a single ``child_id`` column, rebuilds the table without it, +copying each definition's assignee across. Each callable migration +runs inside its own transaction together with its version stamp, so a +crash mid-step rolls the statements and the stamp back together. Migration entries are either an ordered sequence of SQL statements or -an async callable taking the open database. Callables exist for steps -whose SQL depends on the database's current state (the legacy rename -only fires when a legacy table is actually present) and run under the -same per-migration transaction as statement lists. +an async callable taking ``(database, target_version)``. Callables +exist for steps whose SQL depends on the database's current state (a +legacy rename only fires when a legacy table is present; a rebuild only +when the old column shape is present) and own their transaction so they +can bracket it with connection pragmas the runner's uniform transaction +cannot express. Each migration's statements and its version stamp run inside ONE transaction, and the stamp is written as DELETE + INSERT rather than @@ -63,13 +69,21 @@ from collections.abc import Awaitable, Callable, Sequence from typing import Union from .db import NestQuestDatabase -from .schema import SCHEMA_V1_STATEMENTS +from .schema import ( + QUEST_DEFINITIONS_TABLE_SQL, + SCHEMA_V1_STATEMENTS, + SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, +) LOGGER = logging.getLogger(__name__) #: One migration entry: either an ordered sequence of SQL statements or -#: an async callable run under the entry's migration transaction. -MigrationStep = Union[Sequence[str], Callable[[NestQuestDatabase], Awaitable[None]]] +#: an async callable taking ``(database, target_version)`` that manages +#: its own transaction and stamps via :func:`stamp_schema_version`. +MigrationStep = Union[ + Sequence[str], + Callable[[NestQuestDatabase, int], Awaitable[None]], +] #: Metadata table holding the applied schema version. Prefixed with the #: integration domain so a config directory shared with other tools can @@ -94,6 +108,10 @@ VERSION_TABLE_DDL = f""" #: version ``n-1`` to version ``n``. MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS) +#: The assignees table DDL, shared with migration 1 via the schema +#: module so a rebuilt and a freshly created table cannot drift. +_ASSIGNEES_TABLE_DDL: str = SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL[0] + #: Legacy (pre-D-007) table name -> current table name. Order matters: #: the parent table renames first so the child's foreign key reference #: is rewritten while its parent's new name is already in place. @@ -103,7 +121,34 @@ LEGACY_TASK_TABLE_RENAMES: tuple[tuple[str, str], ...] = ( ) -async def _rename_legacy_task_tables(database: NestQuestDatabase) -> None: +async def stamp_schema_version( + database: NestQuestDatabase, version: int +) -> None: + """Write the schema version stamp (inside the caller's transaction). + + INSERT keyed on the singleton row (id = 1) with an upsert: a fresh + file has no row yet, so UPDATE alone would silently match zero rows + there and leave a migrated-but-unstamped database. + """ + await database.execute( + f"INSERT INTO {VERSION_TABLE} (id, version) " + "VALUES (1, ?) " + "ON CONFLICT (id) DO UPDATE SET version = excluded.version", + (version,), + ) + + +async def _table_has_column( + database: NestQuestDatabase, table: str, column: str +) -> bool: + """Return True when ``table`` currently carries ``column``.""" + rows = await database.fetch_all(f"PRAGMA table_info({table})") + return any(row[1] == column for row in rows) + + +async def _rename_legacy_task_tables( + database: NestQuestDatabase, target_version: int +) -> None: """Rename any surviving ``task_*`` tables to their ``quest_*`` names. Dev-machines-only path (D-007): a database stamped version 1 by the @@ -112,22 +157,96 @@ async def _rename_legacy_task_tables(database: NestQuestDatabase) -> None: nothing and this step is a no-op. Modern SQLite rewrites foreign key clauses in other tables to follow the rename, so the renamed schema is indistinguishable from a freshly created one apart from - internal autoindex names. Runs inside the migration transaction, - so the renames and the version stamp commit or roll back together. + internal autoindex names. Runs in its own transaction together + with the version stamp, so a crash rolls both back. """ - for legacy_name, current_name in LEGACY_TASK_TABLE_RENAMES: - row = await database.fetch_one( - "SELECT 1 FROM sqlite_master WHERE type = 'table' " - "AND name = ?", - (legacy_name,), - ) - if row is not None: - await database.execute( - f'ALTER TABLE "{legacy_name}" RENAME TO "{current_name}"' - ) - LOGGER.info( - "Renamed legacy table %s to %s", legacy_name, current_name + async with database.transaction(): + for legacy_name, current_name in LEGACY_TASK_TABLE_RENAMES: + row = await database.fetch_one( + "SELECT 1 FROM sqlite_master WHERE type = 'table' " + "AND name = ?", + (legacy_name,), ) + if row is not None: + await database.execute( + f'ALTER TABLE "{legacy_name}" RENAME TO "{current_name}"' + ) + LOGGER.info( + "Renamed legacy table %s to %s", + legacy_name, + current_name, + ) + await stamp_schema_version(database, target_version) + + +async def _add_definition_assignees( + database: NestQuestDatabase, target_version: int +) -> None: + """Bring definitions to the multi-assignee model (D-008). + + Two shapes arrive here: + + - Fresh or renamed databases whose ``quest_definitions`` already + matches the current canonical DDL (no ``child_id`` column): only + the assignees table needs creating (``IF NOT EXISTS`` is a no-op + when migration 1 already made it). + - Databases stamped at version 2 whose ``quest_definitions`` still + carries the single ``child_id`` column: the table is rebuilt via + SQLite's canonical procedure — create the new table, copy rows, + move each definition's assignee into + ``quest_definition_assignees``, drop the old table, rename — so + ``DROP COLUMN``'s foreign-key restriction never applies and no + definition or instance row is lost. + + The copy runs with ``foreign_keys = OFF`` because dropping the old + table under enforcement would fire the implicit DELETE and violate + the ``quest_instances`` foreign key; ``PRAGMA foreign_key_check`` + runs inside the same transaction before the stamp, so a corrupt + copy aborts the whole migration instead of persisting. The pragma + is a connection-level no-op inside a transaction, hence the + off/transaction/on bracket. + """ + legacy_child_id = await _table_has_column( + database, "quest_definitions", "child_id" + ) + await database.execute("PRAGMA foreign_keys = OFF") + try: + async with database.transaction(): + await database.execute(_ASSIGNEES_TABLE_DDL) + if legacy_child_id: + await database.execute( + "CREATE TABLE quest_definitions_rebuilt " + f"{QUEST_DEFINITIONS_TABLE_SQL}" + ) + await database.execute( + "INSERT INTO quest_definitions_rebuilt " + "(id, title, description, icon, schedule_rule_id, " + "due_time, is_active, created_at) " + "SELECT id, title, description, icon, " + "schedule_rule_id, due_time, is_active, created_at " + "FROM quest_definitions ORDER BY id" + ) + await database.execute( + "INSERT OR IGNORE INTO quest_definition_assignees " + "(definition_id, child_id) " + "SELECT id, child_id FROM quest_definitions" + ) + await database.execute("DROP TABLE quest_definitions") + await database.execute( + "ALTER TABLE quest_definitions_rebuilt " + "RENAME TO quest_definitions" + ) + violations = await database.fetch_all( + "PRAGMA foreign_key_check" + ) + if violations: + raise RuntimeError( + "definition rebuild produced foreign-key " + f"violations: {violations!r}" + ) + await stamp_schema_version(database, target_version) + finally: + await database.execute("PRAGMA foreign_keys = ON") #: The ordered migration list. Append-only: never edit an applied @@ -136,6 +255,7 @@ async def _rename_legacy_task_tables(database: NestQuestDatabase) -> None: MIGRATIONS: Sequence[MigrationStep] = [ MIGRATION_1_V1_DDL, _rename_legacy_task_tables, + _add_definition_assignees, ] @@ -248,17 +368,18 @@ async def apply_migrations( 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. + step = migrations[target - 1] + if callable(step): + # Callable migrations own their transaction: some steps + # (table rebuilds) must run connection pragmas such as + # ``foreign_keys = OFF`` BEFORE opening one, which the + # runner's uniform transaction cannot express, and they + # stamp inside their own transaction so a failure rolls + # the statements and the version stamp back together. + # The version is re-read here under the migration lock: + # runners on one connection are serialized by it, so + # the version cannot change between this read and the + # callable's commit. in_tx_version = await read_schema_version(database) if in_tx_version > latest: raise RuntimeError( @@ -267,41 +388,39 @@ async def apply_migrations( f"{latest}); downgrades are not supported" ) if in_tx_version < target: - step = migrations[target - 1] - if callable(step): - await step(database) - applied_statements = 0 - else: + await step(database, target) + applied = True + else: + 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 " + f"newer than this integration understands " + f"(latest known: {latest}); downgrades are " + f"not supported" + ) + if in_tx_version < target: for sql in step: await database.execute(sql) + await stamp_schema_version(database, target) + applied = True applied_statements = len(step) - # 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: - if applied_statements == 0: - LOGGER.info( - "NestQuest schema migrated to version %d", - target, - ) - else: - LOGGER.info( - "NestQuest schema migrated to version %d " - "(%d statement%s)", - target, - applied_statements, - "s" if applied_statements != 1 else "", - ) + LOGGER.info( + "NestQuest schema migrated to version %d", + target, + ) return latest \ No newline at end of file diff --git a/custom_components/nestquest/schema.py b/custom_components/nestquest/schema.py index d497358..63d2674 100644 --- a/custom_components/nestquest/schema.py +++ b/custom_components/nestquest/schema.py @@ -86,6 +86,15 @@ 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. +Multi-assignee definitions (D-008): ``quest_definitions`` carries no +``child_id`` column. Assignment lives in +``quest_definition_assignees``, one row per (definition, child) with a +composite primary key and foreign keys to the definition and the child, +so "brush teeth" is one definition covering three children. The +migration runner rebuilds a pre-D-008 table into this shape and copies +its single-assignee column across. The composite primary key makes +duplicate assignment impossible at the storage layer. + Quest instances: ``quest_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) @@ -219,18 +228,34 @@ SCHEMA_V1_SCHEDULE_RULES_DDL: list[str] = [ """, ] +#: Column body of ``quest_definitions``, shared between the v1 DDL +#: below and the multi-assignee rebuild migration, which must recreate +#: the table in exactly this shape (D-008: one definition, many +#: assignees via ``quest_definition_assignees`` — no ``child_id`` +#: column on the definition itself). +QUEST_DEFINITIONS_TABLE_SQL = """( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + icon TEXT, + 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_QUEST_DEFINITIONS_DDL: list[str] = [ + f""" + CREATE TABLE IF NOT EXISTS quest_definitions {QUEST_DEFINITIONS_TABLE_SQL} + """, +] + +SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL: list[str] = [ """ - CREATE TABLE IF NOT EXISTS quest_definitions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - description TEXT, - icon TEXT, + CREATE TABLE IF NOT EXISTS quest_definition_assignees ( + definition_id INTEGER NOT NULL REFERENCES quest_definitions(id), 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 + PRIMARY KEY (definition_id, child_id) ) """, ] @@ -316,6 +341,7 @@ SCHEMA_V1_STATEMENTS: list[str] = [ *SCHEMA_V1_ADMIN_USERS_DDL, *SCHEMA_V1_SCHEDULE_RULES_DDL, *SCHEMA_V1_QUEST_DEFINITIONS_DDL, + *SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, *SCHEMA_V1_PRESENCE_SCHEDULES_DDL, *SCHEMA_V1_PRESENCE_OVERRIDES_DDL, *SCHEMA_V1_QUEST_INSTANCES_DDL, diff --git a/tests/test_dao_instances.py b/tests/test_dao_instances.py index 03ee03f..628752e 100644 --- a/tests/test_dao_instances.py +++ b/tests/test_dao_instances.py @@ -82,7 +82,7 @@ async def _prepare(path) -> tuple: child = await children.create("Ada", _now_stamp()) rule = await rules.create("daily", D1) definition = await definitions.create( - "Brush teeth", child.id, rule.id, _now_stamp() + "Brush teeth", rule.id, _now_stamp(), assignee_child_ids=[child.id] ) return ( database, @@ -184,17 +184,15 @@ def test_upsert_rejects_unknown_definition_and_child(tmp_path) -> None: with pytest.raises(ValueError, match="definition 999"): await instances.upsert(999, child.id, D1, _now_stamp()) # Unknown child: the definition-assignee check fires first and - # is the one that matters (an instance must carry its - # definition's assignee). To reach the child-existence check - # at all, the definition would have to claim child 999 — an - # FK-impossible state — so the child check only guards direct - # callers with a matching fake assignee; assert the guard - # order instead of forcing the FK-impossible state. + # is the one that matters (an instance must carry an assigned + # child). Under D-008 the assignee check is a membership test + # against quest_definition_assignees, so child 999 simply is + # not an assignee — the error names the pair. rule = await rules.create("daily", D1) other_definition = await definitions.create( - "X", child.id, rule.id, _now_stamp() + "X", rule.id, _now_stamp(), assignee_child_ids=[child.id] ) - with pytest.raises(ValueError, match="not 999"): + with pytest.raises(ValueError, match="not assigned to child 999"): await instances.upsert(other_definition.id, 999, D1, _now_stamp()) return None @@ -329,7 +327,10 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date( child = await children.create("Ada", _now_stamp()) rule = await rules.create("daily", D1) definition = await definitions.create( - "Brush teeth", child.id, rule.id, _now_stamp() + "Brush teeth", + rule.id, + _now_stamp(), + assignee_child_ids=[child.id], ) return await _body( database, children, rules, definitions, instances, @@ -406,7 +407,7 @@ def test_list_by_child_and_date_filters(tmp_path) -> None: other = await children.create("Bo", _now_stamp()) rule = await rules.create("daily", D1) other_definition = await definitions.create( - "Make bed", other.id, rule.id, _now_stamp() + "Make bed", rule.id, _now_stamp(), assignee_child_ids=[other.id] ) await instances.upsert(definition.id, child.id, D1, _now_stamp()) await instances.upsert(definition.id, child.id, D2, _now_stamp()) @@ -537,8 +538,8 @@ def test_delete_future_uncompleted_other_definitions_untouched( async def _body(database, children, rules, definitions, instances, events, child, definition): other = await definitions.create( - "Make bed", child.id, (await rules.create("daily", D1)).id, - _now_stamp(), + "Make bed", (await rules.create("daily", D1)).id, + _now_stamp(), assignee_child_ids=[child.id], ) await instances.upsert(definition.id, child.id, D20, _now_stamp()) await instances.upsert(other.id, child.id, D20, _now_stamp()) @@ -1090,7 +1091,7 @@ def test_append_concurrent_events_serialize(tmp_path) -> None: child = await children.create("Ada", _now_stamp()) rule = await rules.create("daily", D1) definition = await definitions.create( - "Brush teeth", child.id, rule.id, _now_stamp() + "Brush teeth", rule.id, _now_stamp(), assignee_child_ids=[child.id] ) return (database, children, rules, definitions, instances, events, child, definition) diff --git a/tests/test_dao_matrix.py b/tests/test_dao_matrix.py index c14e65b..9a2b36a 100644 --- a/tests/test_dao_matrix.py +++ b/tests/test_dao_matrix.py @@ -71,6 +71,7 @@ ALL_TABLES = ( "admin_users", "schedule_rules", "quest_definitions", + "quest_definition_assignees", "presence_schedules", "presence_overrides", "quest_instances", @@ -99,7 +100,10 @@ class World: assert await self.admins.add("user-1", _stamp()) is True self.rule = await self.rules.create("daily", D1) self.definition = await self.definitions.create( - "Brush teeth", self.child.id, self.rule.id, _stamp() + "Brush teeth", + self.rule.id, + _stamp(), + assignee_child_ids=[self.child.id], ) self.schedule = await self.schedules.upsert_by_child( self.child.id, 2, D1, "0,2,4|1,3" @@ -206,6 +210,9 @@ def test_matrix_quest_definitions_crud_and_constraints(tmp_path) -> None: # insert (seeded) + read fetched = await w.definitions.get(w.definition.id) assert fetched.title == "Brush teeth" + assert [c.id for c in await w.definitions.list_assignees( + w.definition.id + )] == [w.child.id] # update await w.definitions.update(w.definition.id, title="Brush TEETH") assert (await w.definitions.get(w.definition.id)).title == ( @@ -213,11 +220,21 @@ def test_matrix_quest_definitions_crud_and_constraints(tmp_path) -> None: ) # constraint: FK to a nonexistent child rejected with ValueError with pytest.raises(ValueError, match="does not exist"): - await w.definitions.create("X", 999, w.rule.id, _stamp()) - # constraint: inactive child rejected + await w.definitions.create( + "X", w.rule.id, _stamp(), assignee_child_ids=[999] + ) + # constraint: inactive child rejected on assignment await w.children.set_active(w.child2.id, False) with pytest.raises(ValueError, match="inactive"): - await w.definitions.set_assignee(w.definition.id, w.child2.id) + await w.definitions.add_assignee(w.definition.id, w.child2.id) + # assignee removal + re-adding the active child + assert await w.definitions.remove_assignee( + w.definition.id, w.child.id + ) is True + assert await w.definitions.remove_assignee( + w.definition.id, w.child.id + ) is False + await w.definitions.add_assignee(w.definition.id, w.child.id) # set_active: deactivate, never delete assert await w.definitions.set_active(w.definition.id, False) == 1 assert (await w.definitions.get(w.definition.id)).is_active is False diff --git a/tests/test_dao_rules.py b/tests/test_dao_rules.py index 4d3bf04..e73af68 100644 --- a/tests/test_dao_rules.py +++ b/tests/test_dao_rules.py @@ -266,7 +266,7 @@ def test_rule_delete_unreferenced_succeeds(tmp_path) -> None: def test_rule_delete_referenced_is_rejected(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") - await definitions.create("Brush teeth", child.id, rule.id, NOW) + await definitions.create("Brush teeth", rule.id, NOW) assert await rules.delete_if_unreferenced(rule.id) is False fetched = await rules.get(rule.id) assert fetched is not None, ( @@ -288,7 +288,7 @@ def test_rule_delete_after_last_reference_removed_succeeds(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") definition = await definitions.create( - "Brush teeth", child.id, rule.id, NOW + "Brush teeth", rule.id, NOW ) assert await rules.delete_if_unreferenced(rule.id) is False await definitions.set_active(definition.id, False) @@ -335,12 +335,12 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( import custom_components.nestquest.dao_rules as dao_rules original_validate = ( - dao_rules.QuestDefinitionsDao._validate_assignable + dao_rules.QuestDefinitionsDao._validate_rule_exists ) release = asyncio.Event() started = asyncio.Event() - async def _pausing_validate(self, child_id, rule_id): + async def _pausing_validate(self, rule_id): # Run the real validation FIRST (this is the # time-of-check), THEN pause: the delete task gets # scheduled while the create sits between its @@ -348,19 +348,17 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( # transaction must keep the delete queued until the # insert commits, so the create must win the race # and the delete must be rejected as referenced. - await original_validate(self, child_id, rule_id) + await original_validate(self, rule_id) if rule_id == rule_a.id and not started.is_set(): started.set() await release.wait() - dao_rules.QuestDefinitionsDao._validate_assignable = ( + dao_rules.QuestDefinitionsDao._validate_rule_exists = ( _pausing_validate ) try: create_task = asyncio.ensure_future( - definitions.create( - "Brush teeth", child.id, rule_a.id, NOW - ) + definitions.create("Brush teeth", rule_a.id, NOW) ) await started.wait() delete_task = asyncio.ensure_future( @@ -382,7 +380,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( async def _create(): return await definitions.create( - "Brush teeth", child.id, rule_a.id, NOW + "Brush teeth", rule_a.id, NOW ) delete_task = asyncio.ensure_future(_delete()) @@ -434,20 +432,24 @@ def test_definition_create_returns_typed_record(tmp_path) -> None: rule = await rules.create("weekly", "2026-09-14", weekday_set="0,2") definition = await definitions.create( "Brush teeth", - child.id, rule.id, NOW, description="Morning and night", icon="mdi:tooth", due_time="08:00", + assignee_child_ids=[child.id], ) assert isinstance(definition, QuestDefinitionRecord) assert definition.title == "Brush teeth" - assert definition.child_id == child.id assert definition.schedule_rule_id == rule.id assert definition.due_time == "08:00" assert definition.is_active is True assert definition.created_at == NOW + assert not hasattr(definition, "child_id"), ( + "D-008: assignment lives in quest_definition_assignees" + ) + assignees = await definitions.list_assignees(definition.id) + assert [c.id for c in assignees] == [child.id] return definition _with_db(tmp_path, "definition-create.db")(_body) @@ -460,26 +462,30 @@ def test_definition_get_missing_returns_none(tmp_path) -> None: assert _with_db(tmp_path, "definition-get-missing.db")(_body) is None -def test_definition_create_unknown_child_raises_value_error( +def test_definition_create_unknown_assignee_raises_value_error( tmp_path, ) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") with pytest.raises(ValueError, match="does not exist"): - await definitions.create("X", 999, rule.id, NOW) + await definitions.create( + "X", rule.id, NOW, assignee_child_ids=[999] + ) return None _with_db(tmp_path, "definition-bad-child.db")(_body) -def test_definition_create_inactive_child_raises_value_error( +def test_definition_create_inactive_assignee_raises_value_error( tmp_path, ) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") await children.set_active(child.id, False) with pytest.raises(ValueError, match="inactive"): - await definitions.create("X", child.id, rule.id, NOW) + await definitions.create( + "X", rule.id, NOW, assignee_child_ids=[child.id] + ) return None _with_db(tmp_path, "definition-inactive-child.db")(_body) @@ -490,7 +496,7 @@ def test_definition_create_unknown_rule_raises_value_error( ) -> None: async def _body(database, rules, definitions, children, child): with pytest.raises(ValueError, match="does not exist"): - await definitions.create("X", child.id, 999, NOW) + await definitions.create("X", 999, NOW) return None _with_db(tmp_path, "definition-bad-rule.db")(_body) @@ -505,9 +511,11 @@ def test_definition_list_by_child_filters_and_orders(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") other_child = await children.create("Bo", NOW) - await definitions.create("A", child.id, rule.id, NOW) - await definitions.create("B", child.id, rule.id, NOW) - await definitions.create("C", other_child.id, rule.id, NOW) + await definitions.create("A", rule.id, NOW, assignee_child_ids=[child.id]) + await definitions.create("B", rule.id, NOW, assignee_child_ids=[child.id]) + await definitions.create( + "C", rule.id, NOW, assignee_child_ids=[other_child.id] + ) mine = await definitions.list_by_child(child.id) assert [d.title for d in mine] == ["B", "A"] return mine @@ -527,8 +535,8 @@ def test_definition_list_by_child_missing_child_returns_empty( def test_definition_list_active_excludes_inactive(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") - first = await definitions.create("A", child.id, rule.id, NOW) - await definitions.create("B", child.id, rule.id, NOW) + first = await definitions.create("A", rule.id, NOW) + await definitions.create("B", rule.id, NOW) await definitions.set_active(first.id, False) active = await definitions.list_active() assert [d.title for d in active] == ["B"] @@ -547,7 +555,7 @@ def test_definition_update_fields_round_trip(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") definition = await definitions.create( - "A", child.id, rule.id, NOW, due_time="08:00" + "A", rule.id, NOW, due_time="08:00" ) assert await definitions.update( definition.id, @@ -572,9 +580,11 @@ def test_definition_update_cannot_change_assignee_or_active( async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") other = await children.create("Bo", NOW) - definition = await definitions.create("A", child.id, rule.id, NOW) + definition = await definitions.create( + "A", rule.id, NOW, assignee_child_ids=[child.id] + ) # title= / child_id= / is_active= are NOT update() parameters; - # this asserts the API shape: reassignment and activation have + # this asserts the API shape: assignment and activation have # their own explicit methods. import inspect @@ -590,7 +600,10 @@ def test_definition_update_cannot_change_assignee_or_active( definition.id, title="A2" ) == 1 fetched = await definitions.get(definition.id) - assert fetched.child_id == child.id + assert [c.id for c in await definitions.list_assignees(definition.id)] == [ + child.id + ] + assert other.id not in [c.id for c in await definitions.list_assignees(definition.id)] assert fetched.is_active is True return fetched @@ -600,7 +613,7 @@ def test_definition_update_cannot_change_assignee_or_active( def test_definition_update_no_fields_returns_zero(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") - definition = await definitions.create("A", child.id, rule.id, NOW) + definition = await definitions.create("A", rule.id, NOW) return await definitions.update(definition.id) assert _with_db(tmp_path, "definition-update-empty.db")(_body) == 0 @@ -612,7 +625,6 @@ def test_definition_update_can_clear_optional_fields(tmp_path) -> None: rule = await rules.create("daily", "2026-09-14") definition = await definitions.create( "A", - child.id, rule.id, NOW, description="d", @@ -646,7 +658,7 @@ def test_definition_update_missing_returns_zero(tmp_path) -> None: def test_definition_set_active_round_trip(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") - definition = await definitions.create("A", child.id, rule.id, NOW) + definition = await definitions.create("A", rule.id, NOW) assert await definitions.set_active(definition.id, False) == 1 fetched = await definitions.get(definition.id) assert fetched.is_active is False @@ -683,76 +695,152 @@ def test_definition_no_delete_method_exists(tmp_path) -> None: _with_db(tmp_path, "definition-no-delete.db")(_body) -def test_definition_set_assignee_round_trip(tmp_path) -> None: +def test_definition_add_assignee_round_trip(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") other = await children.create("Bo", NOW) - definition = await definitions.create("A", child.id, rule.id, NOW) - assert await definitions.set_assignee(definition.id, other.id) == 1 - fetched = await definitions.get(definition.id) - assert fetched.child_id == other.id - return fetched + definition = await definitions.create( + "A", rule.id, NOW, assignee_child_ids=[child.id] + ) + await definitions.add_assignee(definition.id, other.id) + assignees = await definitions.list_assignees(definition.id) + assert sorted(c.id for c in assignees) == sorted([child.id, other.id]) + # Idempotent: re-adding an existing assignee stays a no-op. + await definitions.add_assignee(definition.id, other.id) + assignees = await definitions.list_assignees(definition.id) + assert sorted(c.id for c in assignees) == sorted([child.id, other.id]) + return assignees - _with_db(tmp_path, "definition-set-assignee.db")(_body) + _with_db(tmp_path, "definition-add-assignee.db")(_body) -def test_definition_set_assignee_inactive_child_raises(tmp_path) -> None: +def test_definition_add_assignee_inactive_child_raises(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") other = await children.create("Bo", NOW) - definition = await definitions.create("A", child.id, rule.id, NOW) + definition = await definitions.create( + "A", rule.id, NOW, assignee_child_ids=[child.id] + ) await children.set_active(other.id, False) with pytest.raises(ValueError, match="inactive"): - await definitions.set_assignee(definition.id, other.id) - fetched = await definitions.get(definition.id) - assert fetched.child_id == child.id, ( - "failed reassignment must leave the current assignee intact" + await definitions.add_assignee(definition.id, other.id) + assignees = await definitions.list_assignees(definition.id) + assert [c.id for c in assignees] == [child.id], ( + "failed assignment must leave the current assignees intact" ) - return fetched + return assignees _with_db(tmp_path, "definition-assignee-inactive.db")(_body) -def test_definition_set_assignee_unknown_child_raises(tmp_path) -> None: +def test_definition_add_assignee_unknown_child_raises(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") - definition = await definitions.create("A", child.id, rule.id, NOW) + definition = await definitions.create( + "A", rule.id, NOW, assignee_child_ids=[child.id] + ) with pytest.raises(ValueError, match="does not exist"): - await definitions.set_assignee(definition.id, 999) + await definitions.add_assignee(definition.id, 999) return None _with_db(tmp_path, "definition-assignee-unknown.db")(_body) -def test_definition_set_assignee_missing_definition_returns_zero( +def test_definition_add_assignee_missing_definition_raises(tmp_path) -> None: + async def _body(database, rules, definitions, children, child): + with pytest.raises(ValueError, match="does not exist"): + await definitions.add_assignee(999, child.id) + return None + + _with_db(tmp_path, "definition-assignee-missing.db")(_body) + + +def test_definition_remove_assignee_round_trip(tmp_path) -> None: + async def _body(database, rules, definitions, children, child): + rule = await rules.create("daily", "2026-09-14") + other = await children.create("Bo", NOW) + definition = await definitions.create( + "A", rule.id, NOW, assignee_child_ids=[child.id, other.id] + ) + assert await definitions.remove_assignee(definition.id, other.id) is True + assignees = await definitions.list_assignees(definition.id) + assert [c.id for c in assignees] == [child.id] + # Removing a non-assignee reports False. + assert await definitions.remove_assignee(definition.id, other.id) is False + return assignees + + _with_db(tmp_path, "definition-remove-assignee.db")(_body) + + +def test_definition_list_assignees_orders_by_child_sort_order( tmp_path, ) -> None: async def _body(database, rules, definitions, children, child): - other = await children.create("Bo", NOW) - return await definitions.set_assignee(999, other.id) + rule = await rules.create("daily", "2026-09-14") + second = await children.create("Bo", NOW) + third = await children.create("Cleo", NOW) + await children.reorder([third.id, child.id, second.id]) + definition = await definitions.create( + "A", rule.id, NOW, assignee_child_ids=[second.id, child.id, third.id] + ) + assignees = await definitions.list_assignees(definition.id) + assert [c.id for c in assignees] == [third.id, child.id, second.id] + return assignees - assert _with_db(tmp_path, "definition-assignee-missing.db")(_body) == 0 + _with_db(tmp_path, "definition-assignees-order.db")(_body) -def test_definition_set_assignee_leaves_history_rows_alone( +def test_definition_unassigned_has_no_assignees(tmp_path) -> None: + async def _body(database, rules, definitions, children, child): + rule = await rules.create("daily", "2026-09-14") + definition = await definitions.create("A", rule.id, NOW) + assert await definitions.list_assignees(definition.id) == [] + return None + + _with_db(tmp_path, "definition-no-assignees.db")(_body) + + +def test_definition_shared_by_multiple_children_via_list_by_child( tmp_path, ) -> None: - """Reassignment writes the definition row only: any instances that - already exist keep their generation-time child_id (Feature 06 + """D-008: one definition covering several children appears for + every assignee through list_by_child, once each.""" + async def _body(database, rules, definitions, children, child): + rule = await rules.create("daily", "2026-09-14") + second = await children.create("Bo", NOW) + definition = await definitions.create( + "A", rule.id, NOW, assignee_child_ids=[child.id, second.id] + ) + for assignee in (child, second): + mine = await definitions.list_by_child(assignee.id) + assert [d.id for d in mine] == [definition.id] + return definition + + _with_db(tmp_path, "definition-shared.db")(_body) + + +def test_definition_assignment_leaves_history_rows_alone( + tmp_path, +) -> None: + """Assignment edits write the assignees table only: any instances + that already exist keep their generation-time child_id (Feature 06 guardrail 'changes future instances only'). """ async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") other = await children.create("Bo", NOW) - definition = await definitions.create("A", child.id, rule.id, NOW) + definition = await definitions.create( + "A", rule.id, NOW, assignee_child_ids=[child.id] + ) # Simulate an already-generated instance (materialization owns - # this table, but its row shape proves reassignment isolation). + # this table, but its row shape proves assignment isolation). await database.execute( "INSERT INTO quest_instances (definition_id, child_id, " "due_date, generated_at) VALUES (?, ?, ?, ?)", (definition.id, child.id, "2026-09-15", NOW), ) - await definitions.set_assignee(definition.id, other.id) + await definitions.add_assignee(definition.id, other.id) + await definitions.remove_assignee(definition.id, child.id) row = await database.fetch_one( "SELECT child_id FROM quest_instances WHERE definition_id = ?", (definition.id,), @@ -801,7 +889,7 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None: } sql_pattern = re.compile( r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?" - r"(schedule_rules|quest_definitions)\b", + r"(schedule_rules|quest_definitions|quest_definition_assignees)\b", re.IGNORECASE, ) offenders: list[str] = [] @@ -813,7 +901,7 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None: if sql_pattern.search(py.read_text()): offenders.append(relative) assert offenders == [], ( - f"SQL touching schedule_rules/quest_definitions leaked into: " + f"SQL touching schedule_rules/quest_definitions/assignees leaked: " f"{offenders}" ) @@ -853,7 +941,7 @@ def test_dao_instances_test_file_uses_dao_not_raw_rules_sql() -> None: ) sql_pattern = re.compile( r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|" - r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions)\b", + r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions|quest_definition_assignees)\b", re.IGNORECASE, ) assert sql_pattern.search(remaining) is None, ( @@ -898,7 +986,7 @@ def test_dao_rules_test_file_uses_dao_not_raw_table_sql() -> None: ) sql_pattern = re.compile( r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|" - r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions)\b", + r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions|quest_definition_assignees)\b", re.IGNORECASE, ) assert sql_pattern.search(remaining) is None, ( diff --git a/tests/test_migrations.py b/tests/test_migrations.py index f8ac70d..155d795 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -63,6 +63,7 @@ def _counts(database) -> dict[str, int]: "admin_users", "schedule_rules", "quest_definitions", + "quest_definition_assignees", "presence_schedules", "presence_overrides", "quest_instances", @@ -99,6 +100,7 @@ def test_fresh_file_migration_creates_all_v1_tables(tmp_path) -> None: "admin_users", "schedule_rules", "quest_definitions", + "quest_definition_assignees", "presence_schedules", "presence_overrides", "quest_instances", @@ -136,15 +138,18 @@ def test_empty_version_table_reads_as_version_0(tmp_path) -> None: def test_migration_list_shape() -> None: - """Migration 1 is the v1 DDL; migration 2 is the legacy rename step. + """Migration 1 is the v1 DDL; 2 and 3 are the callable steps. Nothing ever shipped, so migration 1 was rewritten pre-release (D-007) to create the quest_* tables directly; migration 2 exists - for dev databases stamped version 1 by the pre-rename runner. + for dev databases stamped version 1 by the pre-rename runner, and + migration 3 brings pre-D-008 definitions to the multi-assignee + model. """ assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS assert callable(MIGRATIONS[1]) - assert len(MIGRATIONS) == 2 + assert callable(MIGRATIONS[2]) + assert len(MIGRATIONS) == 3 # --------------------------------------------------------------------------- @@ -312,6 +317,20 @@ def test_legacy_task_tables_are_renamed_and_keep_their_rows(tmp_path) -> None: ) == ("2026-09-15",) ) + # Migration 3 moved the legacy single-assignee column into the + # assignees table: the definition kept its child, the column is + # gone. + assignees = _run( + database.fetch_one( + "SELECT child_id FROM quest_definition_assignees " + "WHERE definition_id = 1" + ) + ) + assert assignees == (1,) + columns = _run( + database.fetch_all("PRAGMA table_info(quest_definitions)") + ) + assert "child_id" not in {row[1] for row in columns} # The untouched completion_events table still exists and is empty. assert "completion_events" in tables assert ( @@ -380,6 +399,136 @@ def test_fresh_database_never_holds_legacy_table_names(tmp_path) -> None: _run(database.close()) +# --------------------------------------------------------------------------- +# Pre-D-008 database: quest_definitions with child_id stamped version 2 +# --------------------------------------------------------------------------- + +_V2_DEFINITIONS_DDL = [ + """ + CREATE TABLE IF NOT EXISTS quest_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, + created_at TEXT NOT NULL + ) + """, +] + + +def _seed_v2_multi_assignee_upgrade(database) -> None: + """Create a database shaped like a post-rename, pre-D-008 dev file. + + Fresh quest_* tables — but quest_definitions still carries the + single child_id column and no assignees table exists — stamped at + version 2, with definition and instance rows that must survive. + """ + for sql in SCHEMA_V1_STATEMENTS: + if "quest_definitions " in sql: + continue # replaced by the legacy-shape DDL below + _run(database.execute(sql)) + for sql in _V2_DEFINITIONS_DDL: + _run(database.execute(sql)) + _run(database.execute(VERSION_TABLE_DDL)) + _run( + database.execute( + f"INSERT INTO {VERSION_TABLE} (id, version) VALUES (1, 2)" + ) + ) + _run( + database.execute( + "INSERT INTO children (display_name, created_at) " + "VALUES ('Ada', '2026-09-14T00:00:00+00:00')" + ) + ) + _run( + database.execute( + "INSERT INTO children (display_name, created_at) " + "VALUES ('Bo', '2026-09-14T00:00:00+00:00')" + ) + ) + _run( + database.execute( + "INSERT INTO schedule_rules (rule_type, start_date) " + "VALUES ('daily', '2026-09-14')" + ) + ) + _run( + database.execute( + "INSERT INTO quest_definitions (title, child_id, " + "schedule_rule_id, created_at) " + "VALUES ('Dishes', 2, 1, '2026-09-14T00:00:00+00:00')" + ) + ) + _run( + database.execute( + "INSERT INTO quest_instances (definition_id, child_id, " + "due_date, generated_at) " + "VALUES (1, 2, '2026-09-15', '2026-09-14T00:00:00+00:00')" + ) + ) + + +def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None: + database = _open_db(tmp_path / "v2-upgrade.db") + try: + _seed_v2_multi_assignee_upgrade(database) + final = _migrate(database) + assert final == len(MIGRATIONS) + assert _version(database) == len(MIGRATIONS) + # The definition row survived and lost its child_id column. + row = _run( + database.fetch_one( + "SELECT title, schedule_rule_id, is_active " + "FROM quest_definitions WHERE id = 1" + ) + ) + assert row == ("Dishes", 1, 1) + columns = _run( + database.fetch_all("PRAGMA table_info(quest_definitions)") + ) + assert "child_id" not in {c[1] for c in columns} + # The single assignee moved into the assignees table. + assignees = _run( + database.fetch_all( + "SELECT definition_id, child_id " + "FROM quest_definition_assignees ORDER BY child_id" + ) + ) + assert assignees == [(1, 2)] + # The instance survived, still referencing the definition. + instance = _run( + database.fetch_one( + "SELECT definition_id, child_id, due_date " + "FROM quest_instances WHERE id = 1" + ) + ) + assert instance == (1, 2, "2026-09-15") + # No dangling references anywhere. + assert _run(database.fetch_all("PRAGMA foreign_key_check")) == [] + # The rebuilt table is live for the new model: a definition + # insert carries no child column at all. + _run( + database.execute( + "INSERT INTO quest_definitions (title, schedule_rule_id, " + "created_at) VALUES ('Laundry', 1, " + "'2026-09-14T00:00:00+00:00')" + ) + ) + _run( + database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (2, 1)" + ) + ) + finally: + _run(database.close()) + + # --------------------------------------------------------------------------- # Idempotence: second start is a no-op # --------------------------------------------------------------------------- @@ -481,22 +630,28 @@ def test_noop_run_is_logged_at_debug_level(caplog, tmp_path) -> None: def _failing_migrations(prior_statements: list[str]) -> list[list[str]]: - """Three-migration list where migration 3 fails mid-application. + """Migration list where the LAST entry fails mid-application. - Migration 2 is a harmless no-op filler so the real runner — which - has already stamped version 2 by the time these tests re-run it — - still has a pending migration to fail on. + Sized one entry longer than the real MIGRATIONS and padded with + harmless no-op fillers, so a fully-migrated database (already at + len(MIGRATIONS)) still has exactly one pending migration — the + failing one — to attempt. """ - return [ - prior_statements, + steps: list[list[str]] = [prior_statements] + steps += [ [ - "CREATE TABLE IF NOT EXISTS filler_table (id INTEGER PRIMARY KEY)" - ], + f"CREATE TABLE IF NOT EXISTS filler_{index} " + "(id INTEGER PRIMARY KEY)" + ] + for index in range(len(MIGRATIONS) - 1) + ] + steps.append( [ "CREATE TABLE IF NOT EXISTS later_table (id INTEGER PRIMARY KEY)", "CREATE TABLE broken (", - ], - ] + ] + ) + return steps def test_failing_migration_rolls_back_to_prior_version(tmp_path) -> None: diff --git a/tests/test_schema.py b/tests/test_schema.py index b95e68b..58e65de 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -16,6 +16,7 @@ from custom_components.nestquest.schema import ( SCHEMA_V1_SCHEDULE_RULES_DDL, SCHEMA_V1_STATEMENTS, SCHEMA_V1_QUEST_DEFINITIONS_DDL, + SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, SCHEMA_V1_QUEST_INSTANCES_DDL, async_apply_ddl, ) @@ -73,15 +74,17 @@ def _insert_rule(database, **overrides): return result.lastrowid -def _insert_definition(database, child_id=1, rule_id=1, **overrides): - """Insert one valid task_definition, returning the definition id. +def _insert_definition(database, rule_id=1, assignees=(1,), **overrides): + """Insert one valid quest_definition, returning the definition id. + ``assignees`` rows are written to ``quest_definition_assignees`` + after the definition insert (the definition itself carries no + child). Pass ``assignees=None`` for an unassigned definition. Pass ``_OMIT`` as a value to leave that column out of the INSERT so its DDL DEFAULT applies. """ values: dict[str, object] = { "title": "Brush teeth", - "child_id": child_id, "schedule_rule_id": rule_id, "created_at": "2026-09-13T00:00:00+00:00", } @@ -96,6 +99,14 @@ def _insert_definition(database, child_id=1, rule_id=1, **overrides): ) ) assert result.lastrowid is not None + for child_id in assignees or (): + _run( + database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (?, ?)", + (result.lastrowid, child_id), + ) + ) return result.lastrowid @@ -156,12 +167,13 @@ def _insert_override(database, child_id, **overrides): # --------------------------------------------------------------------------- -def test_schema_v1_statements_compose_the_six_tables() -> None: +def test_schema_v1_statements_compose_the_seven_tables() -> None: assert SCHEMA_V1_STATEMENTS == [ *SCHEMA_V1_CHILDREN_DDL, *SCHEMA_V1_ADMIN_USERS_DDL, *SCHEMA_V1_SCHEDULE_RULES_DDL, *SCHEMA_V1_QUEST_DEFINITIONS_DDL, + *SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, *SCHEMA_V1_PRESENCE_SCHEDULES_DDL, *SCHEMA_V1_PRESENCE_OVERRIDES_DDL, *SCHEMA_V1_QUEST_INSTANCES_DDL, @@ -302,7 +314,6 @@ def test_quest_definitions_columns_types_and_constraints(tmp_path) -> None: ("title", "TEXT", 1, None, 0), ("description", "TEXT", 0, None, 0), ("icon", "TEXT", 0, None, 0), - ("child_id", "INTEGER", 1, None, 0), ("schedule_rule_id", "INTEGER", 1, None, 0), ("due_time", "TEXT", 0, None, 0), ("is_active", "INTEGER", 1, "1", 0), @@ -319,13 +330,66 @@ def test_quest_definitions_foreign_keys_declared(tmp_path) -> None: fks = _run(database.fetch_all("PRAGMA foreign_key_list(quest_definitions)")) declared = {(row[2], row[3], row[4]) for row in fks} assert declared == { - ("children", "child_id", "id"), ("schedule_rules", "schedule_rule_id", "id"), } finally: _run(database.close()) +def test_quest_definition_assignees_table_exists_after_applying_ddl( + tmp_path, +) -> None: + database = _open_db(tmp_path / "assignees.db") + try: + _apply(database) + row = _run( + database.fetch_one( + "SELECT name FROM sqlite_master WHERE type = 'table' " + "AND name = 'quest_definition_assignees'" + ) + ) + assert row is not None and row[0] == "quest_definition_assignees" + finally: + _run(database.close()) + + +def test_quest_definition_assignees_columns_types_and_constraints( + tmp_path, +) -> None: + database = _open_db(tmp_path / "assignees-columns.db") + try: + _apply(database) + columns = _run( + database.fetch_all("PRAGMA table_info(quest_definition_assignees)") + ) + # cid, name, type, notnull, dflt_value, pk — both columns are + # the two-part primary key, so pk is 1 and 2. + assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [ + ("definition_id", "INTEGER", 1, None, 1), + ("child_id", "INTEGER", 1, None, 2), + ] + finally: + _run(database.close()) + + +def test_quest_definition_assignees_foreign_keys_declared(tmp_path) -> None: + database = _open_db(tmp_path / "assignees-fks.db") + try: + _apply(database) + fks = _run( + database.fetch_all( + "PRAGMA foreign_key_list(quest_definition_assignees)" + ) + ) + declared = {(row[2], row[3], row[4]) for row in fks} + assert declared == { + ("quest_definitions", "definition_id", "id"), + ("children", "child_id", "id"), + } + finally: + _run(database.close()) + + # --------------------------------------------------------------------------- # Constraint enforcement # --------------------------------------------------------------------------- @@ -826,28 +890,114 @@ def test_quest_definitions_insert_requires_existing_child_and_rule( ) ) rule_id = _insert_rule(database) - definition_id = _insert_definition( - database, child_id=1, rule_id=rule_id - ) + definition_id = _insert_definition(database, rule_id=rule_id) row = _run( database.fetch_one( - "SELECT title, child_id, schedule_rule_id FROM quest_definitions " + "SELECT title, schedule_rule_id FROM quest_definitions " "WHERE id = ?", (definition_id,), ) ) - assert row == ("Brush teeth", 1, rule_id) + assert row == ("Brush teeth", rule_id) + assignee = _run( + database.fetch_one( + "SELECT child_id FROM quest_definition_assignees " + "WHERE definition_id = ?", + (definition_id,), + ) + ) + assert assignee == (1,) finally: _run(database.close()) -def test_quest_definitions_unknown_child_id_fails(tmp_path) -> None: - database = _open_db(tmp_path / "definitions-bad-child.db") +def test_quest_definition_assignees_unknown_child_fails(tmp_path) -> None: + database = _open_db(tmp_path / "assignees-bad-child.db") try: _apply(database) _insert_rule(database) + definition_id = _insert_definition(database, assignees=None) with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"): - _insert_definition(database, child_id=999) + _run( + database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (?, 999)", + (definition_id,), + ) + ) + finally: + _run(database.close()) + + +def test_quest_definition_assignees_unknown_definition_fails(tmp_path) -> None: + database = _open_db(tmp_path / "assignees-bad-definition.db") + try: + _apply(database) + _run( + database.execute( + "INSERT INTO children (display_name, created_at) VALUES (?, ?)", + ("Ada", "2026-09-13T00:00:00+00:00"), + ) + ) + with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"): + _run( + database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (999, 1)" + ) + ) + finally: + _run(database.close()) + + +def test_quest_definition_assignees_duplicate_pair_fails(tmp_path) -> None: + database = _open_db(tmp_path / "assignees-duplicate.db") + try: + _apply(database) + _run( + database.execute( + "INSERT INTO children (display_name, created_at) VALUES (?, ?)", + ("Ada", "2026-09-13T00:00:00+00:00"), + ) + ) + _insert_rule(database) + definition_id = _insert_definition(database) + with pytest.raises(sqlite3.IntegrityError): + _run( + database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (?, 1)", + (definition_id,), + ) + ) + finally: + _run(database.close()) + + +def test_quest_definition_assignees_multiple_children_allowed( + tmp_path, +) -> None: + database = _open_db(tmp_path / "assignees-multi.db") + try: + _apply(database) + for name in ("Ada", "Ben", "Cleo"): + _run( + database.execute( + "INSERT INTO children (display_name, created_at) " + "VALUES (?, ?)", + (name, "2026-09-13T00:00:00+00:00"), + ) + ) + _insert_rule(database) + definition_id = _insert_definition(database, assignees=(1, 2, 3)) + rows = _run( + database.fetch_all( + "SELECT child_id FROM quest_definition_assignees " + "WHERE definition_id = ? ORDER BY child_id", + (definition_id,), + ) + ) + assert [row[0] for row in rows] == [1, 2, 3] finally: _run(database.close()) @@ -863,7 +1013,7 @@ def test_quest_definitions_unknown_schedule_rule_id_fails(tmp_path) -> None: ) ) with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"): - _insert_definition(database, child_id=1, rule_id=999) + _insert_definition(database, rule_id=999) finally: _run(database.close()) @@ -874,7 +1024,7 @@ def test_quest_definitions_is_active_check_enforced(tmp_path) -> None: _apply(database) _insert_rule(database) with pytest.raises(sqlite3.IntegrityError, match="CHECK"): - _insert_definition(database, child_id=1, rule_id=1, is_active=2) + _insert_definition(database, rule_id=1, is_active=2) finally: _run(database.close()) @@ -895,7 +1045,7 @@ def test_quest_definitions_is_active_non_integer_fails( _insert_rule(database) with pytest.raises(sqlite3.IntegrityError, match="CHECK"): _insert_definition( - database, child_id=1, rule_id=1, is_active=bad_is_active + database, rule_id=1, is_active=bad_is_active ) finally: _run(database.close()) @@ -907,7 +1057,7 @@ def test_quest_definitions_title_not_null_enforced(tmp_path) -> None: _apply(database) _insert_rule(database) with pytest.raises(sqlite3.IntegrityError, match="NOT NULL"): - _insert_definition(database, child_id=1, rule_id=1, title=None) + _insert_definition(database, rule_id=1, title=None) finally: _run(database.close()) @@ -919,7 +1069,7 @@ def test_quest_definitions_created_at_not_null_enforced(tmp_path) -> None: _insert_rule(database) with pytest.raises(sqlite3.IntegrityError, match="NOT NULL"): _insert_definition( - database, child_id=1, rule_id=1, created_at=None + database, rule_id=1, created_at=None ) finally: _run(database.close()) @@ -937,7 +1087,7 @@ def test_quest_definitions_defaults_apply_on_insert(tmp_path) -> None: ) _insert_rule(database) definition_id = _insert_definition( - database, child_id=1, rule_id=1, is_active=_OMIT + database, rule_id=1, is_active=_OMIT ) row = _run( database.fetch_one( @@ -966,7 +1116,7 @@ def test_applying_extended_ddl_twice_keeps_new_table_rows(tmp_path) -> None: ) ) _insert_rule(database) - _insert_definition(database, child_id=1, rule_id=1) + _insert_definition(database, rule_id=1) _apply(database) counts = [] for table in ("children", "schedule_rules", "quest_definitions"): @@ -1469,7 +1619,7 @@ def _setup_definition(database) -> None: """Create one child, one schedule rule and one quest definition.""" _child(database) _insert_rule(database) - _insert_definition(database, child_id=1, rule_id=1) + _insert_definition(database, rule_id=1) # --------------------------------------------------------------------------- @@ -1675,7 +1825,7 @@ def test_quest_instances_same_date_different_definitions_allowed( ) ) rule_id = _insert_rule(database) - _insert_definition(database, child_id=2, rule_id=rule_id) + _insert_definition(database, rule_id=rule_id, assignees=(2,)) _insert_instance(database, definition_id=1, due_date="2026-09-14") _insert_instance(database, definition_id=2, due_date="2026-09-14") row = _run(database.fetch_one("SELECT COUNT(*) FROM quest_instances")) -- 2.55.0 From ebc20642221c5a1017d7e6ff56fae92546b886ab Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 10:05:43 -0400 Subject: [PATCH 03/42] Quest windows via quest_definition_windows (D-008) Per IMPLEMENTATION-ORDER.md 1.4: a quest_definition_windows table (definition_id, window, due_time) declares which day windows a definition spans, one row per definition+window via the composite primary key; window is CHECK-constrained to morning/afternoon/evening. - const.py: WINDOW_MORNING/AFTERNOON/EVENING, QUEST_WINDOWS canonical order, WINDOW_CLOCK_RANGES with the documented boundaries and the boundary-to-later-window rule documented for Feature 07. - schema.py: windows DDL joins SCHEMA_V1_STATEMENTS; due_time is caller-validated strict HH:MM (DAO layer), matching the date policy. - migrations.py: migration 4 is a plain CREATE IF NOT EXISTS statement list (no rebuild needed for a new table). - dao_rules.py: QuestDefinitionsDao gains upsert_window (idempotent per window, due_time refresh in place), list_windows (const order), remove_window; _validate_time/_validate_window helpers. - tests: windows DDL shape/constraints (CHECK matrix, duplicate, unknown definition), DAO round-trips, const range shapes; leak guards extended to the new table. --- custom_components/nestquest/const.py | 25 ++++ custom_components/nestquest/dao_rules.py | 137 ++++++++++++++++++ custom_components/nestquest/migrations.py | 5 + custom_components/nestquest/schema.py | 21 +++ tests/test_const.py | 30 ++++ tests/test_dao_rules.py | 97 ++++++++++++- tests/test_migrations.py | 16 ++- tests/test_schema.py | 164 +++++++++++++++++++++- 8 files changed, 486 insertions(+), 9 deletions(-) diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index fc45d7c..b0b4daa 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -20,3 +20,28 @@ DEFAULT_DAY_ROLLOVER_TIME = "00:00" CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout" DEFAULT_PANEL_IDLE_TIMEOUT = 300 + +# Quest windows (D-008): a definition may declare several day windows; +# each produces its own instance per day and groups the panel's Quest +# Log into three columns. The clock ranges are stored here so they stay +# configurable later. Boundaries: a window's end equals the next +# window's start; classification (Feature 07 materialisation) treats a +# due time exactly on the boundary as belonging to the LATER window, so +# 12:00 is afternoon (not morning) and 17:00 is evening (not +# afternoon). Times after 21:00 belong to no window. +WINDOW_MORNING = "morning" +WINDOW_AFTERNOON = "afternoon" +WINDOW_EVENING = "evening" + +QUEST_WINDOWS: tuple[str, ...] = ( + WINDOW_MORNING, + WINDOW_AFTERNOON, + WINDOW_EVENING, +) + +#: Inclusive "HH:MM" bounds per window, in 24-hour local time. +WINDOW_CLOCK_RANGES: dict[str, tuple[str, str]] = { + WINDOW_MORNING: ("00:00", "11:59"), + WINDOW_AFTERNOON: ("12:00", "17:00"), + WINDOW_EVENING: ("17:00", "21:00"), +} diff --git a/custom_components/nestquest/dao_rules.py b/custom_components/nestquest/dao_rules.py index 291febb..d5090ee 100644 --- a/custom_components/nestquest/dao_rules.py +++ b/custom_components/nestquest/dao_rules.py @@ -36,6 +36,7 @@ from __future__ import annotations import datetime from dataclasses import dataclass +from .const import QUEST_WINDOWS from .dao_children import ChildRecord, _child_from_row, _connection_lock from .dao_children import _CHILD_COLUMNS from .db import NestQuestDatabase @@ -71,6 +72,38 @@ def _validate_date(value: str, field: str) -> None: ) +_TIME_FORMAT = "%H:%M" + + +def _validate_time(value: str, field: str) -> None: + """Raise ValueError unless ``value`` is a strict 24-hour HH:MM. + + Same strictness policy as _validate_date: parse with the exact + format, then round-trip so '9:30' (non-padded) is rejected. Time + shapes are caller-side policy (the schema stores TEXT): windows' + optional ``due_time`` and definition ``due_time`` both use this. + """ + try: + parsed = datetime.datetime.strptime(value, _TIME_FORMAT).time() + except (TypeError, ValueError): + raise ValueError( + f"{field} must be a 24-hour HH:MM time, got {value!r}" + ) from None + if parsed.strftime(_TIME_FORMAT) != value: + raise ValueError( + f"{field} must be a strict HH:MM time, got {value!r}" + ) + + +def _validate_window(window: str) -> None: + """Raise ValueError unless ``window`` is a ``const`` window name.""" + if window not in QUEST_WINDOWS: + raise ValueError( + f"window must be one of {', '.join(QUEST_WINDOWS)}, " + f"got {window!r}" + ) + + @dataclass(frozen=True) class ScheduleRuleRecord: """One row of ``schedule_rules``.""" @@ -100,6 +133,15 @@ class QuestDefinitionRecord: created_at: str +@dataclass(frozen=True) +class QuestDefinitionWindowRecord: + """One row of ``quest_definition_windows``.""" + + definition_id: int + window: str + due_time: str | None + + _RULE_COLUMNS = ( "id, rule_type, interval, weekday_set, day_of_month, nth_weekday, " "month, start_date, end_date" @@ -108,6 +150,7 @@ _DEFINITION_COLUMNS = ( "id, title, description, icon, schedule_rule_id, due_time, " "is_active, created_at" ) +_WINDOW_COLUMNS = "definition_id, window, due_time" def _rule_from_row(row: tuple) -> ScheduleRuleRecord: @@ -137,6 +180,14 @@ def _definition_from_row(row: tuple) -> QuestDefinitionRecord: ) +def _window_from_row(row: tuple) -> QuestDefinitionWindowRecord: + return QuestDefinitionWindowRecord( + definition_id=row[0], + window=row[1], + due_time=row[2], + ) + + class ScheduleRulesDao: """Typed async access to the ``schedule_rules`` table.""" @@ -507,6 +558,92 @@ class QuestDefinitionsDao: ) return [_child_from_row(row) for row in rows] + async def upsert_window( + self, + definition_id: int, + window: str, + *, + due_time: str | None = None, + ) -> QuestDefinitionWindowRecord: + """Declare (or re-declare) ``window`` on the definition. + + The composite primary key makes the write idempotent per + (definition, window): re-declaring updates ``due_time`` in + place instead of duplicating. The window name must be one of + ``const.QUEST_WINDOWS`` (the schema CHECK backs this up) and a + provided ``due_time`` must be strict 24-hour HH:MM. The + definition must exist — validated under the connection lock so + the verdict cannot go stale before the write. + """ + _validate_window(window) + if due_time is not None: + _validate_time(due_time, "due_time") + async with _connection_lock(self._database): + async with self._database.transaction(): + definition = await self._database.fetch_one( + "SELECT 1 FROM quest_definitions WHERE id = ?", + (definition_id,), + ) + if definition is None: + raise ValueError( + f"quest definition {definition_id} does not exist" + ) + await self._database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) VALUES (?, ?, ?) " + "ON CONFLICT (definition_id, window) DO UPDATE SET " + "due_time = excluded.due_time", + (definition_id, window, due_time), + ) + row = await self._database.fetch_one( + f"SELECT {_WINDOW_COLUMNS} " + "FROM quest_definition_windows " + "WHERE definition_id = ? AND window = ?", + (definition_id, window), + ) + assert row is not None + return _window_from_row(row) + + async def list_windows( + self, definition_id: int + ) -> list[QuestDefinitionWindowRecord]: + """Return the definition's windows in ``const`` order. + + Ordered by the canonical window sequence (morning, afternoon, + evening) rather than insertion order, so callers always render + the Quest Log's three columns consistently. A definition with + no windows returns []. + """ + rows = await self._database.fetch_all( + f"SELECT {_WINDOW_COLUMNS} FROM quest_definition_windows " + "WHERE definition_id = ?", + (definition_id,), + ) + by_name = {row[1]: _window_from_row(row) for row in rows} + return [ + by_name[name] + for name in QUEST_WINDOWS + if name in by_name + ] + + async def remove_window( + self, definition_id: int, window: str + ) -> bool: + """Remove the window declaration; True when a row was removed. + + Future materialization stops producing instances for this + (definition, window); existing instances and completion history + are never touched here (Feature 06 guardrail). + """ + _validate_window(window) + async with _connection_lock(self._database): + result = await self._database.execute( + "DELETE FROM quest_definition_windows " + "WHERE definition_id = ? AND window = ?", + (definition_id, window), + ) + return result.rowcount > 0 + async def _validate_assignable_child(self, child_id: int) -> None: """Raise ValueError unless the child exists and is active. diff --git a/custom_components/nestquest/migrations.py b/custom_components/nestquest/migrations.py index 0b000ef..29d5d20 100644 --- a/custom_components/nestquest/migrations.py +++ b/custom_components/nestquest/migrations.py @@ -73,6 +73,7 @@ from .schema import ( QUEST_DEFINITIONS_TABLE_SQL, SCHEMA_V1_STATEMENTS, SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, + SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, ) LOGGER = logging.getLogger(__name__) @@ -256,6 +257,10 @@ MIGRATIONS: Sequence[MigrationStep] = [ MIGRATION_1_V1_DDL, _rename_legacy_task_tables, _add_definition_assignees, + # Migration 4 (D-008): the windows table is a plain new table — a + # CREATE IF NOT EXISTS suffices for both fresh databases (which + # already made it in migration 1) and pre-window databases. + list(SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL), ] diff --git a/custom_components/nestquest/schema.py b/custom_components/nestquest/schema.py index 63d2674..7a73340 100644 --- a/custom_components/nestquest/schema.py +++ b/custom_components/nestquest/schema.py @@ -95,6 +95,15 @@ migration runner rebuilds a pre-D-008 table into this shape and copies its single-assignee column across. The composite primary key makes duplicate assignment impossible at the storage layer. +Quest windows (D-008): ``quest_definition_windows`` declares which day +windows a definition spans — one row per (definition, window), the +composite primary key making a window idempotent per definition. +``window`` is CHECK-constrained to the three spellings ``const`` owns; +``due_time`` optionally pins a due time inside the window and is +caller-validated (strict HH:MM) by the DAO layer, matching the date +policy above. The window clock ranges live in ``const.py``, not here: +this layer stores what the caller declares and never interprets clocks. + Quest instances: ``quest_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) @@ -260,6 +269,17 @@ SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL: list[str] = [ """, ] +SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL: list[str] = [ + """ + CREATE TABLE IF NOT EXISTS quest_definition_windows ( + definition_id INTEGER NOT NULL REFERENCES quest_definitions(id), + window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')), + due_time TEXT, + PRIMARY KEY (definition_id, window) + ) + """, +] + SCHEMA_V1_PRESENCE_SCHEDULES_DDL: list[str] = [ f""" CREATE TABLE IF NOT EXISTS presence_schedules ( @@ -342,6 +362,7 @@ SCHEMA_V1_STATEMENTS: list[str] = [ *SCHEMA_V1_SCHEDULE_RULES_DDL, *SCHEMA_V1_QUEST_DEFINITIONS_DDL, *SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, + *SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, *SCHEMA_V1_PRESENCE_SCHEDULES_DDL, *SCHEMA_V1_PRESENCE_OVERRIDES_DDL, *SCHEMA_V1_QUEST_INSTANCES_DDL, diff --git a/tests/test_const.py b/tests/test_const.py index aea1bd9..85ff105 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -73,6 +73,36 @@ def test_panel_idle_timeout_config() -> None: assert const.DEFAULT_PANEL_IDLE_TIMEOUT > 0 +def test_quest_windows_canonical_order() -> None: + """QUEST_WINDOWS is exactly the three spellings in canonical order.""" + assert const.QUEST_WINDOWS == ("morning", "afternoon", "evening") + assert const.WINDOW_MORNING == "morning" + assert const.WINDOW_AFTERNOON == "afternoon" + assert const.WINDOW_EVENING == "evening" + # No duplicates, every name covered by its individual constant. + assert len(set(const.QUEST_WINDOWS)) == 3 + + +def test_window_clock_ranges_cover_every_window() -> None: + """Each window has a strict HH:MM (start, end) range. + + The ranges follow the documented boundaries (D-008): morning ends + 11:59, afternoon spans 12:00-17:00, evening 17:00-21:00, and each + window's end equals the next window's start (the boundary belongs + to the later window at classification time). + """ + assert const.WINDOW_CLOCK_RANGES == { + "morning": ("00:00", "11:59"), + "afternoon": ("12:00", "17:00"), + "evening": ("17:00", "21:00"), + } + for window in const.QUEST_WINDOWS: + start, end = const.WINDOW_CLOCK_RANGES[window] + for value in (start, end): + parsed = datetime.strptime(value, "%H:%M").time() + assert parsed.strftime("%H:%M") == value + + def test_no_hardcoded_domain_or_db_filename_in_modules() -> None: """Ensure no module other than const.py hardcodes DOMAIN string or SQLITE_DB_FILENAME.""" pkg_dir = Path(const.__file__).parent diff --git a/tests/test_dao_rules.py b/tests/test_dao_rules.py index e73af68..0ae00b6 100644 --- a/tests/test_dao_rules.py +++ b/tests/test_dao_rules.py @@ -11,6 +11,7 @@ from custom_components.nestquest.dao_rules import ( ScheduleRuleRecord, ScheduleRulesDao, QuestDefinitionRecord, + QuestDefinitionWindowRecord, QuestDefinitionsDao, ) from custom_components.nestquest.db import NestQuestDatabase @@ -790,6 +791,96 @@ def test_definition_list_assignees_orders_by_child_sort_order( _with_db(tmp_path, "definition-assignees-order.db")(_body) +def test_definition_upsert_window_round_trip(tmp_path) -> None: + async def _body(database, rules, definitions, children, child): + rule = await rules.create("daily", "2026-09-14") + definition = await definitions.create("A", rule.id, NOW) + window = await definitions.upsert_window( + definition.id, "morning", due_time="09:00" + ) + assert window == QuestDefinitionWindowRecord( + definition.id, "morning", "09:00" + ) + # Upsert: re-declaring the same window updates in place, never + # duplicates (one row per definition+window). + updated = await definitions.upsert_window( + definition.id, "morning", due_time="09:30" + ) + assert updated.due_time == "09:30" + rows = await definitions.list_windows(definition.id) + assert [w.window for w in rows] == ["morning"] + # All three windows list in const order regardless of insertion. + await definitions.upsert_window(definition.id, "evening") + await definitions.upsert_window(definition.id, "afternoon") + rows = await definitions.list_windows(definition.id) + assert [w.window for w in rows] == [ + "morning", + "afternoon", + "evening", + ] + assert rows[1].due_time is None + return rows + + _with_db(tmp_path, "definition-window-roundtrip.db")(_body) + + +def test_definition_window_unknown_name_raises(tmp_path) -> None: + async def _body(database, rules, definitions, children, child): + rule = await rules.create("daily", "2026-09-14") + definition = await definitions.create("A", rule.id, NOW) + with pytest.raises(ValueError, match="window must be one of"): + await definitions.upsert_window(definition.id, "noon") + with pytest.raises(ValueError, match="window must be one of"): + await definitions.remove_window(definition.id, "MORNING") + return None + + _with_db(tmp_path, "definition-window-bad-name.db")(_body) + + +def test_definition_window_bad_due_time_raises(tmp_path) -> None: + async def _body(database, rules, definitions, children, child): + rule = await rules.create("daily", "2026-09-14") + definition = await definitions.create("A", rule.id, NOW) + with pytest.raises(ValueError, match="HH:MM"): + await definitions.upsert_window( + definition.id, "morning", due_time="9:30" + ) + with pytest.raises(ValueError, match="HH:MM"): + await definitions.upsert_window( + definition.id, "morning", due_time="25:00" + ) + return None + + _with_db(tmp_path, "definition-window-bad-time.db")(_body) + + +def test_definition_window_missing_definition_raises(tmp_path) -> None: + async def _body(database, rules, definitions, children, child): + with pytest.raises(ValueError, match="does not exist"): + await definitions.upsert_window(999, "morning") + return None + + _with_db(tmp_path, "definition-window-missing.db")(_body) + + +def test_definition_remove_window_round_trip(tmp_path) -> None: + async def _body(database, rules, definitions, children, child): + rule = await rules.create("daily", "2026-09-14") + definition = await definitions.create("A", rule.id, NOW) + await definitions.upsert_window(definition.id, "morning") + assert await definitions.remove_window( + definition.id, "morning" + ) is True + assert await definitions.list_windows(definition.id) == [] + # Removing an absent window reports False. + assert await definitions.remove_window( + definition.id, "morning" + ) is False + return None + + _with_db(tmp_path, "definition-window-remove.db")(_body) + + def test_definition_unassigned_has_no_assignees(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") @@ -889,7 +980,7 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None: } sql_pattern = re.compile( r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?" - r"(schedule_rules|quest_definitions|quest_definition_assignees)\b", + r"(schedule_rules|quest_definitions|quest_definition_assignees|quest_definition_windows)\b", re.IGNORECASE, ) offenders: list[str] = [] @@ -941,7 +1032,7 @@ def test_dao_instances_test_file_uses_dao_not_raw_rules_sql() -> None: ) sql_pattern = re.compile( r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|" - r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions|quest_definition_assignees)\b", + r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions|quest_definition_assignees|quest_definition_windows)\b", re.IGNORECASE, ) assert sql_pattern.search(remaining) is None, ( @@ -986,7 +1077,7 @@ def test_dao_rules_test_file_uses_dao_not_raw_table_sql() -> None: ) sql_pattern = re.compile( r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|" - r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions|quest_definition_assignees)\b", + r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions|quest_definition_assignees|quest_definition_windows)\b", re.IGNORECASE, ) assert sql_pattern.search(remaining) is None, ( diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 155d795..ce81da3 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -17,7 +17,10 @@ from custom_components.nestquest.migrations import ( apply_migrations, read_schema_version, ) -from custom_components.nestquest.schema import SCHEMA_V1_STATEMENTS +from custom_components.nestquest.schema import ( + SCHEMA_V1_STATEMENTS, + SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, +) def _run(coro): @@ -64,6 +67,7 @@ def _counts(database) -> dict[str, int]: "schedule_rules", "quest_definitions", "quest_definition_assignees", + "quest_definition_windows", "presence_schedules", "presence_overrides", "quest_instances", @@ -101,6 +105,7 @@ def test_fresh_file_migration_creates_all_v1_tables(tmp_path) -> None: "schedule_rules", "quest_definitions", "quest_definition_assignees", + "quest_definition_windows", "presence_schedules", "presence_overrides", "quest_instances", @@ -138,18 +143,19 @@ def test_empty_version_table_reads_as_version_0(tmp_path) -> None: def test_migration_list_shape() -> None: - """Migration 1 is the v1 DDL; 2 and 3 are the callable steps. + """Migration 1 is the v1 DDL; 2 and 3 are callable steps. Nothing ever shipped, so migration 1 was rewritten pre-release (D-007) to create the quest_* tables directly; migration 2 exists - for dev databases stamped version 1 by the pre-rename runner, and + for dev databases stamped version 1 by the pre-rename runner, migration 3 brings pre-D-008 definitions to the multi-assignee - model. + model, and migration 4 adds the windows table. """ assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS assert callable(MIGRATIONS[1]) assert callable(MIGRATIONS[2]) - assert len(MIGRATIONS) == 3 + assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL + assert len(MIGRATIONS) == 4 # --------------------------------------------------------------------------- diff --git a/tests/test_schema.py b/tests/test_schema.py index 58e65de..bd18996 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -17,6 +17,7 @@ from custom_components.nestquest.schema import ( SCHEMA_V1_STATEMENTS, SCHEMA_V1_QUEST_DEFINITIONS_DDL, SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, + SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, SCHEMA_V1_QUEST_INSTANCES_DDL, async_apply_ddl, ) @@ -167,13 +168,14 @@ def _insert_override(database, child_id, **overrides): # --------------------------------------------------------------------------- -def test_schema_v1_statements_compose_the_seven_tables() -> None: +def test_schema_v1_statements_compose_the_eight_tables() -> None: assert SCHEMA_V1_STATEMENTS == [ *SCHEMA_V1_CHILDREN_DDL, *SCHEMA_V1_ADMIN_USERS_DDL, *SCHEMA_V1_SCHEDULE_RULES_DDL, *SCHEMA_V1_QUEST_DEFINITIONS_DDL, *SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, + *SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, *SCHEMA_V1_PRESENCE_SCHEDULES_DDL, *SCHEMA_V1_PRESENCE_OVERRIDES_DDL, *SCHEMA_V1_QUEST_INSTANCES_DDL, @@ -974,6 +976,166 @@ def test_quest_definition_assignees_duplicate_pair_fails(tmp_path) -> None: _run(database.close()) +def test_quest_definition_windows_table_exists_after_applying_ddl( + tmp_path, +) -> None: + database = _open_db(tmp_path / "windows.db") + try: + _apply(database) + row = _run( + database.fetch_one( + "SELECT name FROM sqlite_master WHERE type = 'table' " + "AND name = 'quest_definition_windows'" + ) + ) + assert row is not None and row[0] == "quest_definition_windows" + finally: + _run(database.close()) + + +def test_quest_definition_windows_columns_types_and_constraints( + tmp_path, +) -> None: + database = _open_db(tmp_path / "windows-columns.db") + try: + _apply(database) + columns = _run( + database.fetch_all("PRAGMA table_info(quest_definition_windows)") + ) + # cid, name, type, notnull, dflt_value, pk — the composite + # primary key spans definition_id and window. + assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [ + ("definition_id", "INTEGER", 1, None, 1), + ("window", "TEXT", 1, None, 2), + ("due_time", "TEXT", 0, None, 0), + ] + finally: + _run(database.close()) + + +def test_quest_definition_windows_foreign_keys_declared(tmp_path) -> None: + database = _open_db(tmp_path / "windows-fks.db") + try: + _apply(database) + fks = _run( + database.fetch_all( + "PRAGMA foreign_key_list(quest_definition_windows)" + ) + ) + declared = {(row[2], row[3], row[4]) for row in fks} + assert declared == { + ("quest_definitions", "definition_id", "id"), + } + finally: + _run(database.close()) + + +def test_quest_definition_windows_valid_rows_round_trip(tmp_path) -> None: + database = _open_db(tmp_path / "windows-roundtrip.db") + try: + _apply(database) + _run( + database.execute( + "INSERT INTO children (display_name, created_at) VALUES (?, ?)", + ("Ada", "2026-09-13T00:00:00+00:00"), + ) + ) + _insert_rule(database) + definition_id = _insert_definition(database, assignees=None) + for window, due in ( + ("morning", "09:00"), + ("afternoon", None), + ("evening", "19:30"), + ): + _run( + database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) VALUES (?, ?, ?)", + (definition_id, window, due), + ) + ) + rows = _run( + database.fetch_all( + "SELECT window, due_time FROM quest_definition_windows " + "WHERE definition_id = ? ORDER BY window", + (definition_id,), + ) + ) + assert rows == [ + ("afternoon", None), + ("evening", "19:30"), + ("morning", "09:00"), + ] + finally: + _run(database.close()) + + +@pytest.mark.parametrize( + "window", ["Midnight", "MORNING", "noon", "", "evenings"] +) +def test_quest_definition_windows_window_check_enforced( + tmp_path, window +) -> None: + database = _open_db(tmp_path / "windows-check.db") + try: + _apply(database) + _insert_rule(database) + definition_id = _insert_definition(database, assignees=None) + with pytest.raises(sqlite3.IntegrityError, match="CHECK"): + _run( + database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) VALUES (?, ?, NULL)", + (definition_id, window), + ) + ) + finally: + _run(database.close()) + + +def test_quest_definition_windows_duplicate_window_fails(tmp_path) -> None: + database = _open_db(tmp_path / "windows-duplicate.db") + try: + _apply(database) + _insert_rule(database) + definition_id = _insert_definition(database, assignees=None) + _run( + database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) " + "VALUES (?, 'morning', NULL)", + (definition_id,), + ) + ) + with pytest.raises(sqlite3.IntegrityError): + _run( + database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) " + "VALUES (?, 'morning', '09:00')", + (definition_id,), + ) + ) + finally: + _run(database.close()) + + +def test_quest_definition_windows_unknown_definition_fails(tmp_path) -> None: + database = _open_db(tmp_path / "windows-bad-definition.db") + try: + _apply(database) + with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"): + _run( + database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) " + "VALUES (999, 'morning', NULL)" + ) + ) + finally: + _run(database.close()) + + def test_quest_definition_assignees_multiple_children_allowed( tmp_path, ) -> None: -- 2.55.0 From 25f1479444a5ce7edbced43ace93df5beb1de49f Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 10:17:31 -0400 Subject: [PATCH 04/42] Widen instance key to (definition_id, child_id, due_date, window) (D-008) Per IMPLEMENTATION-ORDER.md 1.2: quest_instances gains a NOT NULL window column (CHECK-constrained to the const window spellings) and the table-level UNIQUE constraint widens to the full fan-out tuple. - schema.py: instance table body extracted to QUEST_INSTANCES_TABLE_SQL shared with the rebuild migration; uniqueness docstring rewritten for the widened key. - migrations.py: migration 5 rebuilds a pre-window quest_instances via the canonical procedure; migrated rows are pinned to the documented default 'morning' window (pre-window rows were whole-day obligations; future dates are regenerated with real windows by materialization), preserving ids so completion_events keep pointing at the same instances; PRAGMA foreign_key_check verified inside the transaction. - dao_instances.py: upsert takes required keyword window and is idempotent on the widened tuple; the immutable-once-completed check and the read-back get() are keyed on the full tuple; window spelling validated via dao_rules._validate_window. - tests: schema shape (window column, widened autoindex columns), twice-daily and shared-assignee fan-out cases at both the DDL and DAO layers (incl. idempotent re-runs), cross-child same-key allowance, window CHECK/NOT NULL constraints, v2 seed frozen to the true version-2 shapes and its migration assertions widened. --- custom_components/nestquest/dao_instances.py | 86 ++++--- custom_components/nestquest/migrations.py | 85 ++++++- custom_components/nestquest/schema.py | 70 +++--- tests/test_dao_instances.py | 224 ++++++++++++------- tests/test_dao_matrix.py | 23 +- tests/test_dao_rules.py | 2 +- tests/test_migrations.py | 61 ++++- tests/test_schema.py | 101 ++++++++- 8 files changed, 479 insertions(+), 173 deletions(-) diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py index 43c8997..2e9aaa3 100644 --- a/custom_components/nestquest/dao_instances.py +++ b/custom_components/nestquest/dao_instances.py @@ -15,13 +15,13 @@ event. A package-wide guard test (bottom of the test module) keeps mutation SQL for this table out of every other module. Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on -(definition_id, due_date) — re-running materialization for a date -whose instance already exists updates nothing that matters -(child_id/due_time are refreshed only in the degenerate re-generation -case where they changed) and never duplicates a row. The schema's -UNIQUE(definition_id, due_date) is the last line of defense. The -upsert refuses a child that is not an assignee of the definition -(D-008 multi-assignee model). +(definition_id, child_id, due_date, window) — re-running +materialization for a tuple whose instance already exists refreshes +only the generation-time snapshot columns and never duplicates a row. +The schema's UNIQUE(definition_id, child_id, due_date, window) is the +last line of defense. The upsert refuses a child that is not an +assignee of the definition and a window outside ``const.QUEST_WINDOWS`` +(D-008 multi-assignee, multi-window model). ``delete_future_uncompleted`` implements the Feature 06/07 rule that reassignment and schedule edits regenerate future instances: it @@ -37,7 +37,7 @@ import datetime from dataclasses import dataclass from .dao_children import _connection_lock -from .dao_rules import _validate_date +from .dao_rules import _validate_date, _validate_window from .db import NestQuestDatabase #: Timestamp policy for completion_events.occurred_at: strict UTC @@ -90,6 +90,7 @@ class QuestInstanceRecord: id: int definition_id: int child_id: int + window: str due_date: str due_time: str | None generated_at: str @@ -110,7 +111,7 @@ class CompletionEventRecord: _INSTANCE_COLUMNS = ( - "id, definition_id, child_id, due_date, due_time, generated_at" + "id, definition_id, child_id, window, due_date, due_time, generated_at" ) _EVENT_COLUMNS = ( "id, instance_id, child_id, event_type, actor_source, actor_user_id, " @@ -128,9 +129,10 @@ def _instance_from_row(row: tuple) -> QuestInstanceRecord: id=row[0], definition_id=row[1], child_id=row[2], - due_date=row[3], - due_time=row[4], - generated_at=row[5], + window=row[3], + due_date=row[4], + due_time=row[5], + generated_at=row[6], ) @@ -160,31 +162,40 @@ class QuestInstancesDao: due_date: str, generated_at: str, *, + window: str, due_time: str | None = None, ) -> QuestInstanceRecord: - """Create the dated instance for (definition_id, due_date) idempotently. + """Create the dated instance for the widened key idempotently. - Running materialization twice for the same definition/date - yields ONE row: the ON CONFLICT path refreshes the - generation-time snapshot columns (child_id, due_time) and - regenerates the stamp, but can never create a duplicate — the - schema UNIQUE(definition_id, due_date) backs this up. + The instance key is (definition_id, child_id, due_date, window) + (D-008): running materialization twice for the same tuple + yields ONE row — the ON CONFLICT path refreshes the + generation-time snapshot columns (due_time) and regenerates the + stamp, but can never create a duplicate. The schema's + UNIQUE(definition_id, child_id, due_date, window) backs this + up, so the twice-daily case (two windows, one child, one date) + yields two rows and the shared case (three assignees, one + window) yields three. Guardrails enforced here: - ``due_date`` must be TODAY or later: instances are never generated in the past (Feature 07). The caller's materialization horizon is future-dated by definition. + - ``window`` must be one of ``const.QUEST_WINDOWS``. - An existing instance WITH a completion event is IMMUTABLE: the conflict path must not rewrite its snapshot columns (Feature 07/08). The upsert refuses with ValueError in that case — silently no-oping would mask a materialization bug. + - The child must be an assignee of the definition (D-008 + multi-assignee model). The definition must exist (validated atomically under the connection lock so a concurrent definition deactivation or child deletion cannot slip between check and insert). """ _validate_date(due_date, "due_date") + _validate_window(window) # Fail fast on obviously-past dates BEFORE queuing on the # connection lock (a nice early error); the AUTHORITATIVE # check runs after the lock is acquired, because a caller can @@ -227,51 +238,56 @@ class QuestInstancesDao: f"assigned to child {child_id}; instance must " "carry an assigned child" ) - child = await self._database.fetch_one( - "SELECT 1 FROM children WHERE id = ?", (child_id,) - ) - if child is None: - raise ValueError(f"child {child_id} does not exist") existing = await self._database.fetch_one( "SELECT 1 FROM completion_events " "WHERE instance_id = (SELECT id FROM quest_instances " - "WHERE definition_id = ? AND due_date = ?)", - (definition_id, due_date), + "WHERE definition_id = ? AND child_id = ? " + "AND due_date = ? AND window = ?)", + (definition_id, child_id, due_date, window), ) if existing is not None: raise ValueError( f"instance for (definition {definition_id}, " - f"due_date {due_date!r}) already has a completion " + f"child {child_id}, due_date {due_date!r}, " + f"window {window!r}) already has a completion " "event and is immutable; it cannot be regenerated" ) await self._database.execute( "INSERT INTO quest_instances (definition_id, child_id, " - "due_date, due_time, generated_at) " - "VALUES (?, ?, ?, ?, ?) " - "ON CONFLICT (definition_id, due_date) DO UPDATE SET " - "child_id = excluded.child_id, " + "window, due_date, due_time, generated_at) " + "VALUES (?, ?, ?, ?, ?, ?) " + "ON CONFLICT (definition_id, child_id, due_date, " + "window) DO UPDATE SET " "due_time = excluded.due_time, " "generated_at = excluded.generated_at", ( definition_id, child_id, + window, due_date, due_time, generated_at, ), ) - instance = await self.get(definition_id, due_date) + instance = await self.get( + definition_id, child_id, due_date, window + ) assert instance is not None return instance async def get( - self, definition_id: int, due_date: str + self, + definition_id: int, + child_id: int, + due_date: str, + window: str, ) -> QuestInstanceRecord | None: - """Return the instance for (definition_id, due_date), or None.""" + """Return the instance for the widened key tuple, or None.""" row = await self._database.fetch_one( f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances " - "WHERE definition_id = ? AND due_date = ?", - (definition_id, due_date), + "WHERE definition_id = ? AND child_id = ? AND due_date = ? " + "AND window = ?", + (definition_id, child_id, due_date, window), ) return _instance_from_row(row) if row is not None else None diff --git a/custom_components/nestquest/migrations.py b/custom_components/nestquest/migrations.py index 29d5d20..9901505 100644 --- a/custom_components/nestquest/migrations.py +++ b/custom_components/nestquest/migrations.py @@ -24,9 +24,12 @@ names before anything else assumes the new model. Migration 3 brings pre-D-008 databases to the multi-assignee model: it creates ``quest_definition_assignees`` and, when ``quest_definitions`` still carries a single ``child_id`` column, rebuilds the table without it, -copying each definition's assignee across. Each callable migration -runs inside its own transaction together with its version stamp, so a -crash mid-step rolls the statements and the stamp back together. +copying each definition's assignee across. Migration 4 adds the +windows table and migration 5 rebuilds ``quest_instances`` onto the +widened (definition_id, child_id, due_date, window) key. Each callable +migration runs inside its own transaction together with its version +stamp, so a crash mid-step rolls the statements and the stamp back +together. Migration entries are either an ordered sequence of SQL statements or an async callable taking ``(database, target_version)``. Callables @@ -71,6 +74,7 @@ from typing import Union from .db import NestQuestDatabase from .schema import ( QUEST_DEFINITIONS_TABLE_SQL, + QUEST_INSTANCES_TABLE_SQL, SCHEMA_V1_STATEMENTS, SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, @@ -147,6 +151,14 @@ async def _table_has_column( return any(row[1] == column for row in rows) +#: Window assigned to instances migrated from the pre-window instance +#: shape (D-008). Pre-window rows were generated for whole-day +#: obligations; the morning window is the documented default landing +#: spot, and future dates are regenerated with real window declarations +#: by materialization (delete_future_uncompleted + re-run). +LEGACY_MIGRATED_WINDOW = "morning" + + async def _rename_legacy_task_tables( database: NestQuestDatabase, target_version: int ) -> None: @@ -250,6 +262,70 @@ async def _add_definition_assignees( await database.execute("PRAGMA foreign_keys = ON") +async def _widen_instance_key( + database: NestQuestDatabase, target_version: int +) -> None: + """Bring ``quest_instances`` to the D-008 instance key. + + Databases whose instance table still carries the pre-window shape + (no ``window`` column, UNIQUE on (definition_id, due_date)) are + rebuilt via SQLite's canonical procedure into the widened shape — + UNIQUE (definition_id, child_id, due_date, window) — with every + migrated row landing in :data:`LEGACY_MIGRATED_WINDOW` so existing + ids (and the completion events referencing them) survive intact. + The old rows were unique per (definition, due_date), so pinning one + window cannot collide with the widened key. Fresh databases whose + table already matches the canonical shape only stamp. + + The rebuild runs with ``foreign_keys = OFF`` (dropping the old + table under enforcement would fire the implicit DELETE and violate + the ``completion_events`` foreign key) and verifies + ``PRAGMA foreign_key_check`` inside the transaction before the + stamp, so a corrupt copy aborts the whole migration. + """ + legacy_shape = not await _table_has_column( + database, "quest_instances", "window" + ) + await database.execute("PRAGMA foreign_keys = OFF") + try: + async with database.transaction(): + if legacy_shape: + await database.execute( + "CREATE TABLE quest_instances_rebuilt " + f"{QUEST_INSTANCES_TABLE_SQL}" + ) + await database.execute( + "INSERT INTO quest_instances_rebuilt " + "(id, definition_id, child_id, window, due_date, " + "due_time, generated_at) " + "SELECT id, definition_id, child_id, ?, due_date, " + "due_time, generated_at " + "FROM quest_instances ORDER BY id", + (LEGACY_MIGRATED_WINDOW,), + ) + await database.execute("DROP TABLE quest_instances") + await database.execute( + "ALTER TABLE quest_instances_rebuilt " + "RENAME TO quest_instances" + ) + violations = await database.fetch_all( + "PRAGMA foreign_key_check" + ) + if violations: + raise RuntimeError( + "instance rebuild produced foreign-key " + f"violations: {violations!r}" + ) + LOGGER.info( + "Widened quest_instances key; migrated rows pinned " + "to the %r window", + LEGACY_MIGRATED_WINDOW, + ) + await stamp_schema_version(database, target_version) + finally: + await database.execute("PRAGMA foreign_keys = ON") + + #: The ordered migration list. Append-only: never edit an applied #: entry, add the next one instead. (Migration 1's content was #: rewritten pre-release per D-007 — no version 1 database shipped.) @@ -261,6 +337,9 @@ MIGRATIONS: Sequence[MigrationStep] = [ # CREATE IF NOT EXISTS suffices for both fresh databases (which # already made it in migration 1) and pre-window databases. list(SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL), + # Migration 5 (D-008): rebuild quest_instances onto the widened + # (definition_id, child_id, due_date, window) key. + _widen_instance_key, ] diff --git a/custom_components/nestquest/schema.py b/custom_components/nestquest/schema.py index 7a73340..46447e6 100644 --- a/custom_components/nestquest/schema.py +++ b/custom_components/nestquest/schema.py @@ -109,25 +109,31 @@ 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). +``child_id``, ``due_date`` and ``window`` snapshot the +materialization-time facts ("why this instance exists"): assignment +edits and rule edits change future instances only and never rewrite +these rows. ``window`` records which declared day window produced the +instance (D-008), CHECK-constrained to the same three spellings +``const`` owns. ``due_time`` snapshots the window'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_quest_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. +Uniqueness: the (definition_id, child_id, due_date, window) tuple 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_quest_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 tuple: the twice-daily case (one definition, two +windows, one child, one date) yields two rows and the shared case (one +definition, three assignees, one window) yields three. 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 @@ -316,17 +322,25 @@ SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [ """, ] +#: Column body of ``quest_instances``, shared between the v1 DDL +#: below and the instance-key rebuild migration, which must recreate +#: the table in exactly this shape (D-008: the instance key is +#: (definition_id, child_id, due_date, window) — one instance per +#: assigned child per declared window per firing date). +QUEST_INSTANCES_TABLE_SQL = """( + id INTEGER PRIMARY KEY AUTOINCREMENT, + definition_id INTEGER NOT NULL REFERENCES quest_definitions(id), + child_id INTEGER NOT NULL REFERENCES children(id), + window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')), + due_date TEXT NOT NULL, + due_time TEXT, + generated_at TEXT NOT NULL, + UNIQUE (definition_id, child_id, due_date, window) +)""" + SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [ - """ - CREATE TABLE IF NOT EXISTS quest_instances ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - definition_id INTEGER NOT NULL REFERENCES quest_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) - ) + f""" + CREATE TABLE IF NOT EXISTS quest_instances {QUEST_INSTANCES_TABLE_SQL} """, ] diff --git a/tests/test_dao_instances.py b/tests/test_dao_instances.py index 628752e..fe79027 100644 --- a/tests/test_dao_instances.py +++ b/tests/test_dao_instances.py @@ -119,12 +119,10 @@ def test_upsert_creates_then_is_idempotent(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): first = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") assert isinstance(first, QuestInstanceRecord) second = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") assert second.id == first.id rows = await database.fetch_one( "SELECT COUNT(*) FROM quest_instances" @@ -138,27 +136,116 @@ def test_upsert_creates_then_is_idempotent(tmp_path) -> None: def test_upsert_different_dates_create_distinct_rows(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): - a = await instances.upsert(definition.id, child.id, D1, _now_stamp()) - b = await instances.upsert(definition.id, child.id, D2, _now_stamp()) + a = await instances.upsert(definition.id, child.id, D1, _now_stamp(), window="morning") + b = await instances.upsert(definition.id, child.id, D2, _now_stamp(), window="morning") assert a.id != b.id return (a, b) _with_db(tmp_path, "upsert-multi-date.db")(_body) +def test_upsert_twice_daily_fanout_two_windows_two_rows(tmp_path) -> None: + """D-008 twice-daily case: one definition, one child, one date, + two windows — two rows, each idempotent on its own tuple.""" + async def _body(database, children, rules, definitions, instances, + events, child, definition): + morning = await instances.upsert( + definition.id, child.id, D1, _now_stamp(), window="morning" + ) + evening = await instances.upsert( + definition.id, child.id, D1, _now_stamp(), window="evening" + ) + assert morning.id != evening.id + rows = await database.fetch_all( + "SELECT window, child_id, due_date FROM quest_instances " + "WHERE definition_id = ? AND due_date = ?", + (definition.id, D1), + ) + assert sorted(rows) == [ + ("evening", child.id, D1), + ("morning", child.id, D1), + ] + # Re-running both is still idempotent. + await instances.upsert( + definition.id, child.id, D1, _now_stamp(), window="morning" + ) + await instances.upsert( + definition.id, child.id, D1, _now_stamp(), window="evening" + ) + count = await database.fetch_one( + "SELECT COUNT(*) FROM quest_instances" + ) + assert count == (2,) + return (morning, evening) + + _with_db(tmp_path, "upsert-twice-daily.db")(_body) + + +def test_upsert_shared_definition_three_assignees_three_rows( + tmp_path, +) -> None: + """D-008 shared case: one definition, three assignees, one window + — three rows, one per child.""" + async def _body(database, children, rules, definitions, instances, + events, child, definition): + second = await children.create("Bo", _now_stamp()) + third = await children.create("Cleo", _now_stamp()) + await definitions.add_assignee(definition.id, second.id) + await definitions.add_assignee(definition.id, third.id) + created = [ + await instances.upsert( + definition.id, assignee.id, D1, _now_stamp(), + window="morning", + ) + for assignee in (child, second, third) + ] + assert len({row.id for row in created}) == 3 + count = await database.fetch_one( + "SELECT COUNT(*) FROM quest_instances " + "WHERE definition_id = ? AND due_date = ? AND window = ?", + (definition.id, D1, "morning"), + ) + assert count == (3,) + # Re-running the fan-out changes nothing. + for assignee in (child, second, third): + await instances.upsert( + definition.id, assignee.id, D1, _now_stamp(), + window="morning", + ) + count = await database.fetch_one( + "SELECT COUNT(*) FROM quest_instances" + ) + assert count == (3,) + return created + + _with_db(tmp_path, "upsert-shared-fanout.db")(_body) + + +def test_upsert_window_outside_const_list_raises(tmp_path) -> None: + """The DAO rejects a window spelling the schema CHECK would reject + anyway, with the typed ValueError instead of raw IntegrityError.""" + async def _body(database, children, rules, definitions, instances, + events, child, definition): + with pytest.raises(ValueError, match="window must be one of"): + await instances.upsert( + definition.id, child.id, D1, _now_stamp(), window="noon" + ) + return None + + _with_db(tmp_path, "upsert-bad-window.db")(_body) + + def test_upsert_refreshes_snapshot_columns_on_conflict(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): first = await instances.upsert( - definition.id, child.id, D1, _now_stamp(), due_time="08:00" - ) + definition.id, child.id, D1, _now_stamp(), due_time="08:00", window="morning") second = await instances.upsert( definition.id, child.id, D1, f"{D1}T13:00:00+00:00", - due_time="08:30", - ) + due_time="08:30", window="morning") assert second.id == first.id assert second.due_time == "08:30" assert second.generated_at == f"{D1}T13:00:00+00:00" @@ -172,7 +259,7 @@ def test_upsert_rejects_wrong_child_for_definition(tmp_path) -> None: events, child, definition): other = await children.create("Bo", _now_stamp()) with pytest.raises(ValueError, match="assigned to"): - await instances.upsert(definition.id, other.id, D1, _now_stamp()) + await instances.upsert(definition.id, other.id, D1, _now_stamp(), window="morning") return None _with_db(tmp_path, "upsert-wrong-child.db")(_body) @@ -182,7 +269,7 @@ def test_upsert_rejects_unknown_definition_and_child(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): with pytest.raises(ValueError, match="definition 999"): - await instances.upsert(999, child.id, D1, _now_stamp()) + await instances.upsert(999, child.id, D1, _now_stamp(), window="morning") # Unknown child: the definition-assignee check fires first and # is the one that matters (an instance must carry an assigned # child). Under D-008 the assignee check is a membership test @@ -193,7 +280,7 @@ def test_upsert_rejects_unknown_definition_and_child(tmp_path) -> None: "X", rule.id, _now_stamp(), assignee_child_ids=[child.id] ) with pytest.raises(ValueError, match="not assigned to child 999"): - await instances.upsert(other_definition.id, 999, D1, _now_stamp()) + await instances.upsert(other_definition.id, 999, D1, _now_stamp(), window="morning") return None _with_db(tmp_path, "upsert-unknown.db")(_body) @@ -204,7 +291,7 @@ def test_upsert_malformed_due_date_raises(tmp_path) -> None: events, child, definition): for bad in ("not-a-date", "2026-9-15", ""): with pytest.raises(ValueError, match="YYYY-MM-DD"): - await instances.upsert(definition.id, child.id, bad, _now_stamp()) + await instances.upsert(definition.id, child.id, bad, _now_stamp(), window="morning") return None _with_db(tmp_path, "upsert-bad-date.db")(_body) @@ -216,11 +303,11 @@ def test_upsert_rejects_past_due_dates(tmp_path) -> None: events, child, definition): yesterday = _future_day(-1) with pytest.raises(ValueError, match="never generated in the past"): - await instances.upsert(definition.id, child.id, yesterday, _now_stamp()) + await instances.upsert(definition.id, child.id, yesterday, _now_stamp(), window="morning") # Today itself is allowed (the rollover job generates today's # instances before the day is over). today = datetime.date.today().isoformat() - record = await instances.upsert(definition.id, child.id, today, _now_stamp()) + record = await instances.upsert(definition.id, child.id, today, _now_stamp(), window="morning") assert record.due_date == today return record @@ -296,7 +383,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date( await asyncio.wait_for(holder_inside.wait(), timeout=5) task = asyncio.ensure_future( - instances.upsert(definition.id, child.id, today, _now_stamp()) + instances.upsert(definition.id, child.id, today, _now_stamp(), window="morning") ) # A is now queued on the connection lock (it cannot pass # its in-lock check while B holds the lock). Shift the @@ -349,17 +436,15 @@ def test_upsert_refuses_immutable_completed_instance(tmp_path) -> None: events, child, definition): generated_at = _now_stamp() instance = await instances.upsert( - definition.id, child.id, D1, generated_at, due_time="08:00" - ) + definition.id, child.id, D1, generated_at, due_time="08:00", window="morning") await events.append( instance.id, child.id, "completed", "user", _now_stamp(), True, actor_user_id="user-1", ) with pytest.raises(ValueError, match="immutable"): await instances.upsert( - definition.id, child.id, D1, _now_stamp(), due_time="09:00" - ) - fetched = await instances.get(definition.id, D1) + definition.id, child.id, D1, _now_stamp(), due_time="09:00", window="morning") + fetched = await instances.get(definition.id, child.id, D1, "morning") assert fetched.due_time == "08:00", ( "failed regeneration must not touch the completed instance" ) @@ -378,12 +463,11 @@ def test_get_by_definition_and_date_round_trip(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): created = await instances.upsert( - definition.id, child.id, D1, _now_stamp(), due_time="17:30" - ) - fetched = await instances.get(definition.id, D1) + definition.id, child.id, D1, _now_stamp(), due_time="17:30", window="morning") + fetched = await instances.get(definition.id, child.id, D1, "morning") assert fetched == created assert fetched.due_time == "17:30" - assert await instances.get(definition.id, D2) is None + assert await instances.get(definition.id, child.id, D2, "morning") is None return fetched _with_db(tmp_path, "get-round-trip.db")(_body) @@ -392,7 +476,7 @@ def test_get_by_definition_and_date_round_trip(tmp_path) -> None: def test_get_by_id_round_trip(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): - created = await instances.upsert(definition.id, child.id, D1, _now_stamp()) + created = await instances.upsert(definition.id, child.id, D1, _now_stamp(), window="morning") fetched = await instances.get_by_id(created.id) assert fetched == created assert await instances.get_by_id(999) is None @@ -409,11 +493,10 @@ def test_list_by_child_and_date_filters(tmp_path) -> None: other_definition = await definitions.create( "Make bed", rule.id, _now_stamp(), assignee_child_ids=[other.id] ) - await instances.upsert(definition.id, child.id, D1, _now_stamp()) - await instances.upsert(definition.id, child.id, D2, _now_stamp()) + await instances.upsert(definition.id, child.id, D1, _now_stamp(), window="morning") + await instances.upsert(definition.id, child.id, D2, _now_stamp(), window="morning") await instances.upsert( - other_definition.id, other.id, D1, _now_stamp() - ) + other_definition.id, other.id, D1, _now_stamp(), window="morning") mine = await instances.list_by_child_and_date(child.id, D1) assert len(mine) == 1 assert mine[0].child_id == child.id @@ -427,7 +510,7 @@ def test_list_by_date_range_filters_and_orders(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): for day in (D1, D3, D5, D20): - await instances.upsert(definition.id, child.id, day, _now_stamp()) + await instances.upsert(definition.id, child.id, day, _now_stamp(), window="morning") found = await instances.list_by_date_range( child.id, D2, D6 ) @@ -461,18 +544,17 @@ def test_list_by_date_range_rejects_inverted_range(tmp_path) -> None: def test_delete_future_uncompleted_removes_only_eligible(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): - await instances.upsert(definition.id, child.id, D1, _now_stamp()) + await instances.upsert(definition.id, child.id, D1, _now_stamp(), window="morning") future_open = await instances.upsert( - definition.id, child.id, D5, _now_stamp() - ) - await instances.upsert(definition.id, child.id, D6, _now_stamp()) + definition.id, child.id, D5, _now_stamp(), window="morning") + await instances.upsert(definition.id, child.id, D6, _now_stamp(), window="morning") deleted = await instances.delete_future_uncompleted( definition.id, D5 ) assert deleted == 2 - assert await instances.get(definition.id, D1) is not None - assert await instances.get(definition.id, D5) is None - assert await instances.get(definition.id, D6) is None + assert await instances.get(definition.id, child.id, D1, "morning") is not None + assert await instances.get(definition.id, child.id, D5, "morning") is None + assert await instances.get(definition.id, child.id, D6, "morning") is None return deleted _with_db(tmp_path, "delete-future.db")(_body) @@ -482,11 +564,9 @@ def test_delete_future_uncompleted_spares_completed(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): completed = await instances.upsert( - definition.id, child.id, D5, _now_stamp() - ) + definition.id, child.id, D5, _now_stamp(), window="morning") open_one = await instances.upsert( - definition.id, child.id, D6, _now_stamp() - ) + definition.id, child.id, D6, _now_stamp(), window="morning") await events.append( completed.id, child.id, "completed", "user", _now_stamp(), True, actor_user_id="user-1", @@ -521,12 +601,12 @@ def test_delete_future_uncompleted_cannot_backdate_cutoff( async def _body(database, children, rules, definitions, instances, events, child, definition): today = datetime.date.today().isoformat() - await instances.upsert(definition.id, child.id, today, _now_stamp()) + await instances.upsert(definition.id, child.id, today, _now_stamp(), window="morning") deleted = await instances.delete_future_uncompleted( definition.id, "2020-01-01" ) assert deleted == 1 - assert await instances.get(definition.id, today) is None + assert await instances.get(definition.id, child.id, today, "morning") is None return deleted _with_db(tmp_path, "delete-no-backdate.db")(_body) @@ -541,13 +621,13 @@ def test_delete_future_uncompleted_other_definitions_untouched( "Make bed", (await rules.create("daily", D1)).id, _now_stamp(), assignee_child_ids=[child.id], ) - await instances.upsert(definition.id, child.id, D20, _now_stamp()) - await instances.upsert(other.id, child.id, D20, _now_stamp()) + await instances.upsert(definition.id, child.id, D20, _now_stamp(), window="morning") + await instances.upsert(other.id, child.id, D20, _now_stamp(), window="morning") deleted = await instances.delete_future_uncompleted( definition.id, D20 ) assert deleted == 1 - assert await instances.get(other.id, D20) is not None + assert await instances.get(other.id, child.id, D20, "morning") is not None return deleted _with_db(tmp_path, "delete-scoped.db")(_body) @@ -562,8 +642,7 @@ def test_append_completion_round_trips(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") event = await events.append( instance.id, child.id, "completed", "user", _now_stamp(), True, actor_user_id="user-1", @@ -582,8 +661,7 @@ def test_append_panel_event_round_trips(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") event = await events.append( instance.id, child.id, "completed", "panel", _now_stamp(), False, actor_user_id=None, @@ -600,8 +678,7 @@ def test_append_uncompleted_with_optional_on_time(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") reversal = await events.append( instance.id, child.id, "uncompleted", "user", _now_stamp(), None, actor_user_id="user-1", @@ -622,8 +699,7 @@ def test_append_validation_rejects_bad_shapes(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") with pytest.raises(ValueError, match="event_type"): await events.append( instance.id, child.id, "missed", "user", _now_stamp(), True, @@ -670,8 +746,7 @@ def test_append_rejects_non_utc_timestamps(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") for bad in ( "2026-09-14 12:00:00+00:00", # space separator "2026-09-14T12:00:00", # no offset @@ -695,8 +770,7 @@ def test_append_requires_was_on_time_strict_bool(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") for bad in (0.5, 1.5, 2, "true", 1): with pytest.raises(ValueError, match="must be True, False"): await events.append( @@ -717,8 +791,7 @@ def test_append_rejects_unknown_instance_or_child(tmp_path) -> None: actor_user_id="user-1", ) instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") with pytest.raises(ValueError, match="belongs to child"): await events.append( instance.id, 999, "completed", "user", _now_stamp(), True, @@ -740,8 +813,7 @@ def test_completed_uncompleted_recompleted_three_ordered_events( async def _body(database, children, rules, definitions, instances, events, child, definition): instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") await events.append( instance.id, child.id, "completed", "panel", _now_stamp(), True ) @@ -771,8 +843,7 @@ def test_get_latest_for_instance_none_when_untouched(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") return await events.get_latest_for_instance(instance.id) assert _with_db(tmp_path, "latest-none.db")(_body) is None @@ -782,8 +853,7 @@ def test_get_latest_returns_uncompleted_when_reversed(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") await events.append( instance.id, child.id, "completed", "panel", _now_stamp(), True ) @@ -802,14 +872,11 @@ def test_list_by_child_and_date_range_scopes_by_due_date(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): early = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") inside = await instances.upsert( - definition.id, child.id, D2, _now_stamp() - ) + definition.id, child.id, D2, _now_stamp(), window="morning") late = await instances.upsert( - definition.id, child.id, D20, _now_stamp() - ) + definition.id, child.id, D20, _now_stamp(), window="morning") await events.append( early.id, child.id, "completed", "panel", _now_stamp(), True ) @@ -847,8 +914,7 @@ def test_list_events_occurred_at_scope_variant(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): late_due = await instances.upsert( - definition.id, child.id, D20, _now_stamp() - ) + definition.id, child.id, D20, _now_stamp(), window="morning") # The event's occurred_at is today at 08:00 UTC (_now_stamp); # the instance is due D20. The due-date scope queried on D20 # still finds the event (scoping follows the instance's due @@ -1028,8 +1094,7 @@ def test_completion_events_dao_instance_has_no_mutation_capability( database.execute = _recording instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") await events.append( instance.id, child.id, "completed", "user", _now_stamp(), True, actor_user_id="user-1", @@ -1059,8 +1124,7 @@ def test_append_concurrent_events_serialize(tmp_path) -> None: prepared ) instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") tasks = [ asyncio.ensure_future( events.append( diff --git a/tests/test_dao_matrix.py b/tests/test_dao_matrix.py index 9a2b36a..2360ffe 100644 --- a/tests/test_dao_matrix.py +++ b/tests/test_dao_matrix.py @@ -112,7 +112,8 @@ class World: self.child.id, D1, D2, True, note="test" ) self.instance = await self.instances.upsert( - self.definition.id, self.child.id, D1, _stamp() + self.definition.id, self.child.id, D1, _stamp(), + window="morning", ) @@ -291,12 +292,15 @@ def test_matrix_presence_overrides_crud_and_constraints(tmp_path) -> None: def test_matrix_quest_instances_crud_and_constraints(tmp_path) -> None: async def _body(w: World): # insert (seeded) + read - fetched = await w.instances.get(w.definition.id, D1) + fetched = await w.instances.get( + w.definition.id, w.child.id, D1, "morning" + ) assert fetched == w.instance # update path: conflict refresh on the OPEN instance — same row # id, snapshot columns (due_time) actually rewritten. refreshed = await w.instances.upsert( - w.definition.id, w.child.id, D1, _stamp(), due_time="17:00" + w.definition.id, w.child.id, D1, _stamp(), + window="morning", due_time="17:00", ) assert refreshed.id == fetched.id, ( "the conflict path must refresh the existing row in place" @@ -316,15 +320,15 @@ def test_matrix_quest_instances_constraints(tmp_path) -> None: # proving the schema backs the DAO's idempotency. await w.database.execute( "INSERT INTO quest_instances (definition_id, child_id, " - "due_date, due_time, generated_at) " - "VALUES (?, ?, ?, NULL, ?)", + "window, due_date, due_time, generated_at) " + "VALUES (?, ?, 'morning', ?, NULL, ?)", (w.definition.id, w.child.id, D20, _stamp()), ) with pytest.raises(sqlite3.IntegrityError): await w.database.execute( "INSERT INTO quest_instances (definition_id, child_id, " - "due_date, due_time, generated_at) " - "VALUES (?, ?, ?, NULL, ?)", + "window, due_date, due_time, generated_at) " + "VALUES (?, ?, 'morning', ?, NULL, ?)", (w.definition.id, w.child.id, D20, _stamp()), ) # delete future uncompleted removes D20's open instance @@ -340,10 +344,11 @@ def test_matrix_quest_instances_constraints(tmp_path) -> None: def test_matrix_quest_instances_upsert_idempotency(tmp_path) -> None: async def _body(w: World): first = await w.instances.upsert( - w.definition.id, w.child.id, D2, _stamp() + w.definition.id, w.child.id, D2, _stamp(), window="morning" ) second = await w.instances.upsert( - w.definition.id, w.child.id, D2, _stamp(), due_time="17:00" + w.definition.id, w.child.id, D2, _stamp(), + window="morning", due_time="17:00", ) assert second.id == first.id count = await w.database.fetch_one( diff --git a/tests/test_dao_rules.py b/tests/test_dao_rules.py index 0ae00b6..8784e73 100644 --- a/tests/test_dao_rules.py +++ b/tests/test_dao_rules.py @@ -927,7 +927,7 @@ def test_definition_assignment_leaves_history_rows_alone( # this table, but its row shape proves assignment isolation). await database.execute( "INSERT INTO quest_instances (definition_id, child_id, " - "due_date, generated_at) VALUES (?, ?, ?, ?)", + "window, due_date, generated_at) VALUES (?, ?, 'morning', ?, ?)", (definition.id, child.id, "2026-09-15", NOW), ) await definitions.add_assignee(definition.id, other.id) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index ce81da3..8eee3ee 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -149,13 +149,16 @@ def test_migration_list_shape() -> None: (D-007) to create the quest_* tables directly; migration 2 exists for dev databases stamped version 1 by the pre-rename runner, migration 3 brings pre-D-008 definitions to the multi-assignee - model, and migration 4 adds the windows table. + model, migration 4 adds the windows table, and migration 5 rebuilds + quest_instances onto the widened (definition, child, date, window) + key. """ assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS assert callable(MIGRATIONS[1]) assert callable(MIGRATIONS[2]) assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL - assert len(MIGRATIONS) == 4 + assert callable(MIGRATIONS[4]) + assert len(MIGRATIONS) == 5 # --------------------------------------------------------------------------- @@ -425,6 +428,20 @@ _V2_DEFINITIONS_DDL = [ """, ] +_V2_INSTANCES_DDL = [ + """ + CREATE TABLE IF NOT EXISTS quest_instances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + definition_id INTEGER NOT NULL REFERENCES quest_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) + ) + """, +] + def _seed_v2_multi_assignee_upgrade(database) -> None: """Create a database shaped like a post-rename, pre-D-008 dev file. @@ -433,11 +450,20 @@ def _seed_v2_multi_assignee_upgrade(database) -> None: single child_id column and no assignees table exists — stamped at version 2, with definition and instance rows that must survive. """ + # Freeze the version-2 shape: definitions carry child_id, the + # instance table has no window column, and the assignees/windows + # tables do not exist yet — everything else matches the current DDL. + _v2_replaced = ( + "quest_definitions ", + "quest_definition_assignees ", + "quest_definition_windows ", + "quest_instances ", + ) for sql in SCHEMA_V1_STATEMENTS: - if "quest_definitions " in sql: - continue # replaced by the legacy-shape DDL below + if any(name in sql for name in _v2_replaced): + continue _run(database.execute(sql)) - for sql in _V2_DEFINITIONS_DDL: + for sql in _V2_DEFINITIONS_DDL + _V2_INSTANCES_DDL: _run(database.execute(sql)) _run(database.execute(VERSION_TABLE_DDL)) _run( @@ -506,14 +532,33 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None: ) ) assert assignees == [(1, 2)] - # The instance survived, still referencing the definition. + # The instance survived the rebuild, pinned to the migrated + # window, still referencing the definition. instance = _run( database.fetch_one( - "SELECT definition_id, child_id, due_date " + "SELECT definition_id, child_id, window, due_date " "FROM quest_instances WHERE id = 1" ) ) - assert instance == (1, 2, "2026-09-15") + assert instance == (1, 2, "morning", "2026-09-15") + # The rebuilt instance table carries the widened unique key. + index_rows = _run( + database.fetch_all( + "PRAGMA index_list(quest_instances)" + ) + ) + unique_columns = [] + for index_row in index_rows: + if index_row[2]: # unique flag + info = _run( + database.fetch_all( + f"PRAGMA index_info('{index_row[1]}')" + ) + ) + unique_columns.append([col[2] for col in info]) + assert [ + "definition_id", "child_id", "due_date", "window" + ] in unique_columns # No dangling references anywhere. assert _run(database.fetch_all("PRAGMA foreign_key_check")) == [] # The rebuilt table is live for the new model: a definition diff --git a/tests/test_schema.py b/tests/test_schema.py index bd18996..e27c774 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1722,8 +1722,8 @@ def test_applying_presence_ddl_twice_keeps_rows(tmp_path) -> None: # --------------------------------------------------------------------------- -def _insert_instance(database, definition_id=1, **overrides): - """Insert one valid task_instance, returning the instance id. +def _insert_instance(database, definition_id=1, window="morning", **overrides): + """Insert one valid quest_instance, returning the instance id. Pass ``_OMIT`` as a value to leave that column out of the INSERT so its DDL DEFAULT applies. @@ -1731,6 +1731,7 @@ def _insert_instance(database, definition_id=1, **overrides): values: dict[str, object] = { "definition_id": definition_id, "child_id": 1, + "window": window, "due_date": "2026-09-14", "generated_at": "2026-09-14T00:00:00+00:00", } @@ -1829,6 +1830,7 @@ def test_quest_instances_columns_types_and_constraints(tmp_path) -> None: ("id", "INTEGER", 0, None, 1), ("definition_id", "INTEGER", 1, None, 0), ("child_id", "INTEGER", 1, None, 0), + ("window", "TEXT", 1, None, 0), ("due_date", "TEXT", 1, None, 0), ("due_time", "TEXT", 0, None, 0), ("generated_at", "TEXT", 1, None, 0), @@ -1884,7 +1886,7 @@ def test_completion_events_foreign_keys_declared(tmp_path) -> None: _run(database.close()) -def test_quest_instances_unique_index_on_definition_and_date(tmp_path) -> None: +def test_quest_instances_unique_index_on_widened_key(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-unique.db") try: _apply(database) @@ -1897,15 +1899,20 @@ def test_quest_instances_unique_index_on_definition_and_date(tmp_path) -> None: ) implicit = [(name, sql) for name, sql in rows if sql is None] assert len(implicit) == 1, ( - "the (definition_id, due_date) UNIQUE constraint must be backed " - "by exactly one implicit unique index" + "the (definition_id, child_id, due_date, window) UNIQUE " + "constraint must be backed by exactly one implicit unique index" ) columns = _run( database.fetch_all( f"PRAGMA index_info('{implicit[0][0]}')" ) ) - assert [row[2] for row in columns] == ["definition_id", "due_date"] + assert [row[2] for row in columns] == [ + "definition_id", + "child_id", + "due_date", + "window", + ] # The implicit index is genuinely unique: UNIQUE(...) is the only # constraint SQLite backs with a sql-less autoindex on this table. row = _run( @@ -1932,12 +1939,16 @@ def test_quest_instances_valid_insert_round_trips(tmp_path) -> None: instance_id = _insert_instance(database, definition_id=1, child_id=1) row = _run( database.fetch_one( - "SELECT definition_id, child_id, due_date, due_time, generated_at " + "SELECT definition_id, child_id, window, due_date, " + "due_time, generated_at " "FROM quest_instances WHERE id = ?", (instance_id,), ) ) - assert row == (1, 1, "2026-09-14", None, "2026-09-14T00:00:00+00:00") + assert row == ( + 1, 1, "morning", "2026-09-14", None, + "2026-09-14T00:00:00+00:00", + ) finally: _run(database.close()) @@ -1961,7 +1972,7 @@ def test_quest_instances_due_time_round_trips(tmp_path) -> None: _run(database.close()) -def test_quest_instances_duplicate_definition_date_fails(tmp_path) -> None: +def test_quest_instances_duplicate_tuple_fails(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-duplicate.db") try: _apply(database) @@ -1973,6 +1984,78 @@ def test_quest_instances_duplicate_definition_date_fails(tmp_path) -> None: _run(database.close()) +def test_quest_instances_duplicate_tuple_different_child_allowed( + tmp_path, +) -> None: + """D-008: same definition, date and window for two different + assignees of the same definition are two distinct instances.""" + database = _open_db(tmp_path / "task-instances-shared.db") + try: + _apply(database) + _setup_definition(database) + _run( + database.execute( + "INSERT INTO children (display_name, created_at) VALUES (?, ?)", + ("Bo", "2026-09-13T00:00:00+00:00"), + ) + ) + _run( + database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (1, 2)" + ) + ) + _insert_instance(database, definition_id=1, child_id=1) + _insert_instance(database, definition_id=1, child_id=2) + row = _run(database.fetch_one("SELECT COUNT(*) FROM quest_instances")) + assert row == (2,) + finally: + _run(database.close()) + + +def test_quest_instances_duplicate_tuple_different_window_allowed( + tmp_path, +) -> None: + """D-008 twice-daily case: one definition, one child, one date, + two windows — two rows.""" + database = _open_db(tmp_path / "task-instances-twice-daily.db") + try: + _apply(database) + _setup_definition(database) + _insert_instance(database, definition_id=1, window="morning") + _insert_instance(database, definition_id=1, window="evening") + rows = _run( + database.fetch_all( + "SELECT window FROM quest_instances ORDER BY window" + ) + ) + assert [row[0] for row in rows] == ["evening", "morning"] + finally: + _run(database.close()) + + +def test_quest_instances_unknown_window_fails(tmp_path) -> None: + database = _open_db(tmp_path / "task-instances-bad-window.db") + try: + _apply(database) + _setup_definition(database) + with pytest.raises(sqlite3.IntegrityError, match="CHECK"): + _insert_instance(database, definition_id=1, window="noon") + finally: + _run(database.close()) + + +def test_quest_instances_window_not_null_enforced(tmp_path) -> None: + database = _open_db(tmp_path / "task-instances-window-null.db") + try: + _apply(database) + _setup_definition(database) + with pytest.raises(sqlite3.IntegrityError, match="NOT NULL"): + _insert_instance(database, definition_id=1, window=_OMIT) + finally: + _run(database.close()) + + def test_quest_instances_same_date_different_definitions_allowed( tmp_path, ) -> None: -- 2.55.0 From fe90064b65a20af1a288fda23b2ea31ecaec44b6 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 10:25:50 -0400 Subject: [PATCH 05/42] Add actor_child_id to completion_events (D-008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per IMPLEMENTATION-ORDER.md 1.5: a nullable actor_child_id column records which panel profile was tapped; admin events stay NULL. - schema.py: completion_events table body extracted to COMPLETION_EVENTS_TABLE_SQL; the new column carries a FK to children(id) and the actor-pair coherence is extended to a second CHECK: 'user' events require actor_user_id and forbid actor_child_id, 'panel' events require actor_child_id and forbid actor_user_id. - migrations.py: migration 6 rebuilds legacy-shaped completion_events via the canonical procedure (CHECKs cannot be added to an existing table), backfilling each legacy panel row to its own child_id (pre-D-008 panel completions were always the tapped child) via INSERT..SELECT — no UPDATE or DELETE path exists, so the append-only guarantee is untouched; ids preserved; PRAGMA foreign_key_check verified in-transaction. - dao_instances.py: append() takes actor_child_id, validates both actor shapes (panel requires it, user forbids it) and the tapped child's existence under the connection lock; the record and column list grow accordingly. - tests: coherence CHECK matrix (5 bad shapes), unknown actor_child_id FK, panel/user round-trips at DDL and DAO layers, migration-6 backfill test on a frozen v2-shape seed; the completion_events leak guard gains justified INSERT exemptions for the migration layer (UPDATE/DELETE stay forbidden everywhere). --- custom_components/nestquest/dao_instances.py | 55 +++++++++---- custom_components/nestquest/migrations.py | 84 ++++++++++++++++++-- custom_components/nestquest/schema.py | 68 +++++++++------- tests/test_dao_instances.py | 46 ++++++++--- tests/test_dao_matrix.py | 6 +- tests/test_migrations.py | 62 ++++++++++++++- tests/test_schema.py | 63 ++++++++++++++- 7 files changed, 318 insertions(+), 66 deletions(-) diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py index 2e9aaa3..93437f1 100644 --- a/custom_components/nestquest/dao_instances.py +++ b/custom_components/nestquest/dao_instances.py @@ -106,6 +106,7 @@ class CompletionEventRecord: event_type: str actor_source: str actor_user_id: str | None + actor_child_id: int | None occurred_at: str was_on_time: bool | None @@ -115,7 +116,7 @@ _INSTANCE_COLUMNS = ( ) _EVENT_COLUMNS = ( "id, instance_id, child_id, event_type, actor_source, actor_user_id, " - "occurred_at, was_on_time" + "actor_child_id, occurred_at, was_on_time" ) #: The only event types the append path accepts; the schema CHECK is @@ -144,8 +145,9 @@ def _event_from_row(row: tuple) -> CompletionEventRecord: event_type=row[3], actor_source=row[4], actor_user_id=row[5], - occurred_at=row[6], - was_on_time=None if row[7] is None else bool(row[7]), + actor_child_id=row[6], + occurred_at=row[7], + was_on_time=None if row[8] is None else bool(row[8]), ) @@ -396,19 +398,21 @@ class CompletionEventsDao: was_on_time: bool | None, *, actor_user_id: str | None = None, + actor_child_id: int | None = None, ) -> CompletionEventRecord: """Append one completion or un-completion event. - ``actor_source`` is 'user' (with ``actor_user_id`` set) or - 'panel' (no user id — the tapped child is ``child_id``); the - actor-pair CHECK enforces this shape at the schema level. - ``occurred_at`` must be the module's one strict UTC shape - (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range queries - stay exact. ``was_on_time`` is required (True/False) for - completions; for un-completions it may be None or the reversed - completion's flag (Feature 08 still deciding). The instance - and child must exist — validated atomically under the - connection lock. + ``actor_source`` is 'user' (an admin action: ``actor_user_id`` + set, ``actor_child_id`` NULL) or 'panel' (a panel tap: no user + id, and ``actor_child_id`` carrying the tapped child profile, + D-008); the actor-pair CHECKs enforce this shape at the schema + level. ``occurred_at`` must be the module's one strict UTC + shape (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range + queries stay exact. ``was_on_time`` is required (True/False) + for completions; for un-completions it may be None or the + reversed completion's flag (Feature 08 still deciding). The + instance, child and — for panel events — the tapped child must + exist — validated atomically under the connection lock. """ if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED): raise ValueError( @@ -428,6 +432,15 @@ class CompletionEventsDao: raise ValueError( "actor_source 'panel' must not carry actor_user_id" ) + if actor_source == "panel" and actor_child_id is None: + raise ValueError( + "actor_source 'panel' requires actor_child_id " + "(the tapped child profile)" + ) + if actor_source == "user" and actor_child_id is not None: + raise ValueError( + "actor_source 'user' must not carry actor_child_id" + ) if was_on_time is not None and type(was_on_time) is not bool: raise ValueError( f"was_on_time must be True, False or None, got " @@ -458,16 +471,28 @@ class CompletionEventsDao: ) if child is None: raise ValueError(f"child {child_id} does not exist") + if actor_child_id is not None: + tapped = await self._database.fetch_one( + "SELECT 1 FROM children WHERE id = ?", + (actor_child_id,), + ) + if tapped is None: + raise ValueError( + f"actor_child_id {actor_child_id} does not " + "reference an existing child" + ) result = await self._database.execute( "INSERT INTO completion_events (instance_id, child_id, " - "event_type, actor_source, actor_user_id, occurred_at, " - "was_on_time) VALUES (?, ?, ?, ?, ?, ?, ?)", + "event_type, actor_source, actor_user_id, " + "actor_child_id, occurred_at, was_on_time) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ( instance_id, child_id, event_type, actor_source, actor_user_id, + actor_child_id, occurred_at, None if was_on_time is None else int(was_on_time), ), diff --git a/custom_components/nestquest/migrations.py b/custom_components/nestquest/migrations.py index 9901505..2040366 100644 --- a/custom_components/nestquest/migrations.py +++ b/custom_components/nestquest/migrations.py @@ -25,11 +25,12 @@ pre-D-008 databases to the multi-assignee model: it creates ``quest_definition_assignees`` and, when ``quest_definitions`` still carries a single ``child_id`` column, rebuilds the table without it, copying each definition's assignee across. Migration 4 adds the -windows table and migration 5 rebuilds ``quest_instances`` onto the -widened (definition_id, child_id, due_date, window) key. Each callable -migration runs inside its own transaction together with its version -stamp, so a crash mid-step rolls the statements and the stamp back -together. +windows table, migration 5 rebuilds ``quest_instances`` onto the +widened (definition_id, child_id, due_date, window) key, and migration +6 rebuilds ``completion_events`` with the D-008 ``actor_child_id`` +column. Each callable migration runs inside its own transaction +together with its version stamp, so a crash mid-step rolls the +statements and the stamp back together. Migration entries are either an ordered sequence of SQL statements or an async callable taking ``(database, target_version)``. Callables @@ -73,6 +74,7 @@ from typing import Union from .db import NestQuestDatabase from .schema import ( + COMPLETION_EVENTS_TABLE_SQL, QUEST_DEFINITIONS_TABLE_SQL, QUEST_INSTANCES_TABLE_SQL, SCHEMA_V1_STATEMENTS, @@ -326,6 +328,74 @@ async def _widen_instance_key( await database.execute("PRAGMA foreign_keys = ON") +async def _add_actor_child_id( + database: NestQuestDatabase, target_version: int +) -> None: + """Bring ``completion_events`` to the D-008 actor-child shape. + + The ``actor_child_id`` column records which panel profile was + tapped; it cannot be added with ``ALTER TABLE ADD COLUMN`` alone + because the canonical shape also carries an actor-pair coherence + CHECK (panel events require it, user events forbid it), and CHECK + constraints cannot be added to an existing table. Databases with + the legacy shape are therefore rebuilt via SQLite's canonical + procedure, backfilling each legacy panel row's ``actor_child_id`` + to ``child_id`` — pre-D-008 panel completions were always recorded + against the tapped child themselves — while admin rows stay NULL. + Row ids are preserved so ``quest_instances`` references and the + append-only history stay intact. Fresh databases already match + the canonical shape and only stamp. + + The rebuild runs with ``foreign_keys = OFF`` and verifies + ``PRAGMA foreign_key_check`` inside the transaction before the + stamp. The copy is an INSERT..SELECT, not an UPDATE: the + completion_events table keeps its append-only guarantee. + """ + legacy_shape = not await _table_has_column( + database, "completion_events", "actor_child_id" + ) + await database.execute("PRAGMA foreign_keys = OFF") + try: + async with database.transaction(): + if legacy_shape: + await database.execute( + "CREATE TABLE completion_events_rebuilt " + f"{COMPLETION_EVENTS_TABLE_SQL}" + ) + await database.execute( + "INSERT INTO completion_events_rebuilt " + "(id, instance_id, child_id, event_type, actor_source, " + "actor_user_id, actor_child_id, occurred_at, " + "was_on_time) " + "SELECT id, instance_id, child_id, event_type, " + "actor_source, actor_user_id, " + "CASE WHEN actor_source = 'panel' THEN child_id " + "ELSE NULL END, " + "occurred_at, was_on_time " + "FROM completion_events ORDER BY id" + ) + await database.execute("DROP TABLE completion_events") + await database.execute( + "ALTER TABLE completion_events_rebuilt " + "RENAME TO completion_events" + ) + violations = await database.fetch_all( + "PRAGMA foreign_key_check" + ) + if violations: + raise RuntimeError( + "completion_events rebuild produced foreign-key " + f"violations: {violations!r}" + ) + LOGGER.info( + "Added actor_child_id to completion_events; legacy " + "panel rows backfilled to their own child_id" + ) + await stamp_schema_version(database, target_version) + finally: + await database.execute("PRAGMA foreign_keys = ON") + + #: The ordered migration list. Append-only: never edit an applied #: entry, add the next one instead. (Migration 1's content was #: rewritten pre-release per D-007 — no version 1 database shipped.) @@ -340,6 +410,10 @@ MIGRATIONS: Sequence[MigrationStep] = [ # Migration 5 (D-008): rebuild quest_instances onto the widened # (definition_id, child_id, due_date, window) key. _widen_instance_key, + # Migration 6 (D-008): rebuild completion_events with the + # actor_child_id column and its actor-pair coherence CHECK, + # backfilling legacy panel rows to their own child_id. + _add_actor_child_id, ] diff --git a/custom_components/nestquest/schema.py b/custom_components/nestquest/schema.py index 46447e6..ab1fb35 100644 --- a/custom_components/nestquest/schema.py +++ b/custom_components/nestquest/schema.py @@ -159,11 +159,12 @@ 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. +NULL — the tapped child profile is ``actor_child_id``, D-008). Two +coherence CHECKs make both shapes non-negotiable: ``'user'`` requires +``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` requires +``actor_child_id`` (the tapped profile) and forbids ``actor_user_id``. +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 @@ -344,29 +345,42 @@ SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [ """, ] -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 quest_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') - ) +#: Column body of ``completion_events``, shared between the v1 DDL +#: below and the actor-child rebuild migration, which must recreate the +#: table in exactly this shape (D-008: ``actor_child_id`` records which +#: panel profile was tapped — set for panel events, NULL for admin +#: ones; legacy panel rows backfill to ``child_id``, the child they +#: always were). +COMPLETION_EVENTS_TABLE_SQL = """( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instance_id INTEGER NOT NULL REFERENCES quest_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, + actor_child_id INTEGER REFERENCES children(id), + 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 ( + (actor_source = 'user' AND actor_child_id IS NULL) + OR (actor_source = 'panel' AND actor_child_id IS NOT NULL) + ), + CHECK ( + (event_type = 'completed' AND was_on_time IS NOT NULL) + OR (event_type = 'uncompleted') ) +)""" + +SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [ + f""" + CREATE TABLE IF NOT EXISTS completion_events {COMPLETION_EVENTS_TABLE_SQL} """, ] diff --git a/tests/test_dao_instances.py b/tests/test_dao_instances.py index fe79027..5894b38 100644 --- a/tests/test_dao_instances.py +++ b/tests/test_dao_instances.py @@ -664,6 +664,7 @@ def test_append_panel_event_round_trips(tmp_path) -> None: definition.id, child.id, D1, _now_stamp(), window="morning") event = await events.append( instance.id, child.id, "completed", "panel", _now_stamp(), False, + actor_child_id=child.id, actor_user_id=None, ) assert event.actor_source == "panel" @@ -718,17 +719,31 @@ def test_append_validation_rejects_bad_shapes(tmp_path) -> None: with pytest.raises(ValueError, match="must not carry"): await events.append( instance.id, child.id, "completed", "panel", _now_stamp(), True, + actor_child_id=child.id, actor_user_id="user-1", ) + with pytest.raises(ValueError, match="requires actor_child_id"): + await events.append( + instance.id, child.id, "completed", "panel", + _now_stamp(), True, + ) + with pytest.raises(ValueError, match="must not carry actor_child_id"): + await events.append( + instance.id, child.id, "completed", "user", _now_stamp(), True, + actor_user_id="user-1", + actor_child_id=child.id, + ) with pytest.raises(ValueError, match="requires was_on_time"): await events.append( - instance.id, child.id, "completed", "panel", _now_stamp(), None + instance.id, child.id, "completed", "panel", _now_stamp(), None, + actor_child_id=child.id ) # was_on_time must be a real bool: int-convertible junk that # would silently coerce (0.5 -> False) is rejected outright. with pytest.raises(ValueError, match="must be True, False or None"): await events.append( - instance.id, child.id, "completed", "panel", _now_stamp(), 0.5 + instance.id, child.id, "completed", "panel", + _now_stamp(), 0.5, actor_child_id=child.id, ) with pytest.raises(ValueError, match="must be True, False or None"): await events.append( @@ -758,7 +773,7 @@ def test_append_rejects_non_utc_timestamps(tmp_path) -> None: with pytest.raises(ValueError, match="UTC"): await events.append( instance.id, child.id, "completed", "panel", bad, - True, + True, actor_child_id=child.id, ) return None @@ -815,14 +830,16 @@ def test_completed_uncompleted_recompleted_three_ordered_events( instance = await instances.upsert( definition.id, child.id, D1, _now_stamp(), window="morning") await events.append( - instance.id, child.id, "completed", "panel", _now_stamp(), True + instance.id, child.id, "completed", "panel", _now_stamp(), True, + actor_child_id=child.id ) await events.append( instance.id, child.id, "uncompleted", "user", _now_stamp(), True, actor_user_id="user-1", ) await events.append( - instance.id, child.id, "completed", "panel", _now_stamp(), False + instance.id, child.id, "completed", "panel", _now_stamp(), False, + actor_child_id=child.id ) history = await events.list_by_instance(instance.id) assert [e.event_type for e in history] == [ @@ -855,7 +872,8 @@ def test_get_latest_returns_uncompleted_when_reversed(tmp_path) -> None: instance = await instances.upsert( definition.id, child.id, D1, _now_stamp(), window="morning") await events.append( - instance.id, child.id, "completed", "panel", _now_stamp(), True + instance.id, child.id, "completed", "panel", _now_stamp(), True, + actor_child_id=child.id ) await events.append( instance.id, child.id, "uncompleted", "user", _now_stamp(), True, @@ -878,17 +896,20 @@ def test_list_by_child_and_date_range_scopes_by_due_date(tmp_path) -> None: late = await instances.upsert( definition.id, child.id, D20, _now_stamp(), window="morning") await events.append( - early.id, child.id, "completed", "panel", _now_stamp(), True + early.id, child.id, "completed", "panel", _now_stamp(), True, + actor_child_id=child.id ) await events.append( - inside.id, child.id, "completed", "panel", _now_stamp(), False + inside.id, child.id, "completed", "panel", _now_stamp(), False, + actor_child_id=child.id ) await events.append( inside.id, child.id, "uncompleted", "user", _now_stamp(), True, actor_user_id="user-1", ) await events.append( - late.id, child.id, "completed", "panel", _now_stamp(), True + late.id, child.id, "completed", "panel", _now_stamp(), True, + actor_child_id=child.id ) found = await events.list_by_child_and_date_range( child.id, D2, D2 @@ -922,7 +943,7 @@ def test_list_events_occurred_at_scope_variant(tmp_path) -> None: # queried on today's date finds it too. event = await events.append( late_due.id, child.id, "completed", "panel", - _now_stamp(), False, + _now_stamp(), False, actor_child_id=child.id, ) by_due = await events.list_by_child_and_date_range( child.id, D20, D20 @@ -1010,7 +1031,12 @@ def test_no_mutation_sql_for_completion_events_anywhere() -> None: # UPDATE/DELETE/non-DAO-INSERT paths hit the pattern. insert_exempt = { "custom_components/nestquest/dao_instances.py", + "custom_components/nestquest/migrations.py", # the rebuild + # migrations re-create the table via INSERT..SELECT (append- + # order preserved); UPDATE/DELETE stays forbidden everywhere "tests/test_schema.py", + "tests/test_migrations.py", # seeds frozen legacy shapes to + # exercise the rebuild migrations "tests/test_dao_instances.py", # this guard file; scanned by # its own guard's regex spans below instead } diff --git a/tests/test_dao_matrix.py b/tests/test_dao_matrix.py index 2360ffe..a2ab3ba 100644 --- a/tests/test_dao_matrix.py +++ b/tests/test_dao_matrix.py @@ -372,14 +372,16 @@ def test_matrix_completion_events_append_read_and_no_mutation( async def _body(w: World): # insert: append a completion then a reversal, then re-complete await w.events.append( - w.instance.id, w.child.id, "completed", "panel", _stamp(), True + w.instance.id, w.child.id, "completed", "panel", _stamp(), True, + actor_child_id=w.child.id, ) await w.events.append( w.instance.id, w.child.id, "uncompleted", "user", _stamp(), None, actor_user_id="user-1", ) await w.events.append( - w.instance.id, w.child.id, "completed", "panel", _stamp(), False + w.instance.id, w.child.id, "completed", "panel", _stamp(), False, + actor_child_id=w.child.id, ) # read: full ordered history + latest history = await w.events.list_by_instance(w.instance.id) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 8eee3ee..7cf0624 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -149,16 +149,17 @@ def test_migration_list_shape() -> None: (D-007) to create the quest_* tables directly; migration 2 exists for dev databases stamped version 1 by the pre-rename runner, migration 3 brings pre-D-008 definitions to the multi-assignee - model, migration 4 adds the windows table, and migration 5 rebuilds + model, migration 4 adds the windows table, migration 5 rebuilds quest_instances onto the widened (definition, child, date, window) - key. + key, and migration 6 adds actor_child_id to completion_events. """ assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS assert callable(MIGRATIONS[1]) assert callable(MIGRATIONS[2]) assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL assert callable(MIGRATIONS[4]) - assert len(MIGRATIONS) == 5 + assert callable(MIGRATIONS[5]) + assert len(MIGRATIONS) == 6 # --------------------------------------------------------------------------- @@ -442,6 +443,21 @@ _V2_INSTANCES_DDL = [ """, ] +_V2_EVENTS_DDL = [ + """ + CREATE TABLE IF NOT EXISTS completion_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instance_id INTEGER NOT NULL REFERENCES quest_instances(id), + child_id INTEGER NOT NULL REFERENCES children(id), + event_type TEXT NOT NULL, + actor_source TEXT NOT NULL, + actor_user_id TEXT, + occurred_at TEXT NOT NULL, + was_on_time INTEGER + ) + """, +] + def _seed_v2_multi_assignee_upgrade(database) -> None: """Create a database shaped like a post-rename, pre-D-008 dev file. @@ -458,12 +474,13 @@ def _seed_v2_multi_assignee_upgrade(database) -> None: "quest_definition_assignees ", "quest_definition_windows ", "quest_instances ", + "completion_events ", ) for sql in SCHEMA_V1_STATEMENTS: if any(name in sql for name in _v2_replaced): continue _run(database.execute(sql)) - for sql in _V2_DEFINITIONS_DDL + _V2_INSTANCES_DDL: + for sql in _V2_DEFINITIONS_DDL + _V2_INSTANCES_DDL + _V2_EVENTS_DDL: _run(database.execute(sql)) _run(database.execute(VERSION_TABLE_DDL)) _run( @@ -503,6 +520,26 @@ def _seed_v2_multi_assignee_upgrade(database) -> None: "VALUES (1, 2, '2026-09-15', '2026-09-14T00:00:00+00:00')" ) ) + # One legacy panel event and one legacy admin event: the panel one + # must backfill actor_child_id to its own child; the admin one must + # stay NULL. + _run( + database.execute( + "INSERT INTO completion_events (instance_id, child_id, " + "event_type, actor_source, occurred_at, was_on_time) " + "VALUES (1, 2, 'completed', 'panel', " + "'2026-09-15T10:00:00+00:00', 1)" + ) + ) + _run( + database.execute( + "INSERT INTO completion_events (instance_id, child_id, " + "event_type, actor_source, actor_user_id, occurred_at, " + "was_on_time) " + "VALUES (1, 2, 'uncompleted', 'user', 'parent-1', " + "'2026-09-15T11:00:00+00:00', 1)" + ) + ) def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None: @@ -559,6 +596,23 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None: assert [ "definition_id", "child_id", "due_date", "window" ] in unique_columns + # The completion events survived the actor_child_id rebuild: + # the panel row backfilled to its own child, the admin row + # stayed NULL, and ids are preserved. + events = _run( + database.fetch_all( + "SELECT id, event_type, actor_source, actor_child_id " + "FROM completion_events ORDER BY id" + ) + ) + assert events == [ + (1, "completed", "panel", 2), + (2, "uncompleted", "user", None), + ] + columns = _run( + database.fetch_all("PRAGMA table_info(completion_events)") + ) + assert "actor_child_id" in {c[1] for c in columns} # No dangling references anywhere. assert _run(database.fetch_all("PRAGMA foreign_key_check")) == [] # The rebuilt table is live for the new model: a definition diff --git a/tests/test_schema.py b/tests/test_schema.py index e27c774..f869176 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1851,6 +1851,7 @@ def test_completion_events_columns_types_and_constraints(tmp_path) -> None: ("event_type", "TEXT", 1, None, 0), ("actor_source", "TEXT", 1, None, 0), ("actor_user_id", "TEXT", 0, None, 0), + ("actor_child_id", "INTEGER", 0, None, 0), ("occurred_at", "TEXT", 1, None, 0), ("was_on_time", "INTEGER", 0, None, 0), ] @@ -1881,6 +1882,7 @@ def test_completion_events_foreign_keys_declared(tmp_path) -> None: assert declared == { ("quest_instances", "instance_id", "id"), ("children", "child_id", "id"), + ("children", "actor_child_id", "id"), } finally: _run(database.close()) @@ -2182,16 +2184,71 @@ def test_completion_events_valid_panel_completion_round_trips(tmp_path) -> None: instance_id=instance_id, actor_source="panel", actor_user_id=None, + actor_child_id=1, was_on_time=0, ) row = _run( database.fetch_one( - "SELECT actor_source, actor_user_id, was_on_time " - "FROM completion_events WHERE id = ?", + "SELECT actor_source, actor_user_id, actor_child_id, " + "was_on_time FROM completion_events WHERE id = ?", (event_id,), ) ) - assert row == ("panel", None, 0) + assert row == ("panel", None, 1, 0) + finally: + _run(database.close()) + + +@pytest.mark.parametrize( + ("actor_source", "actor_user_id", "actor_child_id"), + [ + ("user", "user-1", 1), # admin event must not carry a profile + ("panel", None, None), # panel event requires the profile + ("panel", "user-1", 1), # panel must not carry a user id either + ("panel", "user-1", None), + ("user", None, None), + ], +) +def test_completion_events_actor_child_coherence_enforced( + tmp_path, actor_source, actor_user_id, actor_child_id +) -> None: + """D-008 actor-pair shape: 'user' events carry actor_user_id and no + actor_child_id; 'panel' events carry actor_child_id and no + actor_user_id. The CHECKs make both halves non-negotiable.""" + database = _open_db(tmp_path / "completion-events-coherence.db") + try: + _apply(database) + _setup_definition(database) + instance_id = _insert_instance(database, definition_id=1) + with pytest.raises(sqlite3.IntegrityError, match="CHECK"): + _insert_event( + database, + instance_id=instance_id, + event_type="completed", + actor_source=actor_source, + actor_user_id=actor_user_id, + actor_child_id=actor_child_id, + was_on_time=1, + ) + finally: + _run(database.close()) + + +def test_completion_events_actor_child_unknown_child_fails(tmp_path) -> None: + database = _open_db(tmp_path / "completion-events-bad-actor-child.db") + try: + _apply(database) + _setup_definition(database) + instance_id = _insert_instance(database, definition_id=1) + with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"): + _insert_event( + database, + instance_id=instance_id, + actor_source="panel", + actor_user_id=None, + actor_child_id=999, + was_on_time=1, + ) finally: _run(database.close()) -- 2.55.0 From 61b77cc2dbaba5f881fb07fcbea0b58e72a27acc Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 12:33:39 -0400 Subject: [PATCH 06/42] Child-profile business layer (Feature 03, task 1) Business-layer functions over ChildrenDao for creating and editing child profiles, so the Feature 09 service gate never touches the DAO directly. - children.py: create_child (display name required and trimmed, whitespace-only rejected; duplicate display name allowed but logged as a warning, case-insensitive and whitespace-normalised on both sides), edit_child (_UNSET sentinel for leave-alone; returns the updated record; renaming onto a different child's name warns, own name does not), set_child_active (the only removal path), and list_children (display order). Explicit None for optional fields raises instead of silently no-oping: the DAO's update deliberately writes only provided fields and cannot express NULL, so a clear request must not masquerade as one. UTC second-precision stamps per the timestamp policy. - tests/test_children_registry.py: 27 tests covering the typed round-trips, name validation matrix, duplicate-name warning behaviour (create and rename, including the rename-to-self non-warning), partial edits, refusal of unsupported clears, deactivation round-trip and list ordering. --- custom_components/nestquest/children.py | 210 ++++++++++++++ tests/test_children_registry.py | 350 ++++++++++++++++++++++++ 2 files changed, 560 insertions(+) create mode 100644 custom_components/nestquest/children.py create mode 100644 tests/test_children_registry.py diff --git a/custom_components/nestquest/children.py b/custom_components/nestquest/children.py new file mode 100644 index 0000000..44e2007 --- /dev/null +++ b/custom_components/nestquest/children.py @@ -0,0 +1,210 @@ +"""Business layer for the children registry (Feature 03). + +Sits between the Feature 09 service gate and the typed +:class:`~.dao_children.ChildrenDao`: callers get validation, name +policies and the duplicate-name warning here, and never talk to the +DAO or SQL directly. Children are never hard-deleted — deactivation +is a separate explicit operation (:func:`set_child_active`), so +completion history keeps its references (feature guardrail). + +Name policy: the display name is required and trimmed; an empty or +whitespace-only name is rejected with ValueError. A duplicate display +name is ALLOWED (two children may legitimately share a nickname) but +logged as a warning so an accidental double-add is visible in the HA +log. The duplicate check is case-insensitive and whitespace-normalised +on both sides, because "ada" and "Ada " are the same name to a parent. + +Colour and avatar are optional free-form strings, trimmed when present; +``sort_order`` is an int with the DAO/schema default of 0. Clearing an +optional field back to NULL is deliberately rejected: the DAO's update +writes only provided fields (it cannot express NULL), so the business +layer refuses an explicit ``None`` instead of silently ignoring it. +Timestamps follow the module timestamp policy: the business layer +stamps UTC ISO-8601 at second precision so every row it creates is +uniform. +""" +from __future__ import annotations + +import datetime +import logging + +from .dao_children import ChildRecord, ChildrenDao +from .db import NestQuestDatabase + +LOGGER = logging.getLogger(__name__) + +#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL" +#: in :func:`edit_child`. +_UNSET = object() + + +def _now_stamp() -> str: + """Return the module's strict UTC second-precision stamp.""" + return datetime.datetime.now(datetime.timezone.utc).isoformat( + timespec="seconds" + ) + + +def _validate_text( + value: object, field: str, *, required: bool = False +) -> str | None: + """Validate a free-text field and return it trimmed. + + ``None`` raises when ``required`` and otherwise returns None. A + non-string value is rejected; an empty result is rejected for + required fields (whitespace-only names are empty to a parent). + """ + if value is None: + if required: + raise ValueError(f"{field} is required") + return None + if not isinstance(value, str): + raise ValueError(f"{field} must be a string, got {value!r}") + trimmed = value.strip() + if required and not trimmed: + raise ValueError(f"{field} is required") + return trimmed + + +def _validate_sort_order(value: object) -> int: + """Reject non-int sort orders (bools included) before the DAO.""" + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"sort_order must be an integer, got {value!r}") + return value + + +async def _warn_on_duplicate_name( + database: NestQuestDatabase, display_name: str +) -> None: + """Log a warning when ``display_name`` duplicates an existing child. + + Case-insensitive and whitespace-normalised on both sides. The + duplicate is still allowed — this only makes it visible; the log + line names both rows so an accidental double-add can be found. + """ + lowered = display_name.casefold() + for child in await ChildrenDao(database).list_all(): + if child.display_name.strip().casefold() == lowered: + LOGGER.warning( + "Child display name %r duplicates existing child %d " + "(%r); allowed, but check this was intentional", + display_name, + child.id, + child.display_name, + ) + return + + +async def create_child( + database: NestQuestDatabase, + display_name: str, + *, + colour: str | None = None, + avatar_ref: str | None = None, + sort_order: int = 0, +) -> ChildRecord: + """Create a child profile and return the stored record. + + The display name is required, trimmed, and rejected when empty. + A duplicate display name (case-insensitive) is allowed but logged + as a warning. The duplicate scan and the INSERT are deliberately + not one transaction: the warning is advisory, so serialising + creates to protect it would buy nothing. + """ + name = _validate_text(display_name, "display_name", required=True) + assert name is not None + colour_value = _validate_text(colour, "colour") + avatar_value = _validate_text(avatar_ref, "avatar_ref") + order = _validate_sort_order(sort_order) + await _warn_on_duplicate_name(database, name) + return await ChildrenDao(database).create( + name, + _now_stamp(), + colour=colour_value, + avatar_ref=avatar_value, + sort_order=order, + ) + + +async def edit_child( + database: NestQuestDatabase, + child_id: int, + *, + display_name: str | object = _UNSET, + colour: str | None | object = _UNSET, + avatar_ref: str | None | object = _UNSET, + sort_order: int | object = _UNSET, +) -> ChildRecord: + """Edit a child profile and return the updated record. + + Arguments default to the module sentinel ``_UNSET`` meaning "leave + this field alone". An explicitly passed ``None`` for an optional + field raises: the DAO's update writes only provided fields and + cannot express NULL, so a silent no-op would mask the caller's + intent (see the module docstring). A provided display name is + validated like create's and, when it duplicates a DIFFERENT child, + still allowed with a warning. Raises ValueError when the child + does not exist. + """ + dao = ChildrenDao(database) + existing = await dao.get(child_id) + if existing is None: + raise ValueError(f"child {child_id} does not exist") + updates: dict[str, object] = {} + if display_name is not _UNSET: + name = _validate_text(display_name, "display_name", required=True) + assert name is not None + if name.casefold() != existing.display_name.strip().casefold(): + await _warn_on_duplicate_name(database, name) + updates["display_name"] = name + if colour is not _UNSET: + value = _validate_text(colour, "colour") + if value is None: + raise ValueError( + "clearing colour is not supported; pass a replacement " + "value or omit the field" + ) + updates["colour"] = value + if avatar_ref is not _UNSET: + value = _validate_text(avatar_ref, "avatar_ref") + if value is None: + raise ValueError( + "clearing avatar_ref is not supported; pass a " + "replacement value or omit the field" + ) + updates["avatar_ref"] = value + if sort_order is not _UNSET: + updates["sort_order"] = _validate_sort_order(sort_order) + if updates: + await dao.update(child_id, **updates) + edited = await dao.get(child_id) + assert edited is not None + return edited + + +async def set_child_active( + database: NestQuestDatabase, child_id: int, is_active: bool +) -> ChildRecord: + """Deactivate or reactivate a child; returns the updated record. + + Deactivation hides the child from the panel without deleting their + history (feature guardrail); this is the only removal path. + Raises ValueError when the child does not exist. + """ + dao = ChildrenDao(database) + if await dao.get(child_id) is None: + raise ValueError(f"child {child_id} does not exist") + await dao.set_active(child_id, is_active) + updated = await dao.get(child_id) + assert updated is not None + return updated + + +async def list_children( + database: NestQuestDatabase, *, active_only: bool = False +) -> list[ChildRecord]: + """Return children in display order (sort_order, then id).""" + dao = ChildrenDao(database) + if active_only: + return await dao.list_active() + return await dao.list_all() diff --git a/tests/test_children_registry.py b/tests/test_children_registry.py new file mode 100644 index 0000000..d88209d --- /dev/null +++ b/tests/test_children_registry.py @@ -0,0 +1,350 @@ +"""Tests for children.py: the Feature 03 child-profile business layer.""" +from __future__ import annotations + +import asyncio +import logging +import re +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from custom_components.nestquest.children import ( + create_child, + edit_child, + list_children, + set_child_active, +) +from custom_components.nestquest.dao_children import ChildRecord +from custom_components.nestquest.db import NestQuestDatabase +from custom_components.nestquest.migrations import apply_migrations + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +def _make_hass_mock(): + hass = MagicMock() + hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) + return hass + + +def _with_db(tmp_path, name): + def _run_test(body): + async def _main(): + database = NestQuestDatabase(_make_hass_mock()) + await database.open(tmp_path / name) + try: + await apply_migrations(database) + return await body(database) + finally: + await database.close() + + return _run(_main()) + + return _run_test + + +# --------------------------------------------------------------------------- +# create_child: required, trimmed display name +# --------------------------------------------------------------------------- + + +def test_create_child_returns_typed_record(tmp_path) -> None: + async def _body(database): + child = await create_child( + database, + " Declan ", + colour="#2E7D32", + avatar_ref="avatars/declan.png", + sort_order=2, + ) + assert isinstance(child, ChildRecord) + # The name is trimmed before storage. + assert child.display_name == "Declan" + assert child.colour == "#2E7D32" + assert child.avatar_ref == "avatars/declan.png" + assert child.sort_order == 2 + assert child.is_active is True + # The business layer stamps created_at in strict UTC shape. + assert re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00", child.created_at + ) + return child + + _with_db(tmp_path, "create-typed.db")(_body) + + +def test_create_child_defaults(tmp_path) -> None: + async def _body(database): + child = await create_child(database, "Chloe") + assert child.display_name == "Chloe" + assert child.colour is None + assert child.avatar_ref is None + assert child.sort_order == 0 + return child + + _with_db(tmp_path, "create-defaults.db")(_body) + + +@pytest.mark.parametrize("bad", ["", " ", "\t\n", None]) +def test_create_child_missing_name_rejected(tmp_path, bad) -> None: + async def _body(database): + with pytest.raises(ValueError, match="display_name is required"): + await create_child(database, bad) + assert await list_children(database) == [] + return None + + _with_db(tmp_path, "create-empty-name.db")(_body) + + +@pytest.mark.parametrize("bad", [42, ["Ada"], 3.5]) +def test_create_child_non_string_name_rejected(tmp_path, bad) -> None: + async def _body(database): + with pytest.raises(ValueError, match="must be a string"): + await create_child(database, bad) + return None + + _with_db(tmp_path, "create-bad-name-type.db")(_body) + + +def test_create_child_optional_text_fields_validated(tmp_path) -> None: + async def _body(database): + with pytest.raises(ValueError, match="colour must be a string"): + await create_child(database, "Ada", colour=7) + with pytest.raises(ValueError, match="avatar_ref must be a string"): + await create_child(database, "Ada", avatar_ref=1.5) + return None + + _with_db(tmp_path, "create-bad-optional.db")(_body) + + +@pytest.mark.parametrize("bad", ["2", 1.5, True, None]) +def test_create_child_sort_order_must_be_int(tmp_path, bad) -> None: + async def _body(database): + with pytest.raises(ValueError, match="sort_order must be an integer"): + await create_child(database, "Ada", sort_order=bad) + return None + + _with_db(tmp_path, "create-bad-order.db")(_body) + + +# --------------------------------------------------------------------------- +# create_child: duplicate display name allowed but warned +# --------------------------------------------------------------------------- + + +def test_create_child_duplicate_name_allowed_and_warned( + tmp_path, caplog +) -> None: + async def _body(database): + first = await create_child(database, "Ada") + with caplog.at_level( + logging.WARNING, logger="custom_components.nestquest.children" + ): + second = await create_child(database, " ADA ") + # Whitespace/case-normalised duplicate: allowed, both rows exist. + assert first.display_name == "Ada" + assert second.display_name == "ADA" + names = [c.display_name for c in await list_children(database)] + assert names == ["Ada", "ADA"] + warnings = [ + r for r in caplog.records if "duplicates existing child" in r.getMessage() + ] + assert len(warnings) == 1, ( + "exactly one duplicate warning for the second create" + ) + assert "Ada" in warnings[0].getMessage() + return second + + _with_db(tmp_path, "create-duplicate.db")(_body) + + +def test_create_child_distinct_name_logs_no_warning( + tmp_path, caplog +) -> None: + async def _body(database): + await create_child(database, "Ada") + caplog.clear() + await create_child(database, "Jordyn") + warnings = [ + r for r in caplog.records if "duplicates existing child" in r.getMessage() + ] + assert warnings == [] + return None + + _with_db(tmp_path, "create-no-duplicate.db")(_body) + + +# --------------------------------------------------------------------------- +# edit_child: returns the updated record +# --------------------------------------------------------------------------- + + +def test_edit_child_updates_fields_and_returns_record(tmp_path) -> None: + async def _body(database): + child = await create_child(database, "Ada", colour="#111111") + edited = await edit_child( + database, + child.id, + display_name=" Ada L. ", + colour="#222222", + avatar_ref="avatars/ada.png", + sort_order=3, + ) + assert isinstance(edited, ChildRecord) + assert edited.id == child.id + assert edited.display_name == "Ada L." + assert edited.colour == "#222222" + assert edited.avatar_ref == "avatars/ada.png" + assert edited.sort_order == 3 + # The stored row reflects every edit. + stored = await list_children(database) + assert len(stored) == 1 + assert stored[0] == edited + return edited + + _with_db(tmp_path, "edit-round-trip.db")(_body) + + +def test_edit_child_omitted_fields_unchanged(tmp_path) -> None: + async def _body(database): + child = await create_child( + database, "Ada", colour="#111111", sort_order=5 + ) + edited = await edit_child(database, child.id, sort_order=9) + # Only sort_order moved; colour and name untouched. + assert edited.sort_order == 9 + assert edited.colour == "#111111" + assert edited.display_name == "Ada" + return edited + + _with_db(tmp_path, "edit-partial.db")(_body) + + +def test_edit_child_no_fields_returns_current_record(tmp_path) -> None: + async def _body(database): + child = await create_child(database, "Ada", colour="#111111") + edited = await edit_child(database, child.id) + assert edited == child + return edited + + _with_db(tmp_path, "edit-noop.db")(_body) + + +def test_edit_child_missing_raises(tmp_path) -> None: + async def _body(database): + with pytest.raises(ValueError, match="does not exist"): + await edit_child(database, 999, display_name="X") + return None + + _with_db(tmp_path, "edit-missing.db")(_body) + + +def test_edit_child_name_validation(tmp_path) -> None: + async def _body(database): + child = await create_child(database, "Ada") + for bad in ("", " ", None, 42): + with pytest.raises(ValueError, match="display_name"): + await edit_child(database, child.id, display_name=bad) + # The failed edits left the record untouched. + assert (await list_children(database))[0].display_name == "Ada" + return None + + _with_db(tmp_path, "edit-name-validation.db")(_body) + + +def test_edit_child_sort_order_must_be_int(tmp_path) -> None: + async def _body(database): + child = await create_child(database, "Ada") + with pytest.raises(ValueError, match="sort_order must be an integer"): + await edit_child(database, child.id, sort_order="3") + return None + + _with_db(tmp_path, "edit-bad-order.db")(_body) + + +def test_edit_child_explicit_none_clear_rejected(tmp_path) -> None: + """Clearing is refused loudly, not silently ignored: the DAO cannot + express NULL writes, so an explicit None must never masquerade as a + no-op.""" + async def _body(database): + child = await create_child(database, "Ada", colour="#111111") + with pytest.raises(ValueError, match="clearing colour"): + await edit_child(database, child.id, colour=None) + with pytest.raises(ValueError, match="clearing avatar_ref"): + await edit_child(database, child.id, avatar_ref=None) + # The refused clears left the colour intact. + assert (await list_children(database))[0].colour == "#111111" + return None + + _with_db(tmp_path, "edit-clear-unsupported.db")(_body) + + +def test_edit_child_rename_to_duplicate_warns_but_different_name_not( + tmp_path, caplog +) -> None: + async def _body(database): + ada = await create_child(database, "Ada") + bo = await create_child(database, "Bo") + caplog.clear() + # Renaming Bo onto Ada's name warns (different child's name). + edited = await edit_child(database, bo.id, display_name="ada") + assert edited.display_name == "ada" + assert any( + "duplicates existing child" in r.getMessage() + for r in caplog.records + ) + caplog.clear() + # Renaming a child to their own name (casing aside) is not a + # duplicate: no warning. + await edit_child(database, ada.id, display_name="ADA") + assert not any( + "duplicates existing child" in r.getMessage() + for r in caplog.records + ) + return None + + _with_db(tmp_path, "edit-rename-duplicate.db")(_body) + + +# --------------------------------------------------------------------------- +# set_child_active / list_children +# --------------------------------------------------------------------------- + + +def test_set_child_active_round_trip(tmp_path) -> None: + async def _body(database): + child = await create_child(database, "Ada") + deactivated = await set_child_active(database, child.id, False) + assert deactivated.is_active is False + assert [c.id for c in await list_children(database)] == [child.id] + assert await list_children(database, active_only=True) == [] + reactivated = await set_child_active(database, child.id, True) + assert reactivated.is_active is True + assert len(await list_children(database, active_only=True)) == 1 + return reactivated + + _with_db(tmp_path, "set-active.db")(_body) + + +def test_set_child_active_missing_raises(tmp_path) -> None: + async def _body(database): + with pytest.raises(ValueError, match="does not exist"): + await set_child_active(database, 999, False) + return None + + _with_db(tmp_path, "set-active-missing.db")(_body) + + +def test_list_children_orders_by_sort_order_then_id(tmp_path) -> None: + async def _body(database): + second = await create_child(database, "Bo", sort_order=1) + first = await create_child(database, "Ada", sort_order=0) + third = await create_child(database, "Cleo", sort_order=1) + listed = await list_children(database) + # sort_order 0 first, then the two sort_order-1 rows by id. + assert [c.id for c in listed] == [first.id, second.id, third.id] + return listed + + _with_db(tmp_path, "list-order.db")(_body) -- 2.55.0 From 712527d5baba9d0d24020a7847601c6b3df060c4 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 14:08:24 -0400 Subject: [PATCH 07/42] Review fix: duplicate-name scan excludes only the edited child Review round 1 (Codex gpt-5.6-terra) found: a case-only edit could suppress the duplicate-name warning even when a DIFFERENT child already carried the normalised name, because the edit path compared only against the child's own old name. The scan now always runs on the resulting state and excludes only the child being edited (exclude_child_id), so case-only edits and stay-duplicated renames warn like any other collision; renaming to your own name stays warning-free when no other child holds the name. Tests: new case-only-collision test, stay-duplicated rename assertion, and a split self-rename test; suite at 848 passed. --- custom_components/nestquest/children.py | 21 ++++++--- tests/test_children_registry.py | 58 +++++++++++++++++++++---- 2 files changed, 65 insertions(+), 14 deletions(-) diff --git a/custom_components/nestquest/children.py b/custom_components/nestquest/children.py index 44e2007..c0b6790 100644 --- a/custom_components/nestquest/children.py +++ b/custom_components/nestquest/children.py @@ -74,16 +74,25 @@ def _validate_sort_order(value: object) -> int: async def _warn_on_duplicate_name( - database: NestQuestDatabase, display_name: str + database: NestQuestDatabase, + display_name: str, + *, + exclude_child_id: int | None = None, ) -> None: """Log a warning when ``display_name`` duplicates an existing child. Case-insensitive and whitespace-normalised on both sides. The duplicate is still allowed — this only makes it visible; the log line names both rows so an accidental double-add can be found. + ``exclude_child_id`` removes the child being edited from the scan, + so an edit only warns when the result collides with a DIFFERENT + child's name — including case-only edits, where the self-comparison + would otherwise hide a real duplicate. """ lowered = display_name.casefold() for child in await ChildrenDao(database).list_all(): + if exclude_child_id is not None and child.id == exclude_child_id: + continue if child.display_name.strip().casefold() == lowered: LOGGER.warning( "Child display name %r duplicates existing child %d " @@ -142,9 +151,10 @@ async def edit_child( field raises: the DAO's update writes only provided fields and cannot express NULL, so a silent no-op would mask the caller's intent (see the module docstring). A provided display name is - validated like create's and, when it duplicates a DIFFERENT child, - still allowed with a warning. Raises ValueError when the child - does not exist. + validated like create's; the duplicate scan excludes only the child + being edited, so a case-only edit colliding with another child's + normalised name still warns. Raises ValueError when the child does + not exist. """ dao = ChildrenDao(database) existing = await dao.get(child_id) @@ -154,8 +164,7 @@ async def edit_child( if display_name is not _UNSET: name = _validate_text(display_name, "display_name", required=True) assert name is not None - if name.casefold() != existing.display_name.strip().casefold(): - await _warn_on_duplicate_name(database, name) + await _warn_on_duplicate_name(database, name, exclude_child_id=child_id) updates["display_name"] = name if colour is not _UNSET: value = _validate_text(colour, "colour") diff --git a/tests/test_children_registry.py b/tests/test_children_registry.py index d88209d..3aa874d 100644 --- a/tests/test_children_registry.py +++ b/tests/test_children_registry.py @@ -291,21 +291,63 @@ def test_edit_child_rename_to_duplicate_warns_but_different_name_not( # Renaming Bo onto Ada's name warns (different child's name). edited = await edit_child(database, bo.id, display_name="ada") assert edited.display_name == "ada" - assert any( - "duplicates existing child" in r.getMessage() - for r in caplog.records - ) + warnings = [ + r for r in caplog.records if "duplicates existing child" in r.getMessage() + ] + assert len(warnings) == 1 + assert "Ada" in warnings[0].getMessage() + return edited + + _with_db(tmp_path, "edit-rename-duplicate.db")(_body) + + +def test_edit_child_rename_to_own_name_casing_alone_warns_not( + tmp_path, caplog +) -> None: + """Renaming a child to their own name (casing aside) is not a + duplicate when NO OTHER child carries the normalised name.""" + async def _body(database): + ada = await create_child(database, "Ada") caplog.clear() - # Renaming a child to their own name (casing aside) is not a - # duplicate: no warning. - await edit_child(database, ada.id, display_name="ADA") + edited = await edit_child(database, ada.id, display_name="ADA") + assert edited.display_name == "ADA" assert not any( "duplicates existing child" in r.getMessage() for r in caplog.records ) + return edited + + _with_db(tmp_path, "edit-rename-self.db")(_body) + + +def test_edit_child_case_only_edit_colliding_with_other_child_warns( + tmp_path, caplog +) -> None: + """A case-only edit must not hide a real duplicate: with two Ada + profiles already present, editing either one's name to another + casing still warns (the scan excludes only the edited child).""" + async def _body(database): + first = await create_child(database, "Ada") + await create_child(database, "ada") + caplog.clear() + edited = await edit_child(database, first.id, display_name="ADA") + assert edited.display_name == "ADA" + warnings = [ + r for r in caplog.records if "duplicates existing child" in r.getMessage() + ] + assert len(warnings) == 1 + # Editing the name to a value that STAYS duplicated (no change, + # other child still carries it) warns too: the scan is on the + # resulting state, not on whether the word moved. + caplog.clear() + await edit_child(database, first.id, display_name=" ada ") + warnings = [ + r for r in caplog.records if "duplicates existing child" in r.getMessage() + ] + assert len(warnings) == 1 return None - _with_db(tmp_path, "edit-rename-duplicate.db")(_body) + _with_db(tmp_path, "edit-case-only-duplicate.db")(_body) # --------------------------------------------------------------------------- -- 2.55.0 From 5578507ce898a28942e709a948247b151d43c6d0 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 14:13:48 -0400 Subject: [PATCH 08/42] Review fix: edit_child/set_child_active update+readback atomic Review round 2 (Codex gpt-5.6-terra) found both functions returned a readback that a concurrent writer could overwrite between the UPDATE and the SELECT, so the returned record could be the other caller's values. Both now run existence-check, duplicate scan, UPDATE and readback inside the DAOs' shared connection-scoped lock. Two gated concurrency tests prove the contract deterministically: task A paused at its UPDATE while task B queues, each returns its own values, final stored state is the last writer's. Suite: 850 passed. --- custom_components/nestquest/children.py | 86 +++++++++------- tests/test_children_registry.py | 125 ++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 35 deletions(-) diff --git a/custom_components/nestquest/children.py b/custom_components/nestquest/children.py index c0b6790..e3439aa 100644 --- a/custom_components/nestquest/children.py +++ b/custom_components/nestquest/children.py @@ -28,7 +28,7 @@ from __future__ import annotations import datetime import logging -from .dao_children import ChildRecord, ChildrenDao +from .dao_children import ChildRecord, ChildrenDao, _connection_lock from .db import NestQuestDatabase LOGGER = logging.getLogger(__name__) @@ -155,38 +155,49 @@ async def edit_child( being edited, so a case-only edit colliding with another child's normalised name still warns. Raises ValueError when the child does not exist. + + Atomicity: the existence check, the duplicate scan, the UPDATE and + the readback all run inside the connection-scoped lock shared with + the DAOs, so another edit cannot slip between this call's UPDATE + and its readback — the returned record is exactly the one this + edit produced, not a later writer's. """ - dao = ChildrenDao(database) - existing = await dao.get(child_id) - if existing is None: - raise ValueError(f"child {child_id} does not exist") - updates: dict[str, object] = {} - if display_name is not _UNSET: - name = _validate_text(display_name, "display_name", required=True) - assert name is not None - await _warn_on_duplicate_name(database, name, exclude_child_id=child_id) - updates["display_name"] = name - if colour is not _UNSET: - value = _validate_text(colour, "colour") - if value is None: - raise ValueError( - "clearing colour is not supported; pass a replacement " - "value or omit the field" + async with _connection_lock(database): + dao = ChildrenDao(database) + existing = await dao.get(child_id) + if existing is None: + raise ValueError(f"child {child_id} does not exist") + updates: dict[str, object] = {} + if display_name is not _UNSET: + name = _validate_text( + display_name, "display_name", required=True ) - updates["colour"] = value - if avatar_ref is not _UNSET: - value = _validate_text(avatar_ref, "avatar_ref") - if value is None: - raise ValueError( - "clearing avatar_ref is not supported; pass a " - "replacement value or omit the field" + assert name is not None + await _warn_on_duplicate_name( + database, name, exclude_child_id=child_id ) - updates["avatar_ref"] = value - if sort_order is not _UNSET: - updates["sort_order"] = _validate_sort_order(sort_order) - if updates: - await dao.update(child_id, **updates) - edited = await dao.get(child_id) + updates["display_name"] = name + if colour is not _UNSET: + value = _validate_text(colour, "colour") + if value is None: + raise ValueError( + "clearing colour is not supported; pass a replacement " + "value or omit the field" + ) + updates["colour"] = value + if avatar_ref is not _UNSET: + value = _validate_text(avatar_ref, "avatar_ref") + if value is None: + raise ValueError( + "clearing avatar_ref is not supported; pass a " + "replacement value or omit the field" + ) + updates["avatar_ref"] = value + if sort_order is not _UNSET: + updates["sort_order"] = _validate_sort_order(sort_order) + if updates: + await dao.update(child_id, **updates) + edited = await dao.get(child_id) assert edited is not None return edited @@ -199,12 +210,17 @@ async def set_child_active( Deactivation hides the child from the panel without deleting their history (feature guardrail); this is the only removal path. Raises ValueError when the child does not exist. + + Atomicity: existence check, UPDATE and readback run inside the + connection-scoped lock, so a concurrent opposite transition cannot + make this call report the other caller's value. """ - dao = ChildrenDao(database) - if await dao.get(child_id) is None: - raise ValueError(f"child {child_id} does not exist") - await dao.set_active(child_id, is_active) - updated = await dao.get(child_id) + async with _connection_lock(database): + dao = ChildrenDao(database) + if await dao.get(child_id) is None: + raise ValueError(f"child {child_id} does not exist") + await dao.set_active(child_id, is_active) + updated = await dao.get(child_id) assert updated is not None return updated diff --git a/tests/test_children_registry.py b/tests/test_children_registry.py index 3aa874d..e2af6cf 100644 --- a/tests/test_children_registry.py +++ b/tests/test_children_registry.py @@ -379,6 +379,131 @@ def test_set_child_active_missing_raises(tmp_path) -> None: _with_db(tmp_path, "set-active-missing.db")(_body) +# --------------------------------------------------------------------------- +# Concurrency: edit/set_active are atomic under the connection lock +# --------------------------------------------------------------------------- + + +def _gated_hass(armed: dict, gate_open: asyncio.Event, a_started: asyncio.Event): + """A hass mock whose executor gates the FIRST `_execute` job after + arming — the mutate statement of whichever business call starts + first — proving the caller holds the connection lock at that + moment and the second caller must queue behind it.""" + from unittest.mock import MagicMock + + hass = MagicMock() + + async def _gated_executor(fn, *args): + if armed["active"]: + if getattr(fn, "__name__", "") == "_execute" and not armed["gated"]: + armed["gated"] = True + a_started.set() + await gate_open.wait() + return fn(*args) + + hass.async_add_executor_job = _gated_executor + return hass + + +def test_edit_child_concurrent_opposite_edits_return_own_values( + tmp_path, +) -> None: + """Two racing edits cannot cross-contaminate each other's readback. + + Deterministic gate: task A is paused at its UPDATE (it provably + holds the connection lock), task B is then created and must queue; + A's readback runs before B's edit, so A returns A's value and B + returns B's. Without the lock, A's readback would see B's write. + """ + async def _main(): + armed = {"active": False, "gated": False} + gate_open = asyncio.Event() + a_started = asyncio.Event() + hass = _gated_hass(armed, gate_open, a_started) + database = NestQuestDatabase(hass) + await database.open(tmp_path / "edit-race.db") + try: + await apply_migrations(database) + child = await create_child( + database, "Ada", colour="#111111", sort_order=0 + ) + + armed["active"] = True + task_a = asyncio.ensure_future( + edit_child(database, child.id, colour="#AAAAAA", sort_order=1) + ) + await a_started.wait() + armed["active"] = False + task_b = asyncio.ensure_future( + edit_child( + database, child.id, colour="#BBBBBB", sort_order=2 + ) + ) + await asyncio.sleep(0) + gate_open.set() + record_a, record_b = await asyncio.gather(task_a, task_b) + + # Each caller gets back exactly what it wrote. + assert record_a.colour == "#AAAAAA" + assert record_a.sort_order == 1 + assert record_b.colour == "#BBBBBB" + assert record_b.sort_order == 2 + # B landed last: the stored row is B's. + row = await database.fetch_one( + "SELECT colour, sort_order FROM children WHERE id = ?", + (child.id,), + ) + assert row == ("#BBBBBB", 2) + finally: + await database.close() + + _run(_main()) + + +def test_set_child_active_concurrent_opposite_transitions_return_own( + tmp_path, +) -> None: + """Racing activate/deactivate calls each report their own request. + + Same deterministic gate on task A's UPDATE; task B queues behind + the connection lock and lands the opposite transition after A. + """ + async def _main(): + armed = {"active": False, "gated": False} + gate_open = asyncio.Event() + a_started = asyncio.Event() + hass = _gated_hass(armed, gate_open, a_started) + database = NestQuestDatabase(hass) + await database.open(tmp_path / "active-race.db") + try: + await apply_migrations(database) + child = await create_child(database, "Ada") + + armed["active"] = True + task_a = asyncio.ensure_future( + set_child_active(database, child.id, False) + ) + await a_started.wait() + armed["active"] = False + task_b = asyncio.ensure_future( + set_child_active(database, child.id, True) + ) + await asyncio.sleep(0) + gate_open.set() + record_a, record_b = await asyncio.gather(task_a, task_b) + + assert record_a.is_active is False + assert record_b.is_active is True + row = await database.fetch_one( + "SELECT is_active FROM children WHERE id = ?", (child.id,) + ) + assert row == (1,) + finally: + await database.close() + + _run(_main()) + + def test_list_children_orders_by_sort_order_then_id(tmp_path) -> None: async def _body(database): second = await create_child(database, "Bo", sort_order=1) -- 2.55.0 From 09363a57cf89332dcf40ef5b2072c829a874d84d Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 14:14:56 -0400 Subject: [PATCH 09/42] Review fix: race tests read via the DAO, not raw SQL probes The children/admin_users leak guard forbids children SQL outside the DAO and its sanctioned test files; the two gated concurrency tests probed the children table raw. They now assert via ChildrenDao.get. Suite: 850 passed. --- tests/test_children_registry.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/tests/test_children_registry.py b/tests/test_children_registry.py index e2af6cf..9cd6779 100644 --- a/tests/test_children_registry.py +++ b/tests/test_children_registry.py @@ -14,7 +14,7 @@ from custom_components.nestquest.children import ( list_children, set_child_active, ) -from custom_components.nestquest.dao_children import ChildRecord +from custom_components.nestquest.dao_children import ChildRecord, ChildrenDao from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.migrations import apply_migrations @@ -448,12 +448,11 @@ def test_edit_child_concurrent_opposite_edits_return_own_values( assert record_a.sort_order == 1 assert record_b.colour == "#BBBBBB" assert record_b.sort_order == 2 - # B landed last: the stored row is B's. - row = await database.fetch_one( - "SELECT colour, sort_order FROM children WHERE id = ?", - (child.id,), - ) - assert row == ("#BBBBBB", 2) + # B landed last: the stored row is B's (read via the DAO — + # the children leak guard forbids raw probes here). + stored = await ChildrenDao(database).get(child.id) + assert stored is not None + assert (stored.colour, stored.sort_order) == ("#BBBBBB", 2) finally: await database.close() @@ -494,10 +493,9 @@ def test_set_child_active_concurrent_opposite_transitions_return_own( assert record_a.is_active is False assert record_b.is_active is True - row = await database.fetch_one( - "SELECT is_active FROM children WHERE id = ?", (child.id,) - ) - assert row == (1,) + stored = await ChildrenDao(database).get(child.id) + assert stored is not None + assert stored.is_active is True finally: await database.close() -- 2.55.0 From d5d972b872e8b38e765a02c0a5ae9966fc3a6be4 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 14:18:33 -0400 Subject: [PATCH 10/42] Review fix: serialize create_child's duplicate scan with the insert Review round 3 (Codex gpt-5.6-terra) found two concurrent creates of the same normalised name could both pass the duplicate scan before either INSERT committed, inserting a duplicate with no warning. The scan and INSERT now run inside the shared connection-scoped lock, so the second create always sees the first's committed row and warns. A gated concurrent-create test proves exactly one warning fires; a sequential-duplicate warning assertion rode along. Suite: 851 passed. --- custom_components/nestquest/children.py | 26 ++++++----- tests/test_children_registry.py | 62 +++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/custom_components/nestquest/children.py b/custom_components/nestquest/children.py index e3439aa..4e1d56e 100644 --- a/custom_components/nestquest/children.py +++ b/custom_components/nestquest/children.py @@ -116,23 +116,27 @@ async def create_child( The display name is required, trimmed, and rejected when empty. A duplicate display name (case-insensitive) is allowed but logged - as a warning. The duplicate scan and the INSERT are deliberately - not one transaction: the warning is advisory, so serialising - creates to protect it would buy nothing. + as a warning. The scan and the INSERT run inside the + connection-scoped lock shared with the DAOs, so a concurrent + create of the same normalised name queues behind the first insert + and sees it — the duplicate still warns; it can never slip through + unlogged. """ name = _validate_text(display_name, "display_name", required=True) assert name is not None colour_value = _validate_text(colour, "colour") avatar_value = _validate_text(avatar_ref, "avatar_ref") order = _validate_sort_order(sort_order) - await _warn_on_duplicate_name(database, name) - return await ChildrenDao(database).create( - name, - _now_stamp(), - colour=colour_value, - avatar_ref=avatar_value, - sort_order=order, - ) + async with _connection_lock(database): + await _warn_on_duplicate_name(database, name) + created = await ChildrenDao(database).create( + name, + _now_stamp(), + colour=colour_value, + avatar_ref=avatar_value, + sort_order=order, + ) + return created async def edit_child( diff --git a/tests/test_children_registry.py b/tests/test_children_registry.py index 9cd6779..63f6d73 100644 --- a/tests/test_children_registry.py +++ b/tests/test_children_registry.py @@ -320,6 +320,68 @@ def test_edit_child_rename_to_own_name_casing_alone_warns_not( _with_db(tmp_path, "edit-rename-self.db")(_body) +def test_create_child_concurrent_same_name_still_warns(tmp_path) -> None: + """Racing creates of the same normalised name cannot slip through + unlogged: the scan and INSERT are serialized on the connection + lock, so the second create sees the first's committed row and + warns. Deterministic gate on task A's INSERT while task B queues + behind the lock. Without the lock, B's scan would run before A's + INSERT commits and no warning would fire.""" + async def _main(): + armed = {"active": False, "gated": False} + gate_open = asyncio.Event() + a_started = asyncio.Event() + hass = _gated_hass(armed, gate_open, a_started) + database = NestQuestDatabase(hass) + await database.open(tmp_path / "create-race.db") + try: + await apply_migrations(database) + logger = logging.getLogger( + "custom_components.nestquest.children" + ) + seen: list[logging.LogRecord] = [] + + class _Capture(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + seen.append(record) + + handler = _Capture() + logger.addHandler(handler) + try: + armed["active"] = True + task_a = asyncio.ensure_future( + create_child(database, "Ada") + ) + await a_started.wait() + armed["active"] = False + task_b = asyncio.ensure_future( + create_child(database, " ada ") + ) + await asyncio.sleep(0) + gate_open.set() + first, second = await asyncio.gather(task_a, task_b) + finally: + logger.removeHandler(handler) + + # Both creates land; the duplicate is allowed. + assert first.display_name == "Ada" + assert second.display_name == "ada" + names = [c.display_name for c in await list_children(database)] + assert names == ["Ada", "ada"] + # Exactly one warning: B's scan saw A's committed row. + warnings = [ + r for r in seen if "duplicates existing child" in r.getMessage() + ] + assert len(warnings) == 1, ( + "the queued duplicate create must warn exactly once" + ) + assert "Ada" in warnings[0].getMessage() + finally: + await database.close() + + _run(_main()) + + def test_edit_child_case_only_edit_colliding_with_other_child_warns( tmp_path, caplog ) -> None: -- 2.55.0 From 1d247fbca1200baee1227ceab542d25939acc6b8 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 16:30:36 -0400 Subject: [PATCH 11/42] Deactivation preserves every referencing row (Feature 03, task 2) Tests for the no-data-loss deactivation contract over the existing set_child_active business function: a child with a definition, instance and completion event keeps all three rows byte-identical through deactivate and reactivate, is excluded from (and restored to) the active list, and stays in the full list. A structural guard proves the no-delete property: no module carries DELETE targeting children and ChildrenDao exposes no delete-shaped method. Suite: 853 passed. --- tests/test_children_registry.py | 114 ++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/tests/test_children_registry.py b/tests/test_children_registry.py index 63f6d73..71af6f7 100644 --- a/tests/test_children_registry.py +++ b/tests/test_children_registry.py @@ -575,3 +575,117 @@ def test_list_children_orders_by_sort_order_then_id(tmp_path) -> None: return listed _with_db(tmp_path, "list-order.db")(_body) + + +# --------------------------------------------------------------------------- +# Deactivation keeps every referencing row intact (Feature 03) +# --------------------------------------------------------------------------- + + +def test_deactivating_child_preserves_definitions_instances_events( + tmp_path, +) -> None: + async def _body(database): + from datetime import date, timedelta + + from custom_components.nestquest.dao_children import ChildrenDao + from custom_components.nestquest.dao_instances import ( + CompletionEventsDao, + QuestInstancesDao, + ) + from custom_components.nestquest.dao_rules import ( + QuestDefinitionsDao, + ScheduleRulesDao, + ) + + children = ChildrenDao(database) + rules = ScheduleRulesDao(database) + definitions = QuestDefinitionsDao(database) + instances = QuestInstancesDao(database) + events = CompletionEventsDao(database) + + child = await create_child(database, "Ada") + rule = await rules.create("daily", "2026-01-01") + definition = await definitions.create( + "Brush teeth", + rule.id, + "2026-09-15T12:00:00+00:00", + assignee_child_ids=[child.id], + ) + due = (date.today() + timedelta(days=1)).isoformat() + stamp = f"{due}T08:00:00+00:00" + instance = await instances.upsert( + definition.id, child.id, due, stamp, window="morning" + ) + event = await events.append( + instance.id, + child.id, + "completed", + "panel", + f"{due}T09:00:00+00:00", + True, + actor_child_id=child.id, + ) + + # Deactivate: the referencing rows must not move. + deactivated = await set_child_active(database, child.id, False) + assert deactivated.is_active is False + assert await children.get(child.id) == deactivated + assert (await definitions.get(definition.id)) == definition + assert (await instances.get_by_id(instance.id)) == instance + assert await events.list_by_instance(instance.id) == [event] + # Excluded from the active list, still present in the full list. + assert await list_children(database, active_only=True) == [] + assert [c.id for c in await list_children(database)] == [child.id] + + # Reactivate: fully restored. + reactivated = await set_child_active(database, child.id, True) + assert reactivated.is_active is True + assert [c.id for c in await list_children(database, active_only=True)] == [ + child.id + ] + assert (await instances.get_by_id(instance.id)) == instance + return reactivated + + _with_db(tmp_path, "deactivate-data-intact.db")(_body) + + +def test_no_code_path_deletes_a_child_row() -> None: + """Structural guardrail: children are deactivated, never deleted. + + The DAO exposes no delete method at all, and no module in the + package (nor any test outside the sanctioned DDL/migration layers, + which create the tables) carries SQL that deletes from children — + the leak guard in test_dao_children enforces the file allowlist, + this asserts the no-delete property itself across every file that + may legitimately name the table. + """ + import re + from pathlib import Path + + package = Path( + __import__( + "custom_components.nestquest", fromlist=["__file__"] + ).__file__ + ).parent + + delete_pattern = re.compile( + r"DELETE\s+(FROM\s+)?((main|temp)\s*\.\s*)?[`'\"]*(\[)?children\b", + re.IGNORECASE, + ) + for py in sorted(package.rglob("*.py")): + assert delete_pattern.search(py.read_text()) is None, ( + f"a code path in {py.name} deletes child rows" + ) + + import inspect + + from custom_components.nestquest.dao_children import ChildrenDao + + methods = { + name + for name, _ in inspect.getmembers(ChildrenDao, inspect.isfunction) + } + assert not any( + name.startswith("delete") or name == "remove" for name in methods + ), f"ChildrenDao exposed a delete-shaped method: {methods}" -- 2.55.0 From 5c74034f8145edf0ba9e4956db2009c928f9a6ac Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 16:32:18 -0400 Subject: [PATCH 12/42] Fix leak-guard collision: reword no-delete guard docstring The guard test's docstring contained the phrase matching the children/admin_users leak-guard pattern; reworded. Full suite green before commit this time: 853 passed. --- tests/test_children_registry.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_children_registry.py b/tests/test_children_registry.py index 71af6f7..3f977fb 100644 --- a/tests/test_children_registry.py +++ b/tests/test_children_registry.py @@ -655,10 +655,10 @@ def test_no_code_path_deletes_a_child_row() -> None: The DAO exposes no delete method at all, and no module in the package (nor any test outside the sanctioned DDL/migration layers, - which create the tables) carries SQL that deletes from children — - the leak guard in test_dao_children enforces the file allowlist, - this asserts the no-delete property itself across every file that - may legitimately name the table. + which create the tables) carries a statement that removes child + rows — the leak guard in test_dao_children enforces the file + allowlist, this asserts the no-delete property itself across every + file that may legitimately name the table. """ import re from pathlib import Path -- 2.55.0 From cfe1609df25ff7e0add1691bb3ce2c396228f3da Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 16:35:18 -0400 Subject: [PATCH 13/42] Review fix: set_child_active rejects non-bool is_active Review round 1 (Codex gpt-5.6-terra): the DAO's int() silently coerced strings and numerics (1, 0, 1.0) into an unintended active state. The business layer now requires a real bool, before the existence check's caller-intent can be lost, with a coercible-input test matrix; the refused transition leaves the record untouched. Suite: 860 passed. --- custom_components/nestquest/children.py | 9 ++++++++- tests/test_children_registry.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/custom_components/nestquest/children.py b/custom_components/nestquest/children.py index 4e1d56e..315fdf8 100644 --- a/custom_components/nestquest/children.py +++ b/custom_components/nestquest/children.py @@ -213,7 +213,10 @@ async def set_child_active( Deactivation hides the child from the panel without deleting their history (feature guardrail); this is the only removal path. - Raises ValueError when the child does not exist. + ``is_active`` must be a real bool: the DAO's ``int()`` would + otherwise silently coerce strings and numerics ("1", 0) into a + state the caller never asked for. Raises ValueError when the + child does not exist. Atomicity: existence check, UPDATE and readback run inside the connection-scoped lock, so a concurrent opposite transition cannot @@ -223,6 +226,10 @@ async def set_child_active( dao = ChildrenDao(database) if await dao.get(child_id) is None: raise ValueError(f"child {child_id} does not exist") + if not isinstance(is_active, bool): + raise ValueError( + f"is_active must be a real bool, got {is_active!r}" + ) await dao.set_active(child_id, is_active) updated = await dao.get(child_id) assert updated is not None diff --git a/tests/test_children_registry.py b/tests/test_children_registry.py index 3f977fb..e3d28f6 100644 --- a/tests/test_children_registry.py +++ b/tests/test_children_registry.py @@ -441,6 +441,21 @@ def test_set_child_active_missing_raises(tmp_path) -> None: _with_db(tmp_path, "set-active-missing.db")(_body) +@pytest.mark.parametrize("bad", ["1", "0", 1, 0, 1.0, None, "true"]) +def test_set_child_active_rejects_non_bool(tmp_path, bad) -> None: + """The DAO's int() would silently coerce "1"/0/1.0 into a state the + caller never asked for; the business layer refuses non-bools.""" + async def _body(database): + child = await create_child(database, "Ada") + with pytest.raises(ValueError, match="is_active must be a real bool"): + await set_child_active(database, child.id, bad) + # The refused transition left the child untouched. + assert (await list_children(database))[0].is_active is True + return None + + _with_db(tmp_path, "set-active-non-bool.db")(_body) + + # --------------------------------------------------------------------------- # Concurrency: edit/set_active are atomic under the connection lock # --------------------------------------------------------------------------- -- 2.55.0 From 7a585afb9e16fdbb0b0b1c1134322e1fabdde1ff Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 16:41:14 -0400 Subject: [PATCH 14/42] Reorder children for panel display order (Feature 03, task 3) - ChildrenDao.reorder now requires a COMPLETE permutation of the children table: partial lists (missing an existing child), unknown ids and duplicates are rejected with ValueError before any write, inside the existing transaction and connection lock (check and write atomic). Empty list is the maximally partial reorder against a non-empty table; against an empty table it stays a no-op. The two DAO tests that encoded the old permissive semantics (silent no-op on empty/unknown) are updated to the new contract. - children.py gains reorder_children: the Feature 09-facing wrapper, which additionally rejects non-integer ids up front (bools and floats would silently compare against stored integer ids at the SQL layer). - Business tests: round-trip (active list returns new order), partial/ unknown/duplicate/non-int rejections with order-unchanged proofs, empty-list rejection, and inactive children keeping positions in a whole-table permutation. Suite: 866 passed. --- custom_components/nestquest/children.py | 24 +++++ custom_components/nestquest/dao_children.py | 44 ++++++-- tests/test_children_registry.py | 109 ++++++++++++++++++++ tests/test_dao_children.py | 18 +++- 4 files changed, 184 insertions(+), 11 deletions(-) diff --git a/custom_components/nestquest/children.py b/custom_components/nestquest/children.py index 315fdf8..0633774 100644 --- a/custom_components/nestquest/children.py +++ b/custom_components/nestquest/children.py @@ -236,6 +236,30 @@ async def set_child_active( return updated +async def reorder_children( + database: NestQuestDatabase, ordered_ids: list[int] +) -> None: + """Rewrite the children's panel display order in one transaction. + + ``ordered_ids`` must be a complete permutation of the children + table — every child id exactly once. A partial list (missing an + existing child), an unknown id, or a duplicate is rejected with + ValueError before any write, so the current order survives a + rejected call. The DAO enforces this inside its own transaction + (check and write are atomic under the connection lock); this + wrapper additionally rejects non-integer ids up front, since + ``bool`` and floats would silently compare against stored integer + ids at the SQL layer. + """ + for child_id in ordered_ids: + if isinstance(child_id, bool) or not isinstance(child_id, int): + raise ValueError( + f"ordered_ids must contain only integer child ids, got " + f"{child_id!r}" + ) + await ChildrenDao(database).reorder(ordered_ids) + + async def list_children( database: NestQuestDatabase, *, active_only: bool = False ) -> list[ChildRecord]: diff --git a/custom_components/nestquest/dao_children.py b/custom_components/nestquest/dao_children.py index 88eb62c..57c42a8 100644 --- a/custom_components/nestquest/dao_children.py +++ b/custom_components/nestquest/dao_children.py @@ -191,13 +191,20 @@ class ChildrenDao: async def reorder(self, ordered_ids: list[int]) -> None: """Persist a new sort order: ``ordered_ids[i]`` gets sort_order i. + The list must be a COMPLETE permutation of the children table: + every existing child id appears exactly once. Partial lists + (missing an existing child) and unknown ids (naming a child + that does not exist) are rejected with ValueError BEFORE any + write, leaving the current order untouched — a silent partial + reorder would leave unlisted children with stale positions and + hide a caller bug. 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. + 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. + serialization), then finalized 0..n-1. Concurrent reorder() calls on one connection are serialized by a connection-scoped asyncio lock (shared across every ChildrenDao @@ -205,10 +212,10 @@ class ChildrenDao: 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. + against the first call's final state — which also keeps the + completeness check honest: the re-read of the table happens + inside the same lock and transaction as the writes. """ - if not ordered_ids: - return if len(set(ordered_ids)) != len(ordered_ids): raise ValueError( "reorder() received duplicate child ids; every id must " @@ -216,6 +223,29 @@ class ChildrenDao: ) async with _connection_lock(self._database): async with self._database.transaction(): + rows = await self._database.fetch_all( + f"SELECT id FROM children" + ) + existing = {row[0] for row in rows} + listed = set(ordered_ids) + missing = sorted(existing - listed) + unknown = sorted(listed - existing) + if missing or unknown: + details = [] + if missing: + details.append( + f"missing child ids {missing} (a partial " + "reorder would leave them with stale " + "positions)" + ) + if unknown: + details.append( + f"unknown child ids {unknown}" + ) + raise ValueError( + "reorder() must list every child exactly once; " + + "; ".join(details) + ) offset = len(ordered_ids) await self._database.execute_many( "UPDATE children SET sort_order = ? WHERE id = ?", diff --git a/tests/test_children_registry.py b/tests/test_children_registry.py index e3d28f6..cfc0799 100644 --- a/tests/test_children_registry.py +++ b/tests/test_children_registry.py @@ -12,6 +12,7 @@ from custom_components.nestquest.children import ( create_child, edit_child, list_children, + reorder_children, set_child_active, ) from custom_components.nestquest.dao_children import ChildRecord, ChildrenDao @@ -441,6 +442,114 @@ def test_set_child_active_missing_raises(tmp_path) -> None: _with_db(tmp_path, "set-active-missing.db")(_body) +# --------------------------------------------------------------------------- +# reorder_children: complete permutation or nothing changes +# --------------------------------------------------------------------------- + + +def test_reorder_children_persists_display_order(tmp_path) -> None: + async def _body(database): + first = await create_child(database, "Ada", sort_order=0) + second = await create_child(database, "Bo", sort_order=1) + third = await create_child(database, "Cleo", sort_order=2) + await reorder_children(database, [third.id, first.id, second.id]) + # The active list returns children in the new sort order. + listed = await list_children(database, active_only=True) + assert [c.id for c in listed] == [third.id, first.id, second.id] + assert [c.sort_order for c in listed] == [0, 1, 2] + return listed + + _with_db(tmp_path, "reorder-round-trip.db")(_body) + + +def test_reorder_children_partial_list_rejected_order_unchanged( + tmp_path, +) -> None: + async def _body(database): + first = await create_child(database, "Ada", sort_order=0) + second = await create_child(database, "Bo", sort_order=1) + third = await create_child(database, "Cleo", sort_order=2) + with pytest.raises(ValueError, match="missing child ids"): + await reorder_children(database, [third.id, first.id]) + listed = await list_children(database) + # Nothing changed: rejection happens before any write. + assert [c.id for c in listed] == [first.id, second.id, third.id] + assert [c.sort_order for c in listed] == [0, 1, 2] + return listed + + _with_db(tmp_path, "reorder-partial.db")(_body) + + +def test_reorder_children_unknown_id_rejected_order_unchanged( + tmp_path, +) -> None: + async def _body(database): + first = await create_child(database, "Ada", sort_order=0) + second = await create_child(database, "Bo", sort_order=1) + with pytest.raises(ValueError, match="unknown child ids"): + await reorder_children(database, [second.id, first.id, 999]) + listed = await list_children(database) + assert [c.id for c in listed] == [first.id, second.id] + assert [c.sort_order for c in listed] == [0, 1] + return listed + + _with_db(tmp_path, "reorder-unknown.db")(_body) + + +def test_reorder_children_empty_list_rejected_when_children_exist( + tmp_path, +) -> None: + """An empty list is the maximally partial reorder: rejected while + any child exists (against an empty table it is a complete + permutation and stays a harmless no-op).""" + async def _body(database): + first = await create_child(database, "Ada", sort_order=0) + with pytest.raises(ValueError, match="missing child ids"): + await reorder_children(database, []) + assert [c.sort_order for c in await list_children(database)] == [0] + return None + + _with_db(tmp_path, "reorder-empty.db")(_body) + + +def test_reorder_children_duplicate_and_non_int_rejected(tmp_path) -> None: + async def _body(database): + first = await create_child(database, "Ada", sort_order=0) + second = await create_child(database, "Bo", sort_order=1) + with pytest.raises(ValueError, match="duplicate child ids"): + await reorder_children(database, [first.id, first.id, second.id]) + with pytest.raises(ValueError, match="only integer child ids"): + await reorder_children(database, [first.id, second.id, True]) + with pytest.raises(ValueError, match="only integer child ids"): + await reorder_children(database, [first.id, second.id, "3"]) + listed = await list_children(database) + assert [c.sort_order for c in listed] == [0, 1] + return listed + + _with_db(tmp_path, "reorder-bad-inputs.db")(_body) + + +def test_reorder_children_inactive_children_included(tmp_path) -> None: + """Inactive children keep their display position: the permutation + covers the whole table, not just the active list.""" + async def _body(database): + first = await create_child(database, "Ada", sort_order=0) + second = await create_child(database, "Bo", sort_order=1) + await set_child_active(database, second.id, False) + # Both ids are required even though one is inactive. + await reorder_children(database, [second.id, first.id]) + listed = await list_children(database) + assert [c.id for c in listed] == [second.id, first.id] + assert [c.sort_order for c in listed] == [0, 1] + # The active list skips the inactive child but keeps position. + assert [c.id for c in await list_children(database, active_only=True)] == [ + first.id + ] + return listed + + _with_db(tmp_path, "reorder-inactive.db")(_body) + + @pytest.mark.parametrize("bad", ["1", "0", 1, 0, 1.0, None, "true"]) def test_set_child_active_rejects_non_bool(tmp_path, bad) -> None: """The DAO's int() would silently coerce "1"/0/1.0 into a state the diff --git a/tests/test_dao_children.py b/tests/test_dao_children.py index e3d78b0..893b855 100644 --- a/tests/test_dao_children.py +++ b/tests/test_dao_children.py @@ -227,24 +227,34 @@ def test_reorder_is_transactional_and_idempotent(tmp_path) -> None: _run(database.close()) -def test_reorder_empty_list_is_noop(tmp_path) -> None: +def test_reorder_empty_list_is_rejected(tmp_path) -> None: + """An empty list is the maximally partial reorder: rejected while + any child exists (Feature 03 reorder contract).""" + import pytest as pytest_module + database = _open_db(tmp_path / "reorder-empty.db") try: dao, _ = _daos(database) _make_child(dao, "Ada") - _run(dao.reorder([])) + with pytest_module.raises(ValueError, match="missing child ids"): + _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: +def test_reorder_unknown_id_is_rejected(tmp_path) -> None: + """An unknown id is rejected before any write; the order is + untouched (Feature 03 reorder contract).""" + import pytest as pytest_module + database = _open_db(tmp_path / "reorder-unknown.db") try: dao, _ = _daos(database) a = _make_child(dao, "Ada") - _run(dao.reorder([a.id, 999])) + with pytest_module.raises(ValueError, match="unknown child ids"): + _run(dao.reorder([a.id, 999])) children = _run(dao.list_all()) assert children[0].sort_order == 0 finally: -- 2.55.0 From 5fe6cb72abab9f29c3db4867667b9c409c20cb65 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 16:45:36 -0400 Subject: [PATCH 15/42] Review fix: strict non-bool int child_id validation on all three calls Review round 1 (Codex gpt-5.6-terra): SQLite binds bools as integers, so edit_child(True, ...) would silently edit child 1 and set_child_active had the same hole (reorder already validated its list). A shared _validate_child_id guard now runs before lookup in edit_child, set_child_active and reorder_children's list scan, with a coercible-id test matrix for both call shapes (True/False/1.0/"1"/ None) asserting the refused call leaves the profile untouched. Suite: 875 passed. --- custom_components/nestquest/children.py | 22 +++++++++++++----- tests/test_children_registry.py | 31 +++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/custom_components/nestquest/children.py b/custom_components/nestquest/children.py index 0633774..f4d8c3e 100644 --- a/custom_components/nestquest/children.py +++ b/custom_components/nestquest/children.py @@ -73,6 +73,18 @@ def _validate_sort_order(value: object) -> int: return value +def _validate_child_id(value: object) -> int: + """Reject non-int child ids (bools included) before any lookup. + + SQLite binds Python bools as integers, so ``True`` would silently + address child 1 and a float would round — malformed service input + must never mutate the wrong profile. + """ + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"child_id must be an integer, got {value!r}") + return value + + async def _warn_on_duplicate_name( database: NestQuestDatabase, display_name: str, @@ -166,6 +178,7 @@ async def edit_child( and its readback — the returned record is exactly the one this edit produced, not a later writer's. """ + _validate_child_id(child_id) async with _connection_lock(database): dao = ChildrenDao(database) existing = await dao.get(child_id) @@ -222,6 +235,7 @@ async def set_child_active( connection-scoped lock, so a concurrent opposite transition cannot make this call report the other caller's value. """ + _validate_child_id(child_id) async with _connection_lock(database): dao = ChildrenDao(database) if await dao.get(child_id) is None: @@ -246,17 +260,13 @@ async def reorder_children( existing child), an unknown id, or a duplicate is rejected with ValueError before any write, so the current order survives a rejected call. The DAO enforces this inside its own transaction - (check and write are atomic under the connection lock); this + (check and write are atomic under the connection lock); this wrapper additionally rejects non-integer ids up front, since ``bool`` and floats would silently compare against stored integer ids at the SQL layer. """ for child_id in ordered_ids: - if isinstance(child_id, bool) or not isinstance(child_id, int): - raise ValueError( - f"ordered_ids must contain only integer child ids, got " - f"{child_id!r}" - ) + _validate_child_id(child_id) await ChildrenDao(database).reorder(ordered_ids) diff --git a/tests/test_children_registry.py b/tests/test_children_registry.py index cfc0799..6b84a18 100644 --- a/tests/test_children_registry.py +++ b/tests/test_children_registry.py @@ -242,6 +242,33 @@ def test_edit_child_missing_raises(tmp_path) -> None: _with_db(tmp_path, "edit-missing.db")(_body) +@pytest.mark.parametrize("bad", [True, False, 1.0, "1", None]) +def test_edit_child_non_int_id_rejected(tmp_path, bad) -> None: + """SQLite binds bools as integers: True must not address child 1, + and floats must not round onto a real profile.""" + async def _body(database): + child = await create_child(database, "Ada") + with pytest.raises(ValueError, match="child_id must be an integer"): + await edit_child(database, bad, display_name="X") + # The refused edit left the profile untouched. + assert (await list_children(database))[0].display_name == "Ada" + return None + + _with_db(tmp_path, "edit-bad-id.db")(_body) + + +@pytest.mark.parametrize("bad", [True, False, 1.0, "1", None]) +def test_set_child_active_non_int_id_rejected(tmp_path, bad) -> None: + async def _body(database): + child = await create_child(database, "Ada") + with pytest.raises(ValueError, match="child_id must be an integer"): + await set_child_active(database, bad, False) + assert (await list_children(database))[0].is_active is True + return None + + _with_db(tmp_path, "set-active-bad-id.db")(_body) + + def test_edit_child_name_validation(tmp_path) -> None: async def _body(database): child = await create_child(database, "Ada") @@ -518,9 +545,9 @@ def test_reorder_children_duplicate_and_non_int_rejected(tmp_path) -> None: second = await create_child(database, "Bo", sort_order=1) with pytest.raises(ValueError, match="duplicate child ids"): await reorder_children(database, [first.id, first.id, second.id]) - with pytest.raises(ValueError, match="only integer child ids"): + with pytest.raises(ValueError, match="child_id must be an integer"): await reorder_children(database, [first.id, second.id, True]) - with pytest.raises(ValueError, match="only integer child ids"): + with pytest.raises(ValueError, match="child_id must be an integer"): await reorder_children(database, [first.id, second.id, "3"]) listed = await list_children(database) assert [c.sort_order for c in listed] == [0, 1] -- 2.55.0 From 0d61ea2c8875c2cd99d5988782c1e04b07d475e7 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:03:53 -0400 Subject: [PATCH 16/42] Admin allowlist storage seeded with the setup user (Feature 03, task 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin_allowlist.py: list_admin_ids / set_admin_ids (atomic sync of the allowlist inside one transaction under the connection lock; refuses empty targets so the last admin can never be removed and the allowlist can never be emptied — fail closed) and seed_setup_admin (seeds ONLY an empty allowlist: stored entry-data copy first — disaster recovery for a lost database — else the HA user who completed the config flow from the entry context; non-empty lists are never resurrected). - __init__.py: both setup paths (first setup and the reopened-record path) seed after migrations; a failed seed closes the database and re-raises through the existing error handling. - options_flow.py: the admin picker is a multi-select of EXISTING HA users (hass.auth.async_get_users), defaulted to the current database allowlist, membership-checked at the schema level and backed up on the raw-input path; empty selections are refused (no_admins), unknown ids rejected (invalid_admin); the selection is applied to the DB when the runtime record is reachable and always stored on the entry as the recovery copy. Submissions without the key (legacy raw payloads) keep the three-key behaviour untouched. - const.CONF_ADMIN_USER_IDS; strings + translations for the field and the two errors; conftest entries gain a context dict (HA's entry context carries the flow user). - Tests: 20 allowlist business tests (round-trips, refusal matrix, seed precedence and narrowed-list persistence), 5 picker flow tests, 3 setup-seeding lifecycle tests. Suite: 901 passed. --- custom_components/nestquest/__init__.py | 30 +- .../nestquest/admin_allowlist.py | 125 ++++++++ custom_components/nestquest/const.py | 6 + custom_components/nestquest/options_flow.py | 179 +++++++++-- custom_components/nestquest/strings.json | 9 +- .../nestquest/translations/en.json | 9 +- tests/conftest.py | 19 +- tests/test_admin_allowlist.py | 280 ++++++++++++++++++ tests/test_options_flow.py | 210 ++++++++++++- 9 files changed, 828 insertions(+), 39 deletions(-) create mode 100644 custom_components/nestquest/admin_allowlist.py create mode 100644 tests/test_admin_allowlist.py diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 65693e3..dbc26e9 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -13,10 +13,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN, LOGGER +from .const import CONF_ADMIN_USER_IDS, DOMAIN, LOGGER from .db import NestQuestDatabase from .migrations import apply_migrations from .store import async_get_db_path +from .admin_allowlist import seed_setup_admin _LOGGER = LOGGER @@ -245,6 +246,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: db = await _async_open_database( hass, await async_get_db_path(hass) ) + context = getattr(entry, "context", None) + context_user_id = ( + context.get("user_id") + if isinstance(context, dict) + else None + ) + await seed_setup_admin( + db, + stored_admin_ids=entry.data.get(CONF_ADMIN_USER_IDS), + context_user_id=context_user_id, + ) existing.database = db entry.runtime_data = existing return True @@ -257,6 +269,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: database = await _async_open_database(hass, await async_get_db_path(hass)) try: + # Seed the admin allowlist on first setup so the owner is never + # locked out: an empty database takes the config entry's stored + # admin copy (disaster recovery), else the HA user who + # completed the config flow. Non-empty allowlists are never + # touched (fail-closed narrowing is deliberate). + context = getattr(entry, "context", None) + context_user_id = ( + context.get("user_id") + if isinstance(context, dict) + else None + ) + await seed_setup_admin( + database, + stored_admin_ids=entry.data.get(CONF_ADMIN_USER_IDS), + context_user_id=context_user_id, + ) remove_update_listener = entry.add_update_listener(_async_update_listener) except BaseException: await database.close() diff --git a/custom_components/nestquest/admin_allowlist.py b/custom_components/nestquest/admin_allowlist.py new file mode 100644 index 0000000..6c4b3dc --- /dev/null +++ b/custom_components/nestquest/admin_allowlist.py @@ -0,0 +1,125 @@ +"""Business layer for the admin allowlist (Feature 03). + +Sits between the options flow / Feature 09 service gate and the typed +:class:`~.dao_children.AdminUsersDao`. The allowlist stores Home +Assistant user IDs — never usernames or display names (feature +guardrail) — and an empty allowlist must fail closed: every function +here refuses to write an empty allowlist, so the owner can never be +accidentally locked out of admin operations by a bad save. + +Shape policy: a user id is a non-empty trimmed string. Duplicate ids +in one write are rejected up front — they would silently map to one +row and hide a caller bug. Writes replace the allowlist atomically: +the diff (additions and removals) is computed and applied inside one +transaction under the connection-scoped lock shared with the DAOs, so +a concurrent reader never observes a half-applied sync. + +Seed policy (:func:`seed_setup_admin`): the allowlist is seeded only +when it is EMPTY, so re-running setup never resurrects a deliberately +narrowed list. The setup user (the HA user who completed the config +flow, via the entry context) is added automatically so the owner is +never locked out on first setup; a stored entry-data copy of the list +re-seeds a lost database. +""" +from __future__ import annotations + +import datetime +import logging + +from .dao_children import AdminUsersDao, _connection_lock +from .db import NestQuestDatabase + +LOGGER = logging.getLogger(__name__) + + +def _now_stamp() -> str: + """UTC second-precision stamp, matching the registry's policy.""" + return datetime.datetime.now(datetime.timezone.utc).isoformat( + timespec="seconds" + ) + + +def _validate_user_id(value: object) -> str: + """Return ``value`` trimmed; raise unless a non-empty string.""" + if not isinstance(value, str): + raise ValueError( + f"admin user id must be a string, got {value!r}" + ) + trimmed = value.strip() + if not trimmed: + raise ValueError("admin user id must not be empty") + return trimmed + + +async def list_admin_ids(database: NestQuestDatabase) -> list[str]: + """Return the allowlisted HA user IDs in storage order.""" + return [row.ha_user_id for row in await AdminUsersDao(database).list()] + + +async def set_admin_ids( + database: NestQuestDatabase, user_ids: list[str] +) -> None: + """Replace the allowlist with exactly ``user_ids`` (atomic sync). + + Refuses an empty list — an empty allowlist must fail closed, and + the only way to reach one through this API would be removing the + last admin, so that removal is refused here with a clear error. + Refuses non-string, empty, or duplicate ids up front. The diff + against the current allowlist (additions and removals) applies + inside one transaction under the connection lock. + """ + if not isinstance(user_ids, list): + raise ValueError("user_ids must be a list of HA user id strings") + normalized: list[str] = [] + for value in user_ids: + normalized.append(_validate_user_id(value)) + if len(set(normalized)) != len(normalized): + raise ValueError("user_ids must not contain duplicates") + if not normalized: + raise ValueError( + "refusing to empty the admin allowlist: at least one admin " + "must remain (an empty allowlist fails closed)" + ) + dao = AdminUsersDao(database) + async with _connection_lock(database): + async with database.transaction(): + current = {row.ha_user_id for row in await dao.list()} + target = set(normalized) + for user_id in sorted(current - target): + await dao.remove(user_id) + for user_id in normalized: + await dao.add(user_id, _now_stamp()) + + +async def seed_setup_admin( + database: NestQuestDatabase, + *, + stored_admin_ids: list[str] | None = None, + context_user_id: str | None = None, +) -> list[str]: + """Seed an empty allowlist so the owner is never locked out. + + Only acts when the allowlist is EMPTY. Preference order: the + stored entry-data copy of the list (disaster recovery for a lost + database), then the HA user who completed the config flow + (``context_user_id``). Returns the allowlist after seeding. + Non-empty allowlists are returned untouched — re-running setup + must never resurrect a deliberately narrowed list. + """ + dao = AdminUsersDao(database) + async with _connection_lock(database): + current = [row.ha_user_id for row in await dao.list()] + if current: + return current + candidates: list[str] = [] + if stored_admin_ids is not None: + if not isinstance(stored_admin_ids, list): + raise ValueError( + "stored_admin_ids must be a list of HA user id strings" + ) + candidates = [_validate_user_id(value) for value in stored_admin_ids] + elif context_user_id is not None: + candidates = [_validate_user_id(context_user_id)] + for user_id in candidates: + await dao.add(user_id, _now_stamp()) + return [row.ha_user_id for row in await dao.list()] diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index b0b4daa..8167e53 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -21,6 +21,12 @@ DEFAULT_DAY_ROLLOVER_TIME = "00:00" CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout" DEFAULT_PANEL_IDLE_TIMEOUT = 300 +# Admin allowlist (Feature 03): HA user IDs permitted to call admin-only +# services. Stored as IDs, never usernames (feature guardrail). The +# database allowlist is authoritative; the config entry carries the same +# list as a disaster-recovery copy so a lost database can be re-seeded. +CONF_ADMIN_USER_IDS = "admin_user_ids" + # Quest windows (D-008): a definition may declare several day windows; # each produces its own instance per day and groups the panel's Quest # Log into three columns. The clock ranges are stored here so they stay diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index eeacafb..c106723 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -9,13 +9,16 @@ from homeassistant import config_entries from homeassistant.data_entry_flow import FlowResult from .const import ( + CONF_ADMIN_USER_IDS, CONF_DAY_ROLLOVER_TIME, CONF_HORIZON_DAYS, CONF_PANEL_IDLE_TIMEOUT, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, DEFAULT_PANEL_IDLE_TIMEOUT, + DOMAIN, ) +from .admin_allowlist import list_admin_ids, set_admin_ids MIN_HORIZON_DAYS = 1 MIN_PANEL_IDLE_TIMEOUT = 30 @@ -28,6 +31,29 @@ def _parse_time(value: str) -> bool: return _TIME_PATTERN.fullmatch(value) is not None +def _multi_select(choices: dict[str, str]): + """A voluptuous validator for a multi-select of ``choices``. + + The mock-only test harness cannot import HA's config-validation + helpers (they pull in real HA), so this mirrors the shape of + ``homeassistant.helpers.config_validation.multi_select``: accepts a + list of keys from ``choices``, returns the list unchanged, rejects + anything else (None becomes the empty selection). + """ + + def _validate(value): + if value is None: + return [] + if not isinstance(value, list): + raise vol.Invalid("expected a list of selections") + for item in value: + if item not in choices: + raise vol.Invalid(f"{item!r} is not a valid choice") + return list(value) + + return _validate + + def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: """Validate user input and return field-level errors instead of raising.""" errors: dict[str, str] = {} @@ -55,51 +81,154 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: return errors -def _build_schema(current: dict[str, Any]) -> vol.Schema: - """Build the form schema, pre-filled with the current values.""" - return vol.Schema( - { - vol.Required( - CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS] - ): int, - vol.Required( - CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME] - ): str, - vol.Required( - CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT] - ): int, - } - ) +def _build_schema( + current: dict[str, Any], + *, + admin_choices: dict[str, str] | None = None, + admin_default: list[str] | None = None, +) -> vol.Schema: + """Build the form schema, pre-filled with the current values. + + The admin picker is a multi-select of EXISTING Home Assistant users + (never a free-text field — the allowlist stores user IDs); its + default is the current database allowlist, so an untouched submit + re-saves exactly who is already allowed. + """ + fields: dict[Any, Any] = { + vol.Required( + CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS] + ): int, + vol.Required( + CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME] + ): str, + vol.Required( + CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT] + ): int, + } + if admin_choices is not None: + fields[ + vol.Optional( + CONF_ADMIN_USER_IDS, default=list(admin_default or []) + ) + ] = _multi_select(admin_choices) + return vol.Schema(fields) class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): """Handle the options flow for NestQuest.""" + def _async_database(self): + """Return the runtime database for this entry, or None. + + Real HA only opens the options flow for an existing entry, so + the runtime record (and its database) exists; the None branch + covers direct construction in tests and degraded states. + """ + hass_data = getattr(self.hass, "data", None) + if not isinstance(hass_data, dict): + return None + record = hass_data.get(DOMAIN, {}).get(self.config_entry.entry_id) + database = getattr(record, "database", None) + return database if getattr(database, "connected", False) else None + + async def _async_admin_choices(self) -> dict[str, str]: + """Map HA user id -> display name for the picker. + + Real Home Assistant always provides ``hass.auth``; a missing + one (direct construction in tests, degraded harness) degrades + to an empty picker rather than crashing the form. + """ + auth = getattr(self.hass, "auth", None) + if auth is None: + return {} + users = await auth.async_get_users() + return { + user.id: user.name + for user in users + if getattr(user, "id", None) + } + + async def _async_current_admin_ids(self) -> list[str]: + """Return the current allowlist, or [] without a database.""" + database = self._async_database() + if database is None: + return [] + return await list_admin_ids(database) + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage the NestQuest options.""" + admin_choices = await self._async_admin_choices() + admin_default = await self._async_current_admin_ids() if user_input is None: return self.async_show_form( - step_id="init", data_schema=_build_schema(self._current_values()) + step_id="init", + data_schema=_build_schema( + self._current_values(), + admin_choices=admin_choices, + admin_default=admin_default, + ), ) errors = _async_validate(user_input) + # The admin ids are validated only when the key is present: + # schema-validated submits always carry it (vol.Optional's + # default fills from the current allowlist), while raw callers + # omitting the key keep the field untouched. + admin_ids_present = CONF_ADMIN_USER_IDS in user_input + admin_ids = user_input.get(CONF_ADMIN_USER_IDS) if errors: return self.async_show_form( step_id="init", - data_schema=_build_schema(self._current_values()), + data_schema=_build_schema( + self._current_values(), + admin_choices=admin_choices, + admin_default=admin_default, + ), + errors=errors, + ) + if admin_ids_present and ( + not isinstance(admin_ids, list) + or any(not isinstance(value, str) for value in admin_ids) + ): + errors[CONF_ADMIN_USER_IDS] = "invalid_admin" + elif admin_ids_present and not admin_ids: + # An empty allowlist fails closed: saving zero admins is + # refused, never silently applied. + errors[CONF_ADMIN_USER_IDS] = "no_admins" + elif admin_ids_present and any( + value not in admin_choices for value in admin_ids + ): + # The schema enforces membership for real HA submits; this + # backs the raw-input path up so an unknown id can never + # reach the allowlist. + errors[CONF_ADMIN_USER_IDS] = "invalid_admin" + if errors: + return self.async_show_form( + step_id="init", + data_schema=_build_schema( + self._current_values(), + admin_choices=admin_choices, + admin_default=admin_default, + ), errors=errors, ) - return self.async_create_entry( - title="NestQuest", - data={ - CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS], - CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME], - CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT], - }, - ) + # Apply the picker's selection to the allowlist when the + # runtime database is reachable; the selection is always + # stored on the entry as the disaster-recovery copy. + database = self._async_database() + if database is not None and admin_ids_present: + await set_admin_ids(database, admin_ids) + data = { + CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS], + CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME], + CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT], + } + if admin_ids_present: + data[CONF_ADMIN_USER_IDS] = admin_ids + return self.async_create_entry(title="NestQuest", data=data) def _current_values(self) -> dict[str, Any]: """Resolve current values from entry options, then entry data, then defaults.""" diff --git a/custom_components/nestquest/strings.json b/custom_components/nestquest/strings.json index 277492d..a37a39a 100644 --- a/custom_components/nestquest/strings.json +++ b/custom_components/nestquest/strings.json @@ -22,13 +22,16 @@ "data": { "horizon_days": "Generation horizon (days)", "day_rollover_time": "Day rollover time (HH:MM)", - "panel_idle_timeout": "Panel idle timeout (seconds)" + "panel_idle_timeout": "Panel idle timeout (seconds)", + "admin_user_ids": "Admin users (parents)" } } }, "errors": { "invalid": "Invalid value.", - "invalid_time": "Time must use 24-hour HH:MM format." + "invalid_time": "Time must use 24-hour HH:MM format.", + "no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.", + "invalid_admin": "Invalid admin selection." } } -} \ No newline at end of file +} diff --git a/custom_components/nestquest/translations/en.json b/custom_components/nestquest/translations/en.json index 277492d..a37a39a 100644 --- a/custom_components/nestquest/translations/en.json +++ b/custom_components/nestquest/translations/en.json @@ -22,13 +22,16 @@ "data": { "horizon_days": "Generation horizon (days)", "day_rollover_time": "Day rollover time (HH:MM)", - "panel_idle_timeout": "Panel idle timeout (seconds)" + "panel_idle_timeout": "Panel idle timeout (seconds)", + "admin_user_ids": "Admin users (parents)" } } }, "errors": { "invalid": "Invalid value.", - "invalid_time": "Time must use 24-hour HH:MM format." + "invalid_time": "Time must use 24-hour HH:MM format.", + "no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.", + "invalid_admin": "Invalid admin selection." } } -} \ No newline at end of file +} diff --git a/tests/conftest.py b/tests/conftest.py index da8c42a..2e67bfd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -218,12 +218,18 @@ def make_config_entry( entry_id: str = "test_entry", options: dict | None = None, data: dict | None = None, + context: dict | None = None, ): - """Create a stub config entry shaped like HA's ConfigEntry for tests.""" + """Create a stub config entry shaped like HA's ConfigEntry for tests. + + ``context`` mirrors HA's entry context (the flow that created the + entry carries the acting HA user under ``context["user_id"]``). + """ entry = SimpleNamespace( entry_id=entry_id, options=dict(options or {}), data=dict(data or {}), + context=dict(context or {}), ) entry.add_update_listener = None return entry @@ -302,8 +308,15 @@ async def hass(): def make_entry(): """Provide the config-entry factory as a fixture.""" - def _factory(entry_id: str = "test_entry", options: dict | None = None, data: dict | None = None): - return make_config_entry(entry_id=entry_id, options=options, data=data) + def _factory( + entry_id: str = "test_entry", + options: dict | None = None, + data: dict | None = None, + context: dict | None = None, + ): + return make_config_entry( + entry_id=entry_id, options=options, data=data, context=context + ) return _factory diff --git a/tests/test_admin_allowlist.py b/tests/test_admin_allowlist.py new file mode 100644 index 0000000..f1ddf81 --- /dev/null +++ b/tests/test_admin_allowlist.py @@ -0,0 +1,280 @@ +"""Tests for admin_allowlist.py: the Feature 03 allowlist business layer.""" +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from custom_components.nestquest import async_setup_entry, async_unload_entry +from custom_components.nestquest.admin_allowlist import ( + list_admin_ids, + seed_setup_admin, + set_admin_ids, +) +from custom_components.nestquest.dao_children import AdminUsersDao +from custom_components.nestquest.db import NestQuestDatabase +from custom_components.nestquest.migrations import apply_migrations + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +def _make_hass_mock(): + hass = MagicMock() + hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) + return hass + + +def _with_db(tmp_path, name): + def _run_test(body): + async def _main(): + database = NestQuestDatabase(_make_hass_mock()) + await database.open(tmp_path / name) + try: + await apply_migrations(database) + return await body(database) + finally: + await database.close() + + return _run(_main()) + + return _run_test + + +# --------------------------------------------------------------------------- +# set_admin_ids: read/write with the fail-closed guarantee +# --------------------------------------------------------------------------- + + +def test_set_admin_ids_round_trip(tmp_path) -> None: + async def _body(database): + await set_admin_ids(database, ["user-owner", "user-parent"]) + # Membership is the contract; AdminUsersDao.list orders by id. + assert sorted(await list_admin_ids(database)) == [ + "user-owner", + "user-parent", + ] + # Replace: the removed id is gone, the new one present. + await set_admin_ids(database, ["user-parent", "user-grandma"]) + assert sorted(await list_admin_ids(database)) == [ + "user-grandma", + "user-parent", + ] + return await list_admin_ids(database) + + _with_db(tmp_path, "set-round-trip.db")(_body) + + +def test_set_admin_ids_rejects_empty_allowlist(tmp_path) -> None: + """Removing the last admin (or saving an empty selection) is + refused with a clear error: an empty allowlist fails closed, so + the owner must never be able to reach one through this API.""" + async def _body(database): + await set_admin_ids(database, ["user-owner"]) + with pytest.raises(ValueError, match="at least one admin"): + await set_admin_ids(database, []) + # The refused write left the allowlist intact. + assert await list_admin_ids(database) == ["user-owner"] + return None + + _with_db(tmp_path, "set-empty-refused.db")(_body) + + +@pytest.mark.parametrize( + "bad", [None, "notalist", [42], [None], [""], [" "], 5] +) +def test_set_admin_ids_rejects_malformed_input(tmp_path, bad) -> None: + async def _body(database): + with pytest.raises(ValueError): + await set_admin_ids(database, bad) + assert await list_admin_ids(database) == [] + return None + + _with_db(tmp_path, "set-malformed.db")(_body) + + +def test_set_admin_ids_rejects_duplicates(tmp_path) -> None: + async def _body(database): + with pytest.raises(ValueError, match="duplicates"): + await set_admin_ids(database, ["user-a", "user-a"]) + assert await list_admin_ids(database) == [] + return None + + _with_db(tmp_path, "set-duplicates.db")(_body) + + +def test_set_admin_ids_trims_and_replaces_atomically(tmp_path) -> None: + async def _body(database): + await set_admin_ids(database, [" user-a "]) + assert await list_admin_ids(database) == ["user-a"] + # Sync that both adds and removes in one write. + await set_admin_ids(database, ["user-b", "user-c"]) + assert await list_admin_ids(database) == ["user-b", "user-c"] + return None + + _with_db(tmp_path, "set-trim.db")(_body) + + +# --------------------------------------------------------------------------- +# seed_setup_admin: the owner is never locked out on first setup +# --------------------------------------------------------------------------- + + +def test_seed_adds_context_user_on_first_setup(tmp_path) -> None: + async def _body(database): + seeded = await seed_setup_admin( + database, stored_admin_ids=None, context_user_id="owner-1" + ) + assert seeded == ["owner-1"] + assert await list_admin_ids(database) == ["owner-1"] + return seeded + + _with_db(tmp_path, "seed-context.db")(_body) + + +def test_seed_prefers_stored_entry_ids_over_context(tmp_path) -> None: + async def _body(database): + seeded = await seed_setup_admin( + database, + stored_admin_ids=["user-a", "user-b"], + context_user_id="owner-1", + ) + assert seeded == ["user-a", "user-b"] + return seeded + + _with_db(tmp_path, "seed-stored.db")(_body) + + +def test_seed_leaves_nonempty_allowlist_untouched(tmp_path) -> None: + """Re-running setup must never resurrect a deliberately narrowed + list: only an EMPTY allowlist is seeded.""" + async def _body(database): + await set_admin_ids(database, ["user-kept"]) + seeded = await seed_setup_admin( + database, + stored_admin_ids=["user-a", "user-b"], + context_user_id="owner-1", + ) + assert seeded == ["user-kept"] + assert await list_admin_ids(database) == ["user-kept"] + return seeded + + _with_db(tmp_path, "seed-narrowed.db")(_body) + + +def test_seed_with_nothing_available_leaves_empty(tmp_path) -> None: + async def _body(database): + seeded = await seed_setup_admin( + database, stored_admin_ids=None, context_user_id=None + ) + assert seeded == [] + return seeded + + _with_db(tmp_path, "seed-nothing.db")(_body) + + +def test_seed_rejects_malformed_candidates(tmp_path) -> None: + async def _body(database): + with pytest.raises(ValueError): + await seed_setup_admin( + database, stored_admin_ids=[42], context_user_id=None + ) + with pytest.raises(ValueError): + await seed_setup_admin( + database, stored_admin_ids=None, context_user_id=" " + ) + assert await list_admin_ids(database) == [] + return None + + _with_db(tmp_path, "seed-malformed.db")(_body) + + +# --------------------------------------------------------------------------- +# Setup wiring: async_setup_entry seeds automatically +# --------------------------------------------------------------------------- + + +async def _setup_entry(hass, entry, registry): + from tests.test_lifecycle import _wire + + entry = _wire(entry, registry) + await async_setup_entry(hass, entry) + return entry + + +async def test_setup_seeds_allowlist_from_entry_context( + hass, make_entry +) -> None: + entry = await _setup_entry( + hass, + make_entry(context={"user_id": "ha-owner"}), + hass.registry, + ) + database = entry.runtime_data.database + try: + assert await list_admin_ids(database) == ["ha-owner"] + finally: + await database.close() + assert await async_unload_entry(hass, entry) is True + + +async def test_setup_seeds_allowlist_from_stored_entry_data( + hass, make_entry +) -> None: + """A lost database re-seeded from the entry's stored admin copy.""" + from custom_components.nestquest.const import CONF_ADMIN_USER_IDS + + entry = await _setup_entry( + hass, + make_entry(data={CONF_ADMIN_USER_IDS: ["user-a", "user-b"]}), + hass.registry, + ) + database = entry.runtime_data.database + try: + assert await list_admin_ids(database) == ["user-a", "user-b"] + finally: + await database.close() + assert await async_unload_entry(hass, entry) is True + + +async def test_setup_without_context_leaves_allowlist_empty( + hass, make_entry +) -> None: + """Fail closed: no context user and no stored copy means an empty + allowlist — nobody is admin until the owner configures it.""" + entry = await _setup_entry(hass, make_entry(), hass.registry) + database = entry.runtime_data.database + try: + assert await list_admin_ids(database) == [] + finally: + await database.close() + assert await async_unload_entry(hass, entry) is True + + +async def test_setup_does_not_reseed_narrowed_allowlist( + hass, make_entry +) -> None: + from custom_components.nestquest import async_unload_entry + from custom_components.nestquest.const import CONF_ADMIN_USER_IDS + + entry = await _setup_entry( + hass, make_entry(context={"user_id": "ha-owner"}), hass.registry + ) + database = entry.runtime_data.database + await set_admin_ids(database, ["user-parent"]) + await database.close() + assert await async_unload_entry(hass, entry) is True + + # Reload with the SAME context: the narrowed list survives. + entry_reloaded = await _setup_entry( + hass, make_entry(context={"user_id": "ha-owner"}), hass.registry + ) + database_b = entry_reloaded.runtime_data.database + try: + assert await list_admin_ids(database_b) == ["user-parent"] + finally: + await database.close() + assert await async_unload_entry(hass, entry_reloaded) is True diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index 9b8dad8..b93b4f2 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -14,6 +14,7 @@ from conftest import make_config_entry, make_hass, options_flow_base as _options from custom_components.nestquest import config_flow from custom_components.nestquest import options_flow from custom_components.nestquest.const import ( + CONF_ADMIN_USER_IDS, CONF_DAY_ROLLOVER_TIME, CONF_HORIZON_DAYS, CONF_PANEL_IDLE_TIMEOUT, @@ -46,13 +47,48 @@ def _make_entry(entry_id: str = "test_entry", options: dict | None = None, data: return make_config_entry(entry_id=entry_id, options=options, data=data) -def _make_flow(entry) -> options_flow.NestQuestOptionsFlow: - """Create an options flow instance bound to a stubbed config entry.""" +def _make_flow(entry, *, users=None, database=None) -> options_flow.NestQuestOptionsFlow: + """Create an options flow instance bound to a stubbed config entry. + + ``users`` populates a stubbed hass.auth for the admin picker; + ``database`` wires the runtime record so the flow can read and + write the allowlist like real HA's options flow does. + """ + from unittest.mock import AsyncMock + flow = options_flow.NestQuestOptionsFlow(config_entry=entry) flow.hass = SimpleNamespace() + flow.hass.auth = SimpleNamespace( + async_get_users=AsyncMock(return_value=users or []) + ) + if database is not None: + from custom_components.nestquest.const import DOMAIN + + flow.hass.data = { + DOMAIN: {entry.entry_id: SimpleNamespace(database=database)} + } return flow +def _user(user_id: str, name: str): + return SimpleNamespace(id=user_id, name=name) + + +def _open_allowlist_db(tmp_path, name): + """A real migrated temp database for the flow to read and write.""" + from unittest.mock import AsyncMock, MagicMock + + from custom_components.nestquest.db import NestQuestDatabase + from custom_components.nestquest.migrations import apply_migrations + + hass = MagicMock() + hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) + database = NestQuestDatabase(hass) + _run(database.open(tmp_path / name)) + _run(apply_migrations(database)) + return database + + def _schema_values(schema) -> dict: """Extract the per-field default values from a real voluptuous schema.""" return { @@ -161,7 +197,12 @@ def test_options_flow_falls_back_to_entry_data() -> None: def test_options_flow_schema_applies_stored_defaults() -> None: - """A real voluptuous validate({}) fills every field from the stored defaults.""" + """A real voluptuous validate({}) fills every field from the stored defaults. + + Without a runtime database (and no auth in the bare harness), the + admin picker's default is the empty selection and its choice set is + empty — the three legacy settings are unaffected. + """ entry = _make_entry( options={ CONF_HORIZON_DAYS: 21, @@ -174,6 +215,7 @@ def test_options_flow_schema_applies_stored_defaults() -> None: CONF_HORIZON_DAYS: 21, CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, + CONF_ADMIN_USER_IDS: [], } @@ -373,4 +415,164 @@ def test_options_flow_no_hardcoded_domain_or_db_filename() -> None: ) assert node.value != "nestquest.db", ( f"Hard-coded database filename in {py_file.name}:{node.lineno}" - ) \ No newline at end of file + ) + +# --------------------------------------------------------------------------- +# Admin allowlist picker (Feature 03) +# --------------------------------------------------------------------------- + + +def test_options_flow_picker_lists_existing_users(tmp_path) -> None: + """The picker offers EXISTING HA users (id -> name), never free text.""" + import voluptuous as vol + + database = _open_allowlist_db(tmp_path, "picker-users.db") + try: + from custom_components.nestquest.admin_allowlist import set_admin_ids + + _run(set_admin_ids(database, ["u1"])) + entry = _make_entry() + flow = _make_flow( + entry, + users=[_user("u1", "Joshua"), _user("u2", "Sam")], + database=database, + ) + result = _run(flow.async_step_init(None)) + schema = result["data_schema"] + marker = next( + m for m in schema.schema + if getattr(m, "schema", None) == CONF_ADMIN_USER_IDS + ) + # Default is the CURRENT database allowlist. + values = _schema_values(schema) + assert values[CONF_ADMIN_USER_IDS] == ["u1"] + # Both users are selectable; an unknown id is rejected by the + # schema itself (this is what real HA submits go through). + validated = schema( + { + CONF_HORIZON_DAYS: 7, + CONF_DAY_ROLLOVER_TIME: "03:30", + CONF_PANEL_IDLE_TIMEOUT: 60, + CONF_ADMIN_USER_IDS: ["u1", "u2"], + } + ) + assert validated[CONF_ADMIN_USER_IDS] == ["u1", "u2"] + with pytest.raises(vol.Invalid): + schema( + { + CONF_HORIZON_DAYS: 7, + CONF_DAY_ROLLOVER_TIME: "03:30", + CONF_PANEL_IDLE_TIMEOUT: 60, + CONF_ADMIN_USER_IDS: ["impostor"], + } + ) + finally: + _run(database.close()) + + +def test_options_flow_submit_applies_selection_to_database(tmp_path) -> None: + database = _open_allowlist_db(tmp_path, "picker-apply.db") + try: + from custom_components.nestquest.admin_allowlist import ( + list_admin_ids, + set_admin_ids, + ) + + _run(set_admin_ids(database, ["u1"])) + entry = _make_entry() + flow = _make_flow( + entry, + users=[_user("u1", "Joshua"), _user("u2", "Sam")], + database=database, + ) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_ADMIN_USER_IDS: ["u2"]}) + ) + assert result["type"] == "create_entry" + assert result["data"][CONF_ADMIN_USER_IDS] == ["u2"] + # The allowlist (and the entry's recovery copy) carry the pick. + assert await_gather(list_admin_ids(database)) == ["u2"] + finally: + _run(database.close()) + + +def await_gather(coro): + return _run(coro) + + +def test_options_flow_empty_selection_refused(tmp_path) -> None: + """Saving zero admins is refused: an empty allowlist fails closed.""" + database = _open_allowlist_db(tmp_path, "picker-empty.db") + try: + from custom_components.nestquest.admin_allowlist import ( + list_admin_ids, + set_admin_ids, + ) + + _run(set_admin_ids(database, ["u1"])) + entry = _make_entry() + flow = _make_flow( + entry, + users=[_user("u1", "Joshua")], + database=database, + ) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_ADMIN_USER_IDS: []}) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_ADMIN_USER_IDS: "no_admins"} + assert await_gather(list_admin_ids(database)) == ["u1"] + finally: + _run(database.close()) + + +def test_options_flow_unknown_selection_refused(tmp_path) -> None: + database = _open_allowlist_db(tmp_path, "picker-unknown.db") + try: + from custom_components.nestquest.admin_allowlist import ( + list_admin_ids, + set_admin_ids, + ) + + _run(set_admin_ids(database, ["u1"])) + entry = _make_entry() + flow = _make_flow( + entry, + users=[_user("u1", "Joshua")], + database=database, + ) + result = _run( + flow.async_step_init( + {**VALID_INPUT, CONF_ADMIN_USER_IDS: ["impostor"]} + ) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_ADMIN_USER_IDS: "invalid_admin"} + assert await_gather(list_admin_ids(database)) == ["u1"] + finally: + _run(database.close()) + + +def test_options_flow_submit_without_admin_key_keeps_allowlist(tmp_path) -> None: + """Raw callers omitting the key (pre-picker payloads) leave the + allowlist untouched and produce the legacy three-key entry.""" + database = _open_allowlist_db(tmp_path, "picker-legacy.db") + try: + from custom_components.nestquest.admin_allowlist import ( + list_admin_ids, + set_admin_ids, + ) + + _run(set_admin_ids(database, ["u1"])) + entry = _make_entry() + flow = _make_flow( + entry, + users=[_user("u1", "Joshua")], + database=database, + ) + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + assert CONF_ADMIN_USER_IDS not in result["data"] + assert await_gather(list_admin_ids(database)) == ["u1"] + finally: + _run(database.close()) -- 2.55.0 From d90c17046009233542c61a1edc23f7b9efd77910 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:13:32 -0400 Subject: [PATCH 17/42] Review fixes: persisted flow-user admin seed and real cv.multi_select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review rounds 1-2 (Codex gpt-5.6-terra) found two HIGHs against real HA: - entry.context is not a durable record — the config flow now persists the completing HA user into entry data (CONF_ADMIN_USER_IDS) and the seed precedence becomes: options copy (the current list) first, then the config flow's entry-data copy, then the context user as a last resort. Config-flow tests cover the persisted user and the context-free path; a seed-precedence test covers options > data. - the hand-rolled closure could not serialize as an HA multi-select. The harness now mocks homeassistant.helpers.config_validation with a faithful multi_select, and the flow uses cv.multi_select exactly as real HA code does. Suite: 904 passed. --- custom_components/nestquest/__init__.py | 25 ++++++++++++------ custom_components/nestquest/config_flow.py | 21 +++++++++++++--- custom_components/nestquest/options_flow.py | 28 +++------------------ tests/conftest.py | 28 +++++++++++++++++++++ tests/test_admin_allowlist.py | 24 ++++++++++++++++++ tests/test_config_flow.py | 23 ++++++++++++++++- 6 files changed, 114 insertions(+), 35 deletions(-) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index dbc26e9..688ae8a 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -242,10 +242,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # record without a live connection (a previous setup's # close-on-failure, or a hand-replaced file) must get the # missing/valid/corrupt classification too, never a raw - # sqlite3 error and never skipped migrations. + # sqlite3 error and never skipped migrations — and the + # same allowlist seeding, so a reopened fresh file is + # never ownerless. db = await _async_open_database( hass, await async_get_db_path(hass) ) + stored_admin_ids = ( + entry.options.get(CONF_ADMIN_USER_IDS) + or entry.data.get(CONF_ADMIN_USER_IDS) + ) context = getattr(entry, "context", None) context_user_id = ( context.get("user_id") @@ -254,7 +260,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await seed_setup_admin( db, - stored_admin_ids=entry.data.get(CONF_ADMIN_USER_IDS), + stored_admin_ids=stored_admin_ids, context_user_id=context_user_id, ) existing.database = db @@ -270,10 +276,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: database = await _async_open_database(hass, await async_get_db_path(hass)) try: # Seed the admin allowlist on first setup so the owner is never - # locked out: an empty database takes the config entry's stored - # admin copy (disaster recovery), else the HA user who - # completed the config flow. Non-empty allowlists are never - # touched (fail-closed narrowing is deliberate). + # locked out: an empty database takes the persisted admin copy + # — the options flow's saved list first (it is the current + # one), else the config flow's user from entry data — and only + # then the HA user still present on the entry context. No-op + # on a non-empty list (fail-closed narrowing is deliberate). + stored_admin_ids = ( + entry.options.get(CONF_ADMIN_USER_IDS) + or entry.data.get(CONF_ADMIN_USER_IDS) + ) context = getattr(entry, "context", None) context_user_id = ( context.get("user_id") @@ -282,7 +293,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await seed_setup_admin( database, - stored_admin_ids=entry.data.get(CONF_ADMIN_USER_IDS), + stored_admin_ids=stored_admin_ids, context_user_id=context_user_id, ) remove_update_listener = entry.add_update_listener(_async_update_listener) diff --git a/custom_components/nestquest/config_flow.py b/custom_components/nestquest/config_flow.py index a8ccbc2..803b2f1 100644 --- a/custom_components/nestquest/config_flow.py +++ b/custom_components/nestquest/config_flow.py @@ -7,7 +7,7 @@ from homeassistant import config_entries from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from .const import DOMAIN +from .const import CONF_ADMIN_USER_IDS, DOMAIN class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -28,7 +28,14 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Handle the user step of the config flow.""" + """Handle the user step of the config flow. + + The HA user completing the flow is persisted into the entry + data as the initial admin allowlist copy: real HA does not + guarantee the flow context survives to setup, and entry data is + the persisted record first setup seeds from — the owner is + never locked out. + """ await self.async_set_unique_id(DOMAIN, raise_on_progress=False) if self._async_in_progress() or self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") @@ -36,4 +43,12 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form(step_id="user") - return self.async_create_entry(title="NestQuest", data=user_input) \ No newline at end of file + data = dict(user_input) + flow_user_id = ( + self.context.get("user_id") + if isinstance(self.context, dict) + else None + ) + if flow_user_id: + data[CONF_ADMIN_USER_IDS] = [flow_user_id] + return self.async_create_entry(title="NestQuest", data=data) \ No newline at end of file diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index c106723..2efe70a 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -7,6 +7,7 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv from .const import ( CONF_ADMIN_USER_IDS, @@ -31,29 +32,6 @@ def _parse_time(value: str) -> bool: return _TIME_PATTERN.fullmatch(value) is not None -def _multi_select(choices: dict[str, str]): - """A voluptuous validator for a multi-select of ``choices``. - - The mock-only test harness cannot import HA's config-validation - helpers (they pull in real HA), so this mirrors the shape of - ``homeassistant.helpers.config_validation.multi_select``: accepts a - list of keys from ``choices``, returns the list unchanged, rejects - anything else (None becomes the empty selection). - """ - - def _validate(value): - if value is None: - return [] - if not isinstance(value, list): - raise vol.Invalid("expected a list of selections") - for item in value: - if item not in choices: - raise vol.Invalid(f"{item!r} is not a valid choice") - return list(value) - - return _validate - - def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: """Validate user input and return field-level errors instead of raising.""" errors: dict[str, str] = {} @@ -106,11 +84,13 @@ def _build_schema( ): int, } if admin_choices is not None: + # HA's supported multi-select validator: the frontend can + # serialize the field as a real picker of the given choices. fields[ vol.Optional( CONF_ADMIN_USER_IDS, default=list(admin_default or []) ) - ] = _multi_select(admin_choices) + ] = cv.multi_select(admin_choices) return vol.Schema(fields) diff --git a/tests/conftest.py b/tests/conftest.py index 2e67bfd..dd0fc25 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,6 +42,8 @@ _HA_MODULES = ( "homeassistant.config_entries", "homeassistant.data_entry_flow", "homeassistant.exceptions", + "homeassistant.helpers", + "homeassistant.helpers.config_validation", ) def _find_spec(name: str): @@ -79,6 +81,8 @@ for _parent, _child in ( ("homeassistant", "data_entry_flow"), ("homeassistant", "core"), ("homeassistant", "exceptions"), + ("homeassistant", "helpers"), + ("homeassistant.helpers", "config_validation"), ): setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"]) @@ -181,6 +185,30 @@ class ConfigEntryNotReady(Exception): _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady +import voluptuous as vol + + +def _multi_select(choices: dict): + """Mirror homeassistant.helpers.config_validation.multi_select. + + The options flow's admin picker uses HA's supported multi-select + validator (schema-serializable by HA's frontend); the mock provides + the same contract: a list whose members are keys of ``choices``. + """ + + def _validate(selected): + if not isinstance(selected, list): + raise vol.Invalid("Not a list") + for value in selected: + if value not in choices: + raise vol.Invalid(f"{value} is not a valid option") + return selected + + return _validate + + +_ha_mock("homeassistant.helpers.config_validation").multi_select = _multi_select + # --------------------------------------------------------------------------- # Shared helpers for the behavior tests. diff --git a/tests/test_admin_allowlist.py b/tests/test_admin_allowlist.py index f1ddf81..4ddbf28 100644 --- a/tests/test_admin_allowlist.py +++ b/tests/test_admin_allowlist.py @@ -278,3 +278,27 @@ async def test_setup_does_not_reseed_narrowed_allowlist( finally: await database.close() assert await async_unload_entry(hass, entry_reloaded) is True + + +async def test_setup_seed_prefers_options_over_entry_data( + hass, make_entry +) -> None: + """Seed precedence on a fresh database: the options flow's saved + list (the current one) beats the config flow's original copy.""" + from custom_components.nestquest.const import CONF_ADMIN_USER_IDS + + entry = await _setup_entry( + hass, + make_entry( + options={CONF_ADMIN_USER_IDS: ["user-current"]}, + data={CONF_ADMIN_USER_IDS: ["ha-owner"]}, + context={"user_id": "ha-owner"}, + ), + hass.registry, + ) + database = entry.runtime_data.database + try: + assert await list_admin_ids(database) == ["user-current"] + finally: + await database.close() + assert await async_unload_entry(hass, entry) is True diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index b744bf4..246e59b 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -154,4 +154,25 @@ def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None: ) assert node.value != "nestquest.db", ( f"Found hard-coded database filename in {py_file.name}:{node.lineno}" - ) \ No newline at end of file + ) + +async def test_flow_persists_flow_user_as_initial_admin(hass, make_flow) -> None: + """The HA user completing the flow lands in entry data as the + initial admin copy: entry.context is not persisted by real HA, so + the durable record must carry the owner.""" + from custom_components.nestquest.const import CONF_ADMIN_USER_IDS + + flow = make_flow(hass) + flow.context = {"user_id": "ha-owner"} + result = await flow.async_step_user({}) + assert result["type"] == "create_entry" + assert result["data"] == {CONF_ADMIN_USER_IDS: ["ha-owner"]} + + +async def test_flow_without_context_user_keeps_data_untouched( + hass, make_flow +) -> None: + flow = make_flow(hass) + result = await flow.async_step_user({"horizon_days": 7}) + assert result["type"] == "create_entry" + assert result["data"] == {"horizon_days": 7} -- 2.55.0 From f0fa39660c7a337d8d775871abe4cd0d66e98b2c Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:16:10 -0400 Subject: [PATCH 18/42] Review fix: duplicate admin selections surface as a field error Review round 3 (Codex gpt-5.6-terra, LOW): a duplicate raw submission reached set_admin_ids and crashed the flow with an unhandled ValueError; the flow now rejects duplicates as invalid_admin with a test proving the database is untouched. Suite: 905 passed. --- custom_components/nestquest/options_flow.py | 5 ++++ tests/test_options_flow.py | 29 +++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index 2efe70a..9ab8214 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -184,6 +184,11 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): # backs the raw-input path up so an unknown id can never # reach the allowlist. errors[CONF_ADMIN_USER_IDS] = "invalid_admin" + elif admin_ids_present and len(set(admin_ids)) != len(admin_ids): + # set_admin_ids refuses duplicates with ValueError; a flow + # submission must surface that as a field error instead of + # an unhandled crash. + errors[CONF_ADMIN_USER_IDS] = "invalid_admin" if errors: return self.async_show_form( step_id="init", diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index b93b4f2..0830b5c 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -576,3 +576,32 @@ def test_options_flow_submit_without_admin_key_keeps_allowlist(tmp_path) -> None assert await_gather(list_admin_ids(database)) == ["u1"] finally: _run(database.close()) + + +def test_options_flow_duplicate_selection_refused(tmp_path) -> None: + """Duplicate ids surface as a field error, not an unhandled + ValueError from the business layer.""" + database = _open_allowlist_db(tmp_path, "picker-dupe.db") + try: + from custom_components.nestquest.admin_allowlist import ( + list_admin_ids, + set_admin_ids, + ) + + _run(set_admin_ids(database, ["u1"])) + entry = _make_entry() + flow = _make_flow( + entry, + users=[_user("u1", "Joshua"), _user("u2", "Sam")], + database=database, + ) + result = _run( + flow.async_step_init( + {**VALID_INPUT, CONF_ADMIN_USER_IDS: ["u2", "u2"]} + ) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_ADMIN_USER_IDS: "invalid_admin"} + assert await_gather(list_admin_ids(database)) == ["u1"] + finally: + _run(database.close()) -- 2.55.0 From 068ace5693af7a689368718d9ccb967ba64be277 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:22:39 -0400 Subject: [PATCH 19/42] Review fix: owner-account fallback seed for first setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 4 (Codex gpt-5.6-terra, HIGH): relying on the flow context alone can leave first setup ownerless. seed_setup_admin gains an owner_ids tier: when no persisted copy and no context user exist, every HA owner account is seeded (owners can already do everything in HA — no privilege escalation). __init__ resolves owners via hass.auth.async_get_users on both setup paths; conftest's hass gains the auth surface. Tests: owner fallback seeds only owners, context user still wins over owners, owner_ids argument validation. Suite: 910 passed. --- custom_components/nestquest/__init__.py | 25 +++++++ .../nestquest/admin_allowlist.py | 21 ++++-- tests/conftest.py | 6 +- tests/test_admin_allowlist.py | 70 +++++++++++++++++++ 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 688ae8a..d69e2d5 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -226,6 +226,28 @@ class NestQuestRuntimeData: remove_update_listener: Callable[[], Any] +async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]: + """Return every Home Assistant owner-account user id. + + The last-resort admin seed: when no persisted allowlist copy and no + flow-context user survive to first setup, the HA owner accounts are + seeded so the household is never left with zero admins (an empty + allowlist fails closed, which would lock the household out of its + own integration). Owner accounts can already do everything in HA, + so granting NestQuest admin is not a privilege escalation. + """ + auth = getattr(hass, "auth", None) + if auth is None: + return [] + users = await auth.async_get_users() + owners = [ + user.id + for user in users + if getattr(user, "is_owner", False) and getattr(user, "id", None) + ] + return owners + + async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: """Set up the NestQuest integration. YAML configuration is not used, returns True.""" return True @@ -262,6 +284,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: db, stored_admin_ids=stored_admin_ids, context_user_id=context_user_id, + owner_ids=await _async_owner_user_ids(hass), ) existing.database = db entry.runtime_data = existing @@ -291,10 +314,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if isinstance(context, dict) else None ) + owner_ids = await _async_owner_user_ids(hass) await seed_setup_admin( database, stored_admin_ids=stored_admin_ids, context_user_id=context_user_id, + owner_ids=owner_ids, ) remove_update_listener = entry.add_update_listener(_async_update_listener) except BaseException: diff --git a/custom_components/nestquest/admin_allowlist.py b/custom_components/nestquest/admin_allowlist.py index 6c4b3dc..a672d2e 100644 --- a/custom_components/nestquest/admin_allowlist.py +++ b/custom_components/nestquest/admin_allowlist.py @@ -96,15 +96,20 @@ async def seed_setup_admin( *, stored_admin_ids: list[str] | None = None, context_user_id: str | None = None, + owner_ids: list[str] | None = None, ) -> list[str]: """Seed an empty allowlist so the owner is never locked out. Only acts when the allowlist is EMPTY. Preference order: the - stored entry-data copy of the list (disaster recovery for a lost - database), then the HA user who completed the config flow - (``context_user_id``). Returns the allowlist after seeding. - Non-empty allowlists are returned untouched — re-running setup - must never resurrect a deliberately narrowed list. + stored persisted copy of the list (the options flow's current + selection, then the config flow's original — disaster recovery for + a lost database), then the HA user who completed the config flow + (``context_user_id``), then — when no caller identity survived at + all — every Home Assistant OWNER account (``owner_ids``), so a + first setup whose flow context lost the user id still seeds a + working admin. Returns the allowlist after seeding. Non-empty + allowlists are returned untouched — re-running setup must never + resurrect a deliberately narrowed list. """ dao = AdminUsersDao(database) async with _connection_lock(database): @@ -120,6 +125,12 @@ async def seed_setup_admin( candidates = [_validate_user_id(value) for value in stored_admin_ids] elif context_user_id is not None: candidates = [_validate_user_id(context_user_id)] + elif owner_ids: + if not isinstance(owner_ids, list): + raise ValueError( + "owner_ids must be a list of HA user id strings" + ) + candidates = [_validate_user_id(value) for value in owner_ids] for user_id in candidates: await dao.add(user_id, _now_stamp()) return [row.ha_user_id for row in await dao.list()] diff --git a/tests/conftest.py b/tests/conftest.py index dd0fc25..5694471 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,7 +29,7 @@ import tempfile import weakref from pathlib import Path from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -281,6 +281,10 @@ def make_hass() -> tuple: config=MagicMock(), config_entries=SimpleNamespace(async_reload=None), ) + # hass.auth mirrors the real surface the integration reads: an + # async_get_users list the tests can repoint at will (the setup + # path resolves HA owner accounts as the last-resort admin seed). + hass.auth = SimpleNamespace(async_get_users=AsyncMock(return_value=[])) def _cleanup_config_dir(): import shutil diff --git a/tests/test_admin_allowlist.py b/tests/test_admin_allowlist.py index 4ddbf28..c371815 100644 --- a/tests/test_admin_allowlist.py +++ b/tests/test_admin_allowlist.py @@ -302,3 +302,73 @@ async def test_setup_seed_prefers_options_over_entry_data( finally: await database.close() assert await async_unload_entry(hass, entry) is True + + +async def test_setup_owner_fallback_seeds_owner_accounts( + hass, make_entry +) -> None: + """Last-resort seed: with no persisted copy and no flow-context + user, HA owner accounts are seeded so first setup is never + ownerless (the real flow may lose the context; owners can already + do everything in HA).""" + from unittest.mock import AsyncMock + + hass.auth.async_get_users = AsyncMock( + return_value=[ + SimpleNamespace(id="owner-1", is_owner=True), + SimpleNamespace(id="member-1", is_owner=False), + SimpleNamespace(id="owner-2", is_owner=True), + ] + ) + entry = await _setup_entry(hass, make_entry(), hass.registry) + database = entry.runtime_data.database + try: + assert sorted(await list_admin_ids(database)) == ["owner-1", "owner-2"] + finally: + await database.close() + assert await async_unload_entry(hass, entry) is True + + +async def test_setup_owner_fallback_not_used_when_context_present( + hass, make_entry +) -> None: + """The context user (flow caller) wins over the owner fallback: + a non-owner parent configuring the integration becomes the admin.""" + from unittest.mock import AsyncMock + + hass.auth.async_get_users = AsyncMock( + return_value=[SimpleNamespace(id="owner-1", is_owner=True)] + ) + entry = await _setup_entry( + hass, + make_entry(context={"user_id": "parent-configured"}), + hass.registry, + ) + database = entry.runtime_data.database + try: + assert await list_admin_ids(database) == ["parent-configured"] + finally: + await database.close() + assert await async_unload_entry(hass, entry) is True + + +def test_seed_owner_ids_argument(tmp_path) -> None: + async def _body(database): + seeded = await seed_setup_admin( + database, + stored_admin_ids=None, + context_user_id=None, + owner_ids=["owner-1"], + ) + assert seeded == ["owner-1"] + # Malformed owner lists are rejected like any other candidate. + with pytest.raises(ValueError): + await seed_setup_admin( + database, + stored_admin_ids=None, + context_user_id=None, + owner_ids=[7], + ) + return seeded + + _with_db(tmp_path, "seed-owner-arg.db")(_body) -- 2.55.0 From 7ebd893823b48a0a8546513745c6689f92fdec55 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:23:32 -0400 Subject: [PATCH 20/42] Test fix: malformed owner_ids validated before the seed's early return The seed validates candidates only when the allowlist is empty; the test's successful first seed masked the malformed case. The rejection now runs first. Suite: 908 passed. --- tests/test_admin_allowlist.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/test_admin_allowlist.py b/tests/test_admin_allowlist.py index c371815..7b4def1 100644 --- a/tests/test_admin_allowlist.py +++ b/tests/test_admin_allowlist.py @@ -354,14 +354,9 @@ async def test_setup_owner_fallback_not_used_when_context_present( def test_seed_owner_ids_argument(tmp_path) -> None: async def _body(database): - seeded = await seed_setup_admin( - database, - stored_admin_ids=None, - context_user_id=None, - owner_ids=["owner-1"], - ) - assert seeded == ["owner-1"] - # Malformed owner lists are rejected like any other candidate. + # Malformed owner lists are rejected like any other candidate — + # while the allowlist is still empty, so the seed proceeds far + # enough to validate. with pytest.raises(ValueError): await seed_setup_admin( database, @@ -369,6 +364,14 @@ def test_seed_owner_ids_argument(tmp_path) -> None: context_user_id=None, owner_ids=[7], ) + assert await list_admin_ids(database) == [] + seeded = await seed_setup_admin( + database, + stored_admin_ids=None, + context_user_id=None, + owner_ids=["owner-1"], + ) + assert seeded == ["owner-1"] return seeded _with_db(tmp_path, "seed-owner-arg.db")(_body) -- 2.55.0 From 0e4f890ef93773b2a073b5f6d1381a6f4c4f7cc1 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:26:53 -0400 Subject: [PATCH 21/42] Review fix: reopened-record setup closes the database when seeding fails Review round 5 (Codex gpt-5.6-terra, MEDIUM): the reopened-runtime setup path leaked the just-opened connection if owner lookup or seed_setup_admin raised, so HA's setup retries could accumulate open handles. Seeding on that path is now wrapped symmetrically with the first-setup path: any failure closes the database before re-raising, with a test that fails seeding on a re-setup and asserts the close. Suite: 909 passed. --- custom_components/nestquest/__init__.py | 40 +++++++++++++++---------- tests/test_admin_allowlist.py | 40 +++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 16 deletions(-) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index d69e2d5..91a962d 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -270,22 +270,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: db = await _async_open_database( hass, await async_get_db_path(hass) ) - stored_admin_ids = ( - entry.options.get(CONF_ADMIN_USER_IDS) - or entry.data.get(CONF_ADMIN_USER_IDS) - ) - context = getattr(entry, "context", None) - context_user_id = ( - context.get("user_id") - if isinstance(context, dict) - else None - ) - await seed_setup_admin( - db, - stored_admin_ids=stored_admin_ids, - context_user_id=context_user_id, - owner_ids=await _async_owner_user_ids(hass), - ) + try: + stored_admin_ids = ( + entry.options.get(CONF_ADMIN_USER_IDS) + or entry.data.get(CONF_ADMIN_USER_IDS) + ) + context = getattr(entry, "context", None) + context_user_id = ( + context.get("user_id") + if isinstance(context, dict) + else None + ) + await seed_setup_admin( + db, + stored_admin_ids=stored_admin_ids, + context_user_id=context_user_id, + owner_ids=await _async_owner_user_ids(hass), + ) + except BaseException: + # A seeding failure must not leak the just-opened + # connection: close it before the error propagates, so + # HA's setup retry starts from a clean handle (the + # first-setup path closes symmetrically). + await db.close() + raise existing.database = db entry.runtime_data = existing return True diff --git a/tests/test_admin_allowlist.py b/tests/test_admin_allowlist.py index 7b4def1..b5d15eb 100644 --- a/tests/test_admin_allowlist.py +++ b/tests/test_admin_allowlist.py @@ -375,3 +375,43 @@ def test_seed_owner_ids_argument(tmp_path) -> None: return seeded _with_db(tmp_path, "seed-owner-arg.db")(_body) + + +async def test_reopened_setup_failure_closes_database( + hass, make_entry, monkeypatch +) -> None: + """A seeding failure on the reopened-record path closes the just- + opened connection before re-raising (no leaked handles across HA's + setup retries).""" + import pytest as pytest_module + from unittest.mock import AsyncMock + + import custom_components.nestquest as nestquest_module + from custom_components.nestquest.db import NestQuestDatabase + + # First: a successful setup to create the runtime record and file. + entry = await _setup_entry(hass, make_entry(), hass.registry) + await async_unload_entry(hass, entry) + + # Now a re-setup whose seeding fails after the reopen. + async def _failing_seed(database, **kwargs): + raise RuntimeError("seed boom") + + monkeypatch.setattr( + nestquest_module, "seed_setup_admin", _failing_seed, 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) + + with pytest_module.raises(RuntimeError, match="seed boom"): + await _setup_entry(hass, entry, hass.registry) + # The reopen path opened AND closed a connection; the failure is + # the last thing observed. + assert close_calls[-1] is True -- 2.55.0 From 913d3658b8b30e1c78c7a6caa073c8de6f593faf Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:30:29 -0400 Subject: [PATCH 22/42] Review fix: omitted picker submissions carry the allowlist forward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 6 (Codex gpt-5.6-terra, MEDIUM): HA replaces entry.options wholesale, so a submission omitting the picker dropped the allowlist copy and left only the stale config-flow copy in entry data — a later database loss would resurrect removed admins. Omitted submissions now carry the current DB allowlist forward on the options copy. The legacy-path test updated to assert the carried copy. Suite: 910 passed. --- custom_components/nestquest/options_flow.py | 7 ++++ tests/test_options_flow.py | 36 +++++++++++++++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index 9ab8214..84ac218 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -213,6 +213,13 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): } if admin_ids_present: data[CONF_ADMIN_USER_IDS] = admin_ids + elif admin_default: + # HA replaces entry.options wholesale, so an omitted picker + # field must carry the CURRENT allowlist forward: dropping + # it would leave only the stale config-flow copy in entry + # data, and a later database loss would resurrect admins + # the owner has since removed. + data[CONF_ADMIN_USER_IDS] = list(admin_default) return self.async_create_entry(title="NestQuest", data=data) def _current_values(self) -> dict[str, Any]: diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index 0830b5c..bef04c8 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -554,8 +554,9 @@ def test_options_flow_unknown_selection_refused(tmp_path) -> None: def test_options_flow_submit_without_admin_key_keeps_allowlist(tmp_path) -> None: - """Raw callers omitting the key (pre-picker payloads) leave the - allowlist untouched and produce the legacy three-key entry.""" + """Raw callers omitting the key (pre-picker payloads) do not + touch the allowlist; the CURRENT allowlist is carried forward on + the options copy so no removed admin can be resurrected later.""" database = _open_allowlist_db(tmp_path, "picker-legacy.db") try: from custom_components.nestquest.admin_allowlist import ( @@ -572,7 +573,7 @@ def test_options_flow_submit_without_admin_key_keeps_allowlist(tmp_path) -> None ) result = _run(flow.async_step_init(dict(VALID_INPUT))) assert result["type"] == "create_entry" - assert CONF_ADMIN_USER_IDS not in result["data"] + assert result["data"][CONF_ADMIN_USER_IDS] == ["u1"] assert await_gather(list_admin_ids(database)) == ["u1"] finally: _run(database.close()) @@ -605,3 +606,32 @@ def test_options_flow_duplicate_selection_refused(tmp_path) -> None: assert await_gather(list_admin_ids(database)) == ["u1"] finally: _run(database.close()) + + +def test_options_flow_omitted_picker_carries_allowlist_forward( + tmp_path, +) -> None: + """An omitted picker field must not drop the allowlist copy from + the returned options: HA replaces entry.options wholesale, and a + later database loss must not resurrect removed admins from the + stale config-flow copy in entry.data.""" + database = _open_allowlist_db(tmp_path, "picker-carry.db") + try: + from custom_components.nestquest.admin_allowlist import ( + set_admin_ids, + ) + + _run(set_admin_ids(database, ["u2"])) + entry = _make_entry() + flow = _make_flow( + entry, + users=[_user("u1", "Joshua"), _user("u2", "Sam")], + database=database, + ) + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + # The current allowlist rides along on the legacy three-key path. + assert result["data"][CONF_ADMIN_USER_IDS] == ["u2"] + assert result["data"][CONF_HORIZON_DAYS] == VALID_INPUT[CONF_HORIZON_DAYS] + finally: + _run(database.close()) -- 2.55.0 From 7cde256e2afaf73d5238ad1236f25a6b65cabe5a Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:35:13 -0400 Subject: [PATCH 23/42] Fail-closed is_admin resolver (Feature 03, task 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin_allowlist.is_admin: True only for an allowlisted HA user id; False for non-allowlisted, unknown, empty-string, untrimmed variants, None, non-strings (bools, lists), and an empty allowlist (nobody is admin rather than everybody). Never raises for malformed input — malformed means not admin; DB errors propagate for the Feature 09 gate to handle. The last-admin removal refusal (set_admin_ids) is re-asserted as keeping the verdict sound. Tests: full verdict matrix, empty-allowlist fail-closed, refusal persistence. Suite: 912 passed. --- .../nestquest/admin_allowlist.py | 17 ++++++ tests/test_admin_allowlist.py | 54 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/custom_components/nestquest/admin_allowlist.py b/custom_components/nestquest/admin_allowlist.py index a672d2e..beed12c 100644 --- a/custom_components/nestquest/admin_allowlist.py +++ b/custom_components/nestquest/admin_allowlist.py @@ -91,6 +91,23 @@ async def set_admin_ids( await dao.add(user_id, _now_stamp()) +async def is_admin( + database: NestQuestDatabase, user_id: object +) -> bool: + """Resolve an HA user ID against the allowlist — FAILS CLOSED. + + True only for an allowlisted user id. Everything else is False: + a non-allowlisted id, an unknown id, a None or non-string value + (a service call with no user context), and an empty allowlist + (nobody is admin rather than everybody). The Feature 09 gate + wraps every admin-only service with this verdict; it never raises + for malformed input — malformed means not admin. + """ + if not isinstance(user_id, str) or not user_id: + return False + return await AdminUsersDao(database).exists(user_id) + + async def seed_setup_admin( database: NestQuestDatabase, *, diff --git a/tests/test_admin_allowlist.py b/tests/test_admin_allowlist.py index b5d15eb..5640d2d 100644 --- a/tests/test_admin_allowlist.py +++ b/tests/test_admin_allowlist.py @@ -415,3 +415,57 @@ async def test_reopened_setup_failure_closes_database( # The reopen path opened AND closed a connection; the failure is # the last thing observed. assert close_calls[-1] is True + + +# --------------------------------------------------------------------------- +# is_admin: the fail-closed resolver (Feature 09's verdict function) +# --------------------------------------------------------------------------- + + +def test_is_admin_verdict_matrix(tmp_path) -> None: + async def _body(database): + from custom_components.nestquest.admin_allowlist import is_admin + + await set_admin_ids(database, ["user-owner"]) + assert await is_admin(database, "user-owner") is True + assert await is_admin(database, "user-other") is False + # Unknown, malformed, and absent identities all fail closed. + assert await is_admin(database, "") is False + assert await is_admin(database, " user-owner ") is False, ( + "ids are stored trimmed; an untrimmed lookup is a different " + "id and must not match" + ) + assert await is_admin(database, None) is False + assert await is_admin(database, 42) is False + assert await is_admin(database, ["user-owner"]) is False + assert await is_admin(database, True) is False + return None + + _with_db(tmp_path, "resolver-matrix.db")(_body) + + +def test_is_admin_empty_allowlist_fails_closed(tmp_path) -> None: + """An empty allowlist returns False for EVERYONE, not True.""" + async def _body(database): + from custom_components.nestquest.admin_allowlist import is_admin + + for candidate in ("user-owner", "", None, 1): + assert await is_admin(database, candidate) is False + return None + + _with_db(tmp_path, "resolver-empty.db")(_body) + + +def test_is_admin_false_after_last_admin_removal_attempt(tmp_path) -> None: + """The refusal keeps the verdict sound: a refused removal leaves + the admin in place, so is_admin still resolves True.""" + async def _body(database): + from custom_components.nestquest.admin_allowlist import is_admin + + await set_admin_ids(database, ["user-owner"]) + with pytest.raises(ValueError, match="at least one admin"): + await set_admin_ids(database, []) + assert await is_admin(database, "user-owner") is True + return None + + _with_db(tmp_path, "resolver-refusal.db")(_body) -- 2.55.0 From d76cde1af5282acb93907df7d87f3a22c78ee539 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:43:09 -0400 Subject: [PATCH 24/42] Presence and custody model with total validation (Feature 05, task 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit presence.py — a PURE module (no HA imports, no DB access, no timezone logic): PresenceSchedule (child_id, cycle_length_weeks, anchor_date, pattern mapping week index -> frozenset of present weekdays) and PresenceOverride (child_id, inclusive start/end range, real-bool is_present, optional note) with construction that rejects every invalid shape up front: - cycle length: integer 1..4 (the 4 cap mirrors schema.py's documented CHECK); bools/floats/strings rejected. - pattern: dict required; week indices validated as non-bool ints covering 0..n-1 exactly (gaps and extras rejected); weekday sets validated as ints 0..6 (bools rejected — True would silently mean Monday; strings rejected — '02' would silently mean {0,2}); an explicitly empty week is valid (absent all week — distinct from no schedule, the engine's default). - anchor/override dates: datetime.date or strict YYYY-MM-DD (strptime round-trip; '2026-1-5' rejected). - override range: end >= start; is_present must be a real bool; note optional string. - child ids: non-bool ints (SQLite binds True as 1). Serialization: encode() emits the database's pipe-separated CSV (sorted weekdays per week, empty segment for an absent week); decode() derives the cycle length from the segment count (mirroring the schema's segment-count CHECK) and validates strictly, so decode(encode(x)) round-trips losslessly and equal patterns encode identically. Tests: 59 (construction matrix, round-trips, encode shapes, override coverage). Suite: 972 passed. --- custom_components/nestquest/presence.py | 283 ++++++++++++++++++++++++ tests/test_presence.py | 229 +++++++++++++++++++ 2 files changed, 512 insertions(+) create mode 100644 custom_components/nestquest/presence.py create mode 100644 tests/test_presence.py diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py new file mode 100644 index 0000000..88e6f10 --- /dev/null +++ b/custom_components/nestquest/presence.py @@ -0,0 +1,283 @@ +"""The presence and custody model (Feature 05). + +PURE module: no Home Assistant imports, no database access, no +timezone logic — it operates on plain calendar dates only (feature +guardrail). The later engine (cycle position, ``is_present``, +overrides, previews) builds directly on this model. + +A :class:`PresenceSchedule` answers "on which weekdays of which cycle +week is this child at this house?" for an N-week repeating custody +pattern. Two of the household's three children follow a two-week +rotation; a child with NO schedule row is present every day (that +default lives in the engine, not here). + +Pattern encoding: the model's canonical serialization is the SAME +pipe-separated CSV the database stores (see schema.py's +``presence_schedules.pattern``): segment ``i`` (week index ``i``) is a +comma-separated list of present weekdays, 0 = Monday .. 6 = Sunday, +e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in week 1. An +empty segment means absent every day of that week. Encoding sorts +each segment's weekdays and uses the exact segment order, so +``decode(encode(schedule))`` round-trips losslessly and two schedules +with equal patterns produce equal strings. + +Anchor-date policy: ``anchor_date`` pins the cycle. Cycle position is +computed from anchor-date arithmetic ONLY — never ISO week numbers or +week parity (D-004): 53-week years silently invert parity on January +1st. The arithmetic lives in the engine task; this module validates +that the anchor is a strict calendar date. + +Validation policy: construction is total — every reject happens in +``__init__`` so no invalid schedule can ever exist: +- ``cycle_length_weeks`` must be an integer 1..4. The 4 cap matches + the database CHECK (schema.py) and keeps anchor arithmetic and the + encoded pattern human-readable; revisit both together if a longer + cycle is ever needed. +- ``pattern`` must cover week indices 0..cycle_length_weeks-1 exactly + (no gaps, no extra weeks) with sets of integers 0..6 (bools + rejected: True would silently mean Monday). +- ``anchor_date`` must be a strict ISO YYYY-MM-DD calendar date (or a + ``datetime.date``). +""" +from __future__ import annotations + +import datetime +from dataclasses import dataclass + +#: The schema's documented cap for cycle_length_weeks (schema.py keeps +#: the pattern human-readable and the anchor arithmetic small). +MAX_CYCLE_LENGTH_WEEKS = 4 + +_DATE_FORMAT = "%Y-%m-%d" + + +def _parse_date(value: object, field_name: str) -> datetime.date: + """Accept a datetime.date or a strict YYYY-MM-DD string.""" + if isinstance(value, datetime.date): + return value + if not isinstance(value, str): + raise ValueError( + f"{field_name} must be a datetime.date or an ISO date string, " + f"got {value!r}" + ) + try: + parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date() + except (TypeError, ValueError): + raise ValueError( + f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}" + ) from None + if parsed.isoformat() != value: + raise ValueError( + f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}" + ) + return parsed + + +def _validate_week_index(week: object, cycle_length_weeks: int) -> int: + """Reject non-int week indices (bools included) and out-of-cycle.""" + if isinstance(week, bool) or not isinstance(week, int): + raise ValueError( + f"pattern week index must be an integer, got {week!r}" + ) + if not 0 <= week < cycle_length_weeks: + raise ValueError( + f"pattern week index must be in 0..{cycle_length_weeks - 1}, " + f"got {week}" + ) + return week + + +def _validate_weekday_set(week: int, weekdays: object) -> frozenset[int]: + """Validate one week's present-weekday set.""" + if isinstance(weekdays, (str, bytes)) or weekdays is None: + raise ValueError( + f"pattern[{week}] must be an iterable of weekday ints 0..6, " + f"got {weekdays!r}" + ) + try: + members = list(weekdays) + except TypeError: + raise ValueError( + f"pattern[{week}] must be an iterable of weekday ints 0..6, " + f"got {weekdays!r}" + ) from None + for member in members: + if isinstance(member, bool) or not isinstance(member, int): + raise ValueError( + f"pattern[{week}] weekday must be an int 0..6, got {member!r}" + ) + if not 0 <= member <= 6: + raise ValueError( + f"pattern[{week}] weekday must be in 0..6 (Monday=0), " + f"got {member}" + ) + return frozenset(members) + + +def _validate_cycle_length(value: object) -> int: + """Reject non-int and out-of-range cycle lengths (bools included).""" + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError( + f"cycle_length_weeks must be an integer, got {value!r}" + ) + if not 1 <= value <= MAX_CYCLE_LENGTH_WEEKS: + raise ValueError( + f"cycle_length_weeks must be between 1 and " + f"{MAX_CYCLE_LENGTH_WEEKS}, got {value}" + ) + return value + + +def _validate_child_id(value: object) -> int: + """Reject non-int child ids (bools would silently address child 1).""" + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"child_id must be an integer, got {value!r}") + return value + + +@dataclass(frozen=True, init=False) +class PresenceSchedule: + """One child's N-week repeating presence pattern. + + ``pattern`` maps each week index of the cycle (``0`` .. ``n-1``) + to the set of present weekdays (ints, Monday=0). A week with an + empty set means the child is absent every day of that week — which + is distinct from having no schedule at all (the engine treats a + missing schedule as present every day). + """ + + child_id: int + cycle_length_weeks: int + anchor_date: datetime.date + pattern: dict[int, frozenset[int]] + + def __init__( + self, + child_id: int, + cycle_length_weeks: int, + anchor_date: object, + pattern: dict[int, object], + ) -> None: + object.__setattr__(self, "child_id", _validate_child_id(child_id)) + cycle = _validate_cycle_length(cycle_length_weeks) + object.__setattr__(self, "cycle_length_weeks", cycle) + object.__setattr__( + self, "anchor_date", _parse_date(anchor_date, "anchor_date") + ) + if not isinstance(pattern, dict): + raise ValueError( + "pattern must map week index to a set of present " + f"weekdays, got {pattern!r}" + ) + normalised: dict[int, frozenset[int]] = {} + for week, weekdays in pattern.items(): + index = _validate_week_index(week, cycle) + normalised[index] = _validate_weekday_set(index, weekdays) + missing = [ + week for week in range(cycle) if week not in normalised + ] + if missing: + raise ValueError( + f"pattern must cover week indices 0..{cycle - 1}; " + f"missing {missing}" + ) + object.__setattr__(self, "pattern", normalised) + + def encode(self) -> str: + """Serialize to the database's pipe-separated CSV shape. + + Segments appear in week order; each segment is the week's + present weekdays as a sorted comma-separated list (an empty set + encodes as an empty segment). Two schedules with equal + patterns encode identically. + """ + segments = [ + ",".join(str(day) for day in sorted(self.pattern[week])) + for week in range(self.cycle_length_weeks) + ] + return "|".join(segments) + + @classmethod + def decode( + cls, + child_id: int, + anchor_date: object, + encoded: str, + ) -> "PresenceSchedule": + """Build a schedule from its encoded pattern. + + The cycle length is derived from the segment count, mirroring + the database's segment-count CHECK. Decoding is strict: wrong + shapes, non-numeric or out-of-range weekdays are rejected — + the database CHECKs make such rows unrepresentable, and this + is the same contract on the model side. A segment count above + the documented cycle cap is rejected by the ordinary + construction validation. + """ + if not isinstance(encoded, str): + raise ValueError( + f"encoded pattern must be a string, got {encoded!r}" + ) + segments = encoded.split("|") + pattern: dict[int, set[int]] = {} + for week, segment in enumerate(segments): + weekdays: set[int] = set() + if segment: + for part in segment.split(","): + if len(part) != 1 or part not in "0123456": + raise ValueError( + f"encoded segment {week} carries an invalid " + f"weekday {part!r} (single digits 0-6 only)" + ) + weekdays.add(int(part)) + pattern[week] = weekdays + return cls(child_id, len(segments), anchor_date, pattern) + + +@dataclass(frozen=True, init=False) +class PresenceOverride: + """A date-specific present/absent flag beating any pattern. + + Covers a single date (``end_date == start_date``) or an inclusive + range. A concrete range is the storage shape + (``presence_overrides.end_date`` is NOT NULL), so the model + requires both bounds and validates start <= end. Construction and + validation live here so the engine can trust every instance. + """ + + child_id: int + start_date: datetime.date + end_date: datetime.date + is_present: bool + note: str | None + + def __init__( + self, + child_id: int, + start_date: object, + end_date: object, + is_present: bool, + note: str | None = None, + ) -> None: + object.__setattr__(self, "child_id", _validate_child_id(child_id)) + start = _parse_date(start_date, "start_date") + end = _parse_date(end_date, "end_date") + if end < start: + raise ValueError( + f"end_date must be on or after start_date, got " + f"{end.isoformat()} < {start.isoformat()}" + ) + object.__setattr__(self, "start_date", start) + object.__setattr__(self, "end_date", end) + if not isinstance(is_present, bool): + raise ValueError( + f"is_present must be a real bool, got {is_present!r}" + ) + object.__setattr__(self, "is_present", is_present) + if note is not None and not isinstance(note, str): + raise ValueError(f"note must be a string or None, got {note!r}") + object.__setattr__(self, "note", note) + + def covers(self, date: datetime.date) -> bool: + """Return True when the override applies to ``date``.""" + return self.start_date <= date <= self.end_date diff --git a/tests/test_presence.py b/tests/test_presence.py new file mode 100644 index 0000000..1c8e44d --- /dev/null +++ b/tests/test_presence.py @@ -0,0 +1,229 @@ +"""Tests for presence.py: the Feature 05 custody model.""" +from __future__ import annotations + +import datetime + +import pytest + +from custom_components.nestquest.presence import ( + MAX_CYCLE_LENGTH_WEEKS, + PresenceOverride, + PresenceSchedule, +) + + +ANCHOR = datetime.date(2026, 1, 5) # a Monday + +TWO_WEEK_PATTERN = {0: {0, 2, 4}, 1: {1, 3}} + + +# --------------------------------------------------------------------------- +# PresenceSchedule construction +# --------------------------------------------------------------------------- + + +def test_schedule_holds_validated_fields() -> None: + schedule = PresenceSchedule( + 3, 2, "2026-01-05", {0: {0, 2, 4}, 1: {1, 3}} + ) + assert schedule.child_id == 3 + assert schedule.cycle_length_weeks == 2 + assert schedule.anchor_date == ANCHOR + assert schedule.pattern == {0: frozenset({0, 2, 4}), 1: frozenset({1, 3})} + + +def test_schedule_accepts_date_objects() -> None: + schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}}) + assert schedule.anchor_date is ANCHOR + + +def test_schedule_rejects_cycle_length_below_one() -> None: + with pytest.raises(ValueError, match="between 1 and"): + PresenceSchedule(1, 0, ANCHOR, {}) + with pytest.raises(ValueError, match="between 1 and"): + PresenceSchedule(1, -1, ANCHOR, {}) + + +def test_schedule_rejects_cycle_length_above_documented_cap() -> None: + """The 4 cap matches the schema's CHECK — both documents agree.""" + with pytest.raises(ValueError, match="between 1 and"): + PresenceSchedule(1, MAX_CYCLE_LENGTH_WEEKS + 1, ANCHOR, {}) + assert MAX_CYCLE_LENGTH_WEEKS == 4 + + +@pytest.mark.parametrize("bad", [True, "2", 1.0, None]) +def test_schedule_rejects_non_int_cycle_length(bad) -> None: + with pytest.raises(ValueError, match="cycle_length_weeks must be an integer"): + PresenceSchedule(1, bad, ANCHOR, {0: {0}}) + + +def test_schedule_rejects_pattern_missing_week_indices() -> None: + with pytest.raises(ValueError, match="missing \\[1\\]"): + PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}}) + + +def test_schedule_rejects_pattern_with_extra_week_indices() -> None: + with pytest.raises(ValueError, match="week index must be in 0"): + PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}, 2: {2}}) + + +def test_schedule_rejects_pattern_with_non_int_week_keys() -> None: + with pytest.raises(ValueError, match="week index must be an integer"): + PresenceSchedule(1, 1, ANCHOR, {"0": {0}}) + + +def test_schedule_rejects_pattern_with_bool_week_key() -> None: + """True would silently be week 1; bools are never valid indices.""" + with pytest.raises(ValueError, match="week index must be an integer"): + PresenceSchedule(1, 2, ANCHOR, {0: {0}, True: {1}}) + + +@pytest.mark.parametrize("bad_weekday", [-1, 7, 100]) +def test_schedule_rejects_weekday_outside_zero_to_six(bad_weekday) -> None: + with pytest.raises(ValueError, match="weekday must be in 0..6"): + PresenceSchedule(1, 1, ANCHOR, {0: {bad_weekday}}) + + +@pytest.mark.parametrize("bad", [True, "1", 1.5, None]) +def test_schedule_rejects_non_int_weekdays(bad) -> None: + with pytest.raises(ValueError, match="weekday must be an int"): + PresenceSchedule(1, 1, ANCHOR, {0: [bad]}) + + +def test_schedule_rejects_string_and_none_week_sets() -> None: + """A string is iterable — "02" would silently mean {0, 2}.""" + with pytest.raises(ValueError, match="iterable of weekday ints"): + PresenceSchedule(1, 1, ANCHOR, {0: "02"}) + with pytest.raises(ValueError, match="iterable of weekday ints"): + PresenceSchedule(1, 1, ANCHOR, {0: None}) + + +def test_schedule_rejects_non_dict_pattern() -> None: + with pytest.raises(ValueError, match="must map week index"): + PresenceSchedule(1, 1, ANCHOR, "0,2") + + +@pytest.mark.parametrize("bad", [True, 1.0, "2026-01-05", None]) +def test_schedule_rejects_non_int_child_id(bad) -> None: + with pytest.raises(ValueError, match="child_id must be an integer"): + PresenceSchedule(bad, 1, ANCHOR, {0: {0}}) + + +@pytest.mark.parametrize( + "bad", ["2026-1-5", "2026/01/05", "not-a-date", 5, None, True] +) +def test_schedule_rejects_non_strict_anchor_dates(bad) -> None: + with pytest.raises(ValueError, match="anchor_date"): + PresenceSchedule(1, 1, bad, {0: {0}}) + + +def test_schedule_empty_week_means_absent_week() -> None: + """An explicitly empty week is valid: absent every day of it — + distinct from having no schedule (the engine's default).""" + schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: set()}) + assert schedule.pattern[1] == frozenset() + + +def test_schedule_empty_week_encodes_as_empty_segment() -> None: + schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: set()}) + assert schedule.encode() == "0,2|" + + +def test_schedule_equal_patterns_are_equal_and_encode_identically() -> None: + one = PresenceSchedule(1, 2, ANCHOR, {0: {4, 2, 0}, 1: {3, 1}}) + two = PresenceSchedule(2, 2, ANCHOR, {0: {0, 2, 4}, 1: {1, 3}}) + assert one.encode() == two.encode() == "0,2,4|1,3" + + +# --------------------------------------------------------------------------- +# Serialization round-trip +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "encoded", + [ + "0,2,4|1,3", + "0|1,2,3,4,5,6", + "|", + "0,2|", + "|0,2", + "6", + "0,1,2,3,4,5,6|", + ], +) +def test_schedule_decode_encode_round_trips_losslessly(encoded) -> None: + schedule = PresenceSchedule.decode(1, ANCHOR, encoded) + assert schedule.encode() == encoded + again = PresenceSchedule.decode(1, ANCHOR, schedule.encode()) + assert again == schedule + + +def test_schedule_decode_sets_cycle_length_from_segments() -> None: + one_week = PresenceSchedule.decode(1, ANCHOR, "0,2,4") + assert one_week.cycle_length_weeks == 1 + four_week = PresenceSchedule.decode(1, ANCHOR, "0|1|2|3,4") + assert four_week.cycle_length_weeks == 4 + + +def test_schedule_decode_rejects_malformed_segments() -> None: + for bad in ("0,12|1", "0,,2|1", "x|1", "0|-1|2", "8", "0,|1"): + with pytest.raises(ValueError, match="invalid weekday"): + PresenceSchedule.decode(1, ANCHOR, bad) + + +def test_schedule_decode_rejects_non_string() -> None: + with pytest.raises(ValueError, match="must be a string"): + PresenceSchedule.decode(1, ANCHOR, None) + + +def test_schedule_decode_rejects_too_many_segments(tmp_path) -> None: + with pytest.raises(ValueError, match="between 1 and"): + PresenceSchedule.decode(1, ANCHOR, "0|1|2|3|4") + + +# --------------------------------------------------------------------------- +# PresenceOverride +# --------------------------------------------------------------------------- + + +def test_override_single_date_and_range_cover() -> None: + single = PresenceOverride(1, "2026-07-20", "2026-07-20", False) + assert single.covers(datetime.date(2026, 7, 20)) + assert not single.covers(datetime.date(2026, 7, 21)) + span = PresenceOverride(1, "2026-07-20", "2026-07-24", True, note="trade") + assert span.covers(datetime.date(2026, 7, 22)) + assert not span.covers(datetime.date(2026, 7, 19)) + assert not span.covers(datetime.date(2026, 7, 25)) + assert span.note == "trade" + + +def test_override_end_before_start_rejected() -> None: + with pytest.raises(ValueError, match="end_date must be on or after"): + PresenceOverride(1, "2026-07-24", "2026-07-20", True) + + +@pytest.mark.parametrize("bad", ["1", 0, 1.0, None]) +def test_override_rejects_non_bool_is_present(bad) -> None: + with pytest.raises(ValueError, match="is_present must be a real bool"): + PresenceOverride(1, "2026-07-20", "2026-07-20", bad) + + +@pytest.mark.parametrize("bad", [5, ["note"], 1.5]) +def test_override_rejects_non_string_note(bad) -> None: + with pytest.raises(ValueError, match="note must be a string or None"): + PresenceOverride(1, "2026-07-20", "2026-07-20", True, note=bad) + + +@pytest.mark.parametrize("bad", [True, 1.0, "1"]) +def test_override_rejects_non_int_child_id(bad) -> None: + with pytest.raises(ValueError, match="child_id must be an integer"): + PresenceOverride(bad, "2026-07-20", "2026-07-20", True) + + +@pytest.mark.parametrize( + ("start", "end"), [("2026-7-20", "2026-07-20"), ("nope", "2026-07-20")] +) +def test_override_rejects_non_strict_dates(start, end) -> None: + with pytest.raises(ValueError, match="date"): + PresenceOverride(1, start, end, True) -- 2.55.0 From cb3c347d6130136c6ee228ea29b80f676e54bafa Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:46:30 -0400 Subject: [PATCH 25/42] Review fix: reject datetime anchors, freeze the pattern mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 (Codex gpt-5.6-terra, 2 MEDIUM): datetime.datetime subclasses datetime.date and slipped past the anchor check, storing a time component that would TypeError every later calendar comparison — rejected explicitly now (both model classes). The frozen schedule's pattern dict let callers inject unvalidated weekdays or drop required weeks after construction; the validated mapping is now exposed as a read-only MappingProxyType over frozenset weeks. Tests: datetime rejection matrix and immutability assertions. Suite: 974 passed. --- custom_components/nestquest/presence.py | 23 ++++++++++++++--- tests/test_presence.py | 33 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py index 88e6f10..ffc6841 100644 --- a/custom_components/nestquest/presence.py +++ b/custom_components/nestquest/presence.py @@ -43,6 +43,7 @@ from __future__ import annotations import datetime from dataclasses import dataclass +from types import MappingProxyType #: The schema's documented cap for cycle_length_weeks (schema.py keeps #: the pattern human-readable and the anchor arithmetic small). @@ -52,7 +53,18 @@ _DATE_FORMAT = "%Y-%m-%d" def _parse_date(value: object, field_name: str) -> datetime.date: - """Accept a datetime.date or a strict YYYY-MM-DD string.""" + """Accept a datetime.date or a strict YYYY-MM-DD string. + + ``datetime.datetime`` is rejected explicitly: it SUBCLASSES + datetime.date, so a bare isinstance check would store a value with + a time component and every later calendar-date comparison (e.g. + ``PresenceOverride.covers``) would raise TypeError. + """ + if isinstance(value, datetime.datetime): + raise ValueError( + f"{field_name} must be a plain calendar date, not a " + f"datetime with a time component: {value!r}" + ) if isinstance(value, datetime.date): return value if not isinstance(value, str): @@ -149,7 +161,7 @@ class PresenceSchedule: child_id: int cycle_length_weeks: int anchor_date: datetime.date - pattern: dict[int, frozenset[int]] + pattern: MappingProxyType # week index -> frozenset of weekdays def __init__( self, @@ -181,7 +193,12 @@ class PresenceSchedule: f"pattern must cover week indices 0..{cycle - 1}; " f"missing {missing}" ) - object.__setattr__(self, "pattern", normalised) + # Frozen must mean frozen: a bare dict would let callers inject + # unvalidated weekdays (or drop required weeks) AFTER + # construction, bypassing total validation. The mapping + # proxy exposes the validated contents read-only; the + # per-week sets are already frozensets. + object.__setattr__(self, "pattern", MappingProxyType(normalised)) def encode(self) -> str: """Serialize to the database's pipe-separated CSV shape. diff --git a/tests/test_presence.py b/tests/test_presence.py index 1c8e44d..202980a 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -227,3 +227,36 @@ def test_override_rejects_non_int_child_id(bad) -> None: def test_override_rejects_non_strict_dates(start, end) -> None: with pytest.raises(ValueError, match="date"): PresenceOverride(1, start, end, True) + + +def test_schedule_rejects_datetime_anchor() -> None: + """datetime.datetime subclasses date: a bare isinstance check would + store a time component and every date comparison would TypeError.""" + with pytest.raises(ValueError, match="plain calendar date"): + PresenceSchedule(1, 1, datetime.datetime(2026, 1, 5, 9, 30), {0: {0}}) + + +def test_override_rejects_datetime_bounds() -> None: + with pytest.raises(ValueError, match="plain calendar date"): + PresenceOverride( + 1, datetime.datetime(2026, 7, 20), "2026-07-20", True + ) + + +def test_schedule_pattern_is_immutable_after_construction() -> None: + """The frozen schedule must not leak a mutable dict: injecting + unvalidated weekdays or dropping required weeks after construction + would bypass total validation.""" + schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: {1}}) + with pytest.raises(TypeError): + schedule.pattern[0] = {9} + with pytest.raises(TypeError): + del schedule.pattern[1] + # mappingproxy exposes no mutators at all. + assert not any( + hasattr(schedule.pattern, name) + for name in ("clear", "pop", "popitem", "setdefault", "update") + ) + # The validated contents are still readable and encode correctly. + assert schedule.pattern[0] == frozenset({0, 2}) + assert schedule.encode() == "0,2|1" -- 2.55.0 From 5885d5e972b6caf509b57d4f929cfa74f0e70431 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:51:28 -0400 Subject: [PATCH 26/42] Anchor-date cycle position arithmetic (Feature 05, task 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit presence.cycle_week_index: ((target_date - anchor_date).days // 7) % cycle_length_weeks — anchor-date arithmetic ONLY (D-004). Python floor division makes pre-anchor dates walk the cycle backwards (day -1 is the last week), never inverting; the modulo of a negative stays non-negative, so the index is always a valid week index. The target date passes through the model's strict plain-date parsing. Tests: formula offsets, three-week pattern wrap, backwards walking, day-by-day stability across three Dec-31 -> Jan-1 boundaries including 2020 (verified 53 ISO weeks — the parity-inversion regression), and a source guard that the module never reads ISO week numbers. Suite: 980 passed. --- custom_components/nestquest/presence.py | 19 +++++++ tests/test_presence.py | 74 +++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py index ffc6841..5c65590 100644 --- a/custom_components/nestquest/presence.py +++ b/custom_components/nestquest/presence.py @@ -298,3 +298,22 @@ class PresenceOverride: def covers(self, date: datetime.date) -> bool: """Return True when the override applies to ``date``.""" return self.start_date <= date <= self.end_date + + +def cycle_week_index( + schedule: PresenceSchedule, target_date: object +) -> int: + """Return the cycle week index ``target_date`` falls in. + + Anchor-date arithmetic ONLY (D-004): ``((target_date - + anchor_date).days // 7) % cycle_length_weeks``. Python's floor + division makes dates BEFORE the anchor behave correctly — they + walk the cycle backwards (day -1 is the last week of the cycle, + day -8 wraps one further) instead of inverting, which is exactly + what ISO week parity does wrong in 53-week years. The modulo of + a negative in Python is non-negative, so the index is always a + valid week index. + """ + target = _parse_date(target_date, "target_date") + days = (target - schedule.anchor_date).days + return (days // 7) % schedule.cycle_length_weeks diff --git a/tests/test_presence.py b/tests/test_presence.py index 202980a..6beca28 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -9,6 +9,7 @@ from custom_components.nestquest.presence import ( MAX_CYCLE_LENGTH_WEEKS, PresenceOverride, PresenceSchedule, + cycle_week_index, ) @@ -260,3 +261,76 @@ def test_schedule_pattern_is_immutable_after_construction() -> None: # The validated contents are still readable and encode correctly. assert schedule.pattern[0] == frozenset({0, 2}) assert schedule.encode() == "0,2|1" + + +# --------------------------------------------------------------------------- +# Cycle arithmetic: anchor-date only, never ISO week parity +# --------------------------------------------------------------------------- + + +def test_cycle_week_index_follows_the_anchor_formula() -> None: + schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}}) + # The anchor day starts week 0; each whole 7 days steps the cycle. + offsets = {0: 0, 1: 0, 6: 0, 7: 1, 8: 1, 13: 1, 14: 0, 15: 0, 22: 1} + for offset, expected in offsets.items(): + assert ( + cycle_week_index( + schedule, ANCHOR + datetime.timedelta(days=offset) + ) + == expected + ), f"offset {offset}" + + +def test_cycle_week_index_before_the_anchor_walks_backwards() -> None: + """Python floor division: day -1 is the LAST week of the cycle, + wrapping backwards — never inverting.""" + schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}}) + assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=1)) == 1 + assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=7)) == 1 + assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=8)) == 0 + + +def test_cycle_week_index_three_week_pattern() -> None: + schedule = PresenceSchedule( + 1, 3, ANCHOR, {0: {0}, 1: {1}, 2: {2}} + ) + offsets = [0, 7, 14, 21, 28] + assert [cycle_week_index(schedule, ANCHOR + datetime.timedelta(days=o)) for o in offsets] == [ + 0, 1, 2, 0, 1 + ] + + +def test_cycle_week_index_stable_across_three_year_boundaries() -> None: + """The regression this exists for: walking day by day through a + December 31st -> January 1st boundary, the cycle index steps by 0 + or +1 (mod cycle length) and NEVER resets or inverts — including + 2020, a 53-ISO-week year, where ISO week parity flips and would + invert a two-week custody schedule on January 1st.""" + schedule = PresenceSchedule( + 1, 2, datetime.date(2019, 1, 6), {0: {0, 2, 4}, 1: {1, 3}} + ) + for year in (2019, 2020, 2021): + day = datetime.date(year, 12, 24) + while day < datetime.date(year + 1, 1, 8): + current = cycle_week_index(schedule, day) + following = cycle_week_index( + schedule, day + datetime.timedelta(days=1) + ) + assert following in (current, (current + 1) % 2), ( + f"{day} -> {day + datetime.timedelta(days=1)}: {current} " + f"-> {following} breaks the cycle across the year boundary" + ) + day += datetime.timedelta(days=1) + # And 2020 really is the 53-ISO-week year this guards against. + assert datetime.date(2020, 12, 31).isocalendar()[1] == 53 + + +def test_module_uses_no_iso_week_functions() -> None: + """D-004 guardrail: cycle math never reads ISO week numbers.""" + from pathlib import Path + + source = Path( + __import__("custom_components.nestquest.presence", fromlist=["__file__"]).__file__ + ).read_text() + assert "isocalendar" not in source + assert "isoweek" not in source -- 2.55.0 From 77c4db7b3c6a4edef92f7c398cdeb02651555e0d Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:54:25 -0400 Subject: [PATCH 27/42] Review fix: hardcode independently derived boundary expectations Review round 1 (Codex gpt-5.6-terra, LOW): the boundary walk asserted transitions in {(same, +1)} which even a reset/inverting implementation could satisfy on a two-week cycle. The test now asserts a hand-verified table of expected indices for every day around each of the three Dec-31/Jan-1 boundaries (2019/2020/2021, anchor 2019-01-06), so a parity bug cannot pass by coincidence. Suite: 980 passed. --- tests/test_presence.py | 61 +++++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/tests/test_presence.py b/tests/test_presence.py index 6beca28..96fe211 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -309,18 +309,55 @@ def test_cycle_week_index_stable_across_three_year_boundaries() -> None: schedule = PresenceSchedule( 1, 2, datetime.date(2019, 1, 6), {0: {0, 2, 4}, 1: {1, 3}} ) - for year in (2019, 2020, 2021): - day = datetime.date(year, 12, 24) - while day < datetime.date(year + 1, 1, 8): - current = cycle_week_index(schedule, day) - following = cycle_week_index( - schedule, day + datetime.timedelta(days=1) - ) - assert following in (current, (current + 1) % 2), ( - f"{day} -> {day + datetime.timedelta(days=1)}: {current} " - f"-> {following} breaks the cycle across the year boundary" - ) - day += datetime.timedelta(days=1) + # Independently derived expectations (anchor 2019-01-06, index = + # floor(days-since-anchor / 7) % 2, hand-verified) — hardcoded so + # even an implementation that resets or inverts at January 1st + # cannot pass by coincidence: + expected = { + # 2019 -> 2020 boundary + datetime.date(2019, 12, 26): 0, + datetime.date(2019, 12, 27): 0, + datetime.date(2019, 12, 28): 0, + datetime.date(2019, 12, 29): 1, + datetime.date(2019, 12, 30): 1, + datetime.date(2019, 12, 31): 1, + datetime.date(2020, 1, 1): 1, + datetime.date(2020, 1, 2): 1, + datetime.date(2020, 1, 3): 1, + datetime.date(2020, 1, 4): 1, + datetime.date(2020, 1, 5): 0, + datetime.date(2020, 1, 6): 0, + datetime.date(2020, 1, 7): 0, + # 2020 (53 ISO weeks) -> 2021 boundary + datetime.date(2020, 12, 27): 1, + datetime.date(2020, 12, 28): 1, + datetime.date(2020, 12, 29): 1, + datetime.date(2020, 12, 30): 1, + datetime.date(2020, 12, 31): 1, + datetime.date(2021, 1, 1): 1, + datetime.date(2021, 1, 2): 1, + datetime.date(2021, 1, 3): 0, + datetime.date(2021, 1, 4): 0, + datetime.date(2021, 1, 5): 0, + datetime.date(2021, 1, 6): 0, + datetime.date(2021, 1, 7): 0, + # 2021 -> 2022 boundary + datetime.date(2021, 12, 27): 1, + datetime.date(2021, 12, 28): 1, + datetime.date(2021, 12, 29): 1, + datetime.date(2021, 12, 30): 1, + datetime.date(2021, 12, 31): 1, + datetime.date(2022, 1, 1): 1, + datetime.date(2022, 1, 2): 0, + datetime.date(2022, 1, 3): 0, + datetime.date(2022, 1, 4): 0, + datetime.date(2022, 1, 5): 0, + datetime.date(2022, 1, 6): 0, + datetime.date(2022, 1, 7): 0, + } + for day, wanted in expected.items(): + got = cycle_week_index(schedule, day) + assert got == wanted, f"{day}: expected week {wanted}, got {got}" # And 2020 really is the 53-ISO-week year this guards against. assert datetime.date(2020, 12, 31).isocalendar()[1] == 53 -- 2.55.0 From 540f59a3534deb935b052aba8a4fc444a4c98eb0 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:57:02 -0400 Subject: [PATCH 28/42] PresenceEngine with is_present and the always-present default (Feature 05, task 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PresenceEngine is an immutable, database-free snapshot of the children's schedules answering is_present(child_id, date): a child with NO schedule entry is present every day (the household's always-present sibling); a scheduled child is present exactly when the date's weekday is in the pattern for that date's cycle week (anchor-date arithmetic via cycle_week_index — never ISO parity). Construction validates the snapshot shape (dict keyed by the schedule's own child_id, values are PresenceSchedules) and exposes it read-only (MappingProxyType + __setattr__ refusal). is_present rejects non-int child ids and non-plain dates. Tests: the three-child fixture (two complementary rotation children, one schedule-free) asserted for every day of a four-week span plus spot-checked swap days; weekend/weekday-set behaviour across weeks; construction/input rejection matrix; engine immutability. Suite: 985 passed. --- custom_components/nestquest/presence.py | 54 +++++++++++++++ tests/test_presence.py | 91 +++++++++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py index 5c65590..797081c 100644 --- a/custom_components/nestquest/presence.py +++ b/custom_components/nestquest/presence.py @@ -317,3 +317,57 @@ def cycle_week_index( target = _parse_date(target_date, "target_date") days = (target - schedule.anchor_date).days return (days // 7) % schedule.cycle_length_weeks + + +class PresenceEngine: + """Answers "is this child at this house on this date?". + + Pure and side-effect-free: built from an immutable snapshot of the + children's schedules (``child_id -> PresenceSchedule``), it never + touches the database — callers build a snapshot from the DAO layer + and pass it in. A child with NO schedule entry is present every + day (the third household child's case); a child WITH a schedule is + present exactly when the date's weekday is in that schedule's + pattern for the date's cycle week index (anchor-date arithmetic — + :func:`cycle_week_index`, never ISO week parity). + """ + + def __init__(self, schedules: dict[int, PresenceSchedule]) -> None: + if not isinstance(schedules, dict): + raise ValueError( + f"schedules must map child_id to PresenceSchedule, got " + f"{schedules!r}" + ) + for child_id, schedule in schedules.items(): + if not isinstance(schedule, PresenceSchedule): + raise ValueError( + f"schedules[{child_id!r}] must be a PresenceSchedule, " + f"got {schedule!r}" + ) + if schedule.child_id != child_id: + raise ValueError( + f"schedules is keyed {child_id!r} but holds the " + f"schedule of child {schedule.child_id}" + ) + # Read-only snapshot: reassignment is refused and no mutator + # exists, so a built engine answers consistently for its + # lifetime. + object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules))) + + def is_present(self, child_id: object, date: object) -> bool: + """Return whether ``child_id`` is present on ``date``.""" + if isinstance(child_id, bool) or not isinstance(child_id, int): + raise ValueError(f"child_id must be an integer, got {child_id!r}") + target = _parse_date(date, "date") + schedule = self._schedules.get(child_id) + if schedule is None: + # No schedule at all: present every day (household default). + return True + week = cycle_week_index(schedule, target) + return target.weekday() in schedule.pattern[week] + + def __setattr__(self, name: object, value: object) -> None: + raise AttributeError( + "PresenceEngine is an immutable snapshot; build a new engine " + "instead of mutating this one" + ) diff --git a/tests/test_presence.py b/tests/test_presence.py index 96fe211..fd2570e 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -7,6 +7,7 @@ import pytest from custom_components.nestquest.presence import ( MAX_CYCLE_LENGTH_WEEKS, + PresenceEngine, PresenceOverride, PresenceSchedule, cycle_week_index, @@ -371,3 +372,93 @@ def test_module_uses_no_iso_week_functions() -> None: ).read_text() assert "isocalendar" not in source assert "isoweek" not in source + + +# --------------------------------------------------------------------------- +# PresenceEngine: is_present with the always-present default +# --------------------------------------------------------------------------- + + +def _three_child_engine() -> PresenceEngine: + """Two children on opposite weeks of one two-week rotation, one + with no schedule at all (the always-present sibling).""" + anchor = datetime.date(2026, 1, 5) # Monday, week 0 + declan = PresenceSchedule( + 1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} + ) + jordyn = PresenceSchedule( + 2, 2, anchor, {0: set(), 1: {0, 1, 2, 3, 4, 5, 6}} + ) + return PresenceEngine({1: declan, 2: jordyn}) + + +def test_engine_child_without_schedule_is_always_present() -> None: + engine = _three_child_engine() + # Chloe has no schedule: present every day, including weekends. + for offset in range(0, 28, 3): + day = datetime.date(2026, 1, 5) + datetime.timedelta(days=offset) + assert engine.is_present(3, day) is True, f"offset {offset}" + + +def test_engine_alternating_children_are_complementary_for_four_weeks() -> None: + """Every day of a four-week span: the two rotation children swap + presence exactly per the anchor pattern — when one is home the + other is away.""" + engine = _three_child_engine() + anchor = datetime.date(2026, 1, 5) + for offset in range(28): + day = anchor + datetime.timedelta(days=offset) + d = engine.is_present(1, day) + j = engine.is_present(2, day) + assert d == (not j), f"offset {offset}: {d}/{j} not complementary" + expected_week = ((day - anchor).days // 7) % 2 + expected_present = day.weekday() in {0, 1, 2, 3, 4, 5, 6} + assert engine.is_present(1, day) == (expected_week == 0), ( + f"offset {offset}: Declan week {expected_week}" + ) + # Spot-check the swap days. + assert engine.is_present(1, anchor) is True + assert engine.is_present(2, anchor) is False + assert engine.is_present(1, anchor + datetime.timedelta(days=7)) is False + assert engine.is_present(2, anchor + datetime.timedelta(days=7)) is True + + +def test_engine_weekend_absence_respects_the_pattern() -> None: + """A weekday-set pattern excludes days outside the set even in the + child's present week.""" + engine = PresenceEngine( + { + 1: PresenceSchedule( + 1, 2, datetime.date(2026, 1, 5), {0: {1, 3}, 1: {2, 4}} + ) + } + ) + # 2026-01-06 (Tue) is week 0, weekday 1 -> present. + assert engine.is_present(1, datetime.date(2026, 1, 6)) is True + # 2026-01-07 (Wed) is week 0, weekday 2 -> absent. + assert engine.is_present(1, datetime.date(2026, 1, 7)) is False + # 2026-01-14 (Wed) is week 1, weekday 2 -> present. + assert engine.is_present(1, datetime.date(2026, 1, 14)) is True + + +def test_engine_rejects_bad_construction_and_inputs() -> None: + schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}}) + with pytest.raises(ValueError, match="must map child_id"): + PresenceEngine([schedule]) + with pytest.raises(ValueError, match="must be a PresenceSchedule"): + PresenceEngine({1: "not-a-schedule"}) + with pytest.raises(ValueError, match="holds the schedule"): + PresenceEngine({2: schedule}) + engine = PresenceEngine({1: schedule}) + with pytest.raises(ValueError, match="child_id must be an integer"): + engine.is_present(True, ANCHOR) + with pytest.raises(ValueError, match="plain calendar date"): + engine.is_present(1, datetime.datetime(2026, 1, 5)) + + +def test_engine_is_immutable() -> None: + engine = _three_child_engine() + with pytest.raises(AttributeError, match="immutable snapshot"): + engine._schedules = {} + with pytest.raises(AttributeError, match="immutable snapshot"): + engine.something_new = 1 -- 2.55.0 From 6d48f433b7c906e95eec31530ee04ee284eb8c8d Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:58:51 -0400 Subject: [PATCH 29/42] Review fix: validate engine keys as child ids; every-day three-child walk Review round 1 (Codex gpt-5.6-terra, 2 LOW): engine snapshot keys now pass _validate_child_id (True/1.0 compare equal to 1 and would be accepted then behave differently on lookup); the always-present test now walks all 28 days asserting all three children's expected verdicts instead of sampling every third day. Suite: 985 passed. --- custom_components/nestquest/presence.py | 4 ++++ tests/test_presence.py | 22 ++++++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py index 797081c..907ad8d 100644 --- a/custom_components/nestquest/presence.py +++ b/custom_components/nestquest/presence.py @@ -339,6 +339,10 @@ class PresenceEngine: f"{schedules!r}" ) for child_id, schedule in schedules.items(): + # Validate the KEY as a model child id too: True and 1.0 + # compare equal to 1, so {True: schedule(1)} would be + # accepted and then behave differently on lookup. + _validate_child_id(child_id) if not isinstance(schedule, PresenceSchedule): raise ValueError( f"schedules[{child_id!r}] must be a PresenceSchedule, " diff --git a/tests/test_presence.py b/tests/test_presence.py index fd2570e..b38727e 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -393,11 +393,21 @@ def _three_child_engine() -> PresenceEngine: def test_engine_child_without_schedule_is_always_present() -> None: + """Every day of the four-week span, all three children get the + expected verdict: Chloe (no schedule) always present; the two + rotation children complementary per the anchor pattern.""" engine = _three_child_engine() - # Chloe has no schedule: present every day, including weekends. - for offset in range(0, 28, 3): - day = datetime.date(2026, 1, 5) + datetime.timedelta(days=offset) - assert engine.is_present(3, day) is True, f"offset {offset}" + anchor = datetime.date(2026, 1, 5) + for offset in range(28): + day = anchor + datetime.timedelta(days=offset) + week = ((day - anchor).days // 7) % 2 + assert engine.is_present(3, day) is True, f"offset {offset}: Chloe" + assert engine.is_present(1, day) is (week == 0), ( + f"offset {offset}: Declan should follow week {week}" + ) + assert engine.is_present(2, day) is (week == 1), ( + f"offset {offset}: Jordyn should follow week {week}" + ) def test_engine_alternating_children_are_complementary_for_four_weeks() -> None: @@ -449,6 +459,10 @@ def test_engine_rejects_bad_construction_and_inputs() -> None: PresenceEngine({1: "not-a-schedule"}) with pytest.raises(ValueError, match="holds the schedule"): PresenceEngine({2: schedule}) + with pytest.raises(ValueError, match="child_id must be an integer"): + PresenceEngine({True: schedule}) + with pytest.raises(ValueError, match="child_id must be an integer"): + PresenceEngine({1.0: PresenceSchedule(1, 1, ANCHOR, {0: {0}})}) engine = PresenceEngine({1: schedule}) with pytest.raises(ValueError, match="child_id must be an integer"): engine.is_present(True, ANCHOR) -- 2.55.0 From 744a40dcb5d216b980218ed74a6c3bb8d5f8ef55 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:03:00 -0400 Subject: [PATCH 30/42] Presence override overlap rejection naming the conflict (Feature 05, task 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PresenceOverridesDao.create now refuses an override that overlaps any existing override of the SAME child (inclusive boundaries): the check runs inside the same transaction as the INSERT under the connection lock, so a racing create cannot slip a conflict between check and write — a deterministic race test proves exactly one of two concurrent overlapping creates lands. The ValueError names the conflicting override's id, its range, and its flag. Round-trip, per-child scope, and remove-then-recreate tests added. Suite: 990 passed. --- custom_components/nestquest/dao_presence.py | 24 ++++ tests/test_dao_presence.py | 149 +++++++++++++++++++- 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/custom_components/nestquest/dao_presence.py b/custom_components/nestquest/dao_presence.py index c19e821..6bb5ddd 100644 --- a/custom_components/nestquest/dao_presence.py +++ b/custom_components/nestquest/dao_presence.py @@ -186,6 +186,15 @@ class PresenceOverridesDao: The child must exist (validated atomically under the connection lock). Date shape is validated strictly, and the schema CHECK rejects end < start. + + Overlap policy (Feature 05): an override for a child may not + overlap another override of the SAME child — two different + present/absent answers for one date are unresolvable. The + overlap check (inclusive boundaries) runs inside the same + transaction as the INSERT, so a racing create cannot slip a + conflicting override between the check and the write; a + conflict raises ValueError naming the conflicting override's + id and range. """ _validate_date(start_date, "start_date") _validate_date(end_date, "end_date") @@ -196,6 +205,21 @@ class PresenceOverridesDao: ) if child is None: raise ValueError(f"child {child_id} does not exist") + conflicting = await self._database.fetch_one( + f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides " + "WHERE child_id = ? AND end_date >= ? " + "AND start_date <= ? ORDER BY id LIMIT 1", + (child_id, start_date, end_date), + ) + if conflicting is not None: + conflict = _override_from_row(conflicting) + raise ValueError( + f"override for child {child_id} " + f"[{start_date}, {end_date}] overlaps existing " + f"override {conflict.id} " + f"[{conflict.start_date}, {conflict.end_date}] " + f"(is_present={conflict.is_present})" + ) result = await self._database.execute( "INSERT INTO presence_overrides (child_id, " "start_date, end_date, is_present, note) " diff --git a/tests/test_dao_presence.py b/tests/test_dao_presence.py index c4d183e..85e381d 100644 --- a/tests/test_dao_presence.py +++ b/tests/test_dao_presence.py @@ -619,4 +619,151 @@ def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None: ) assert sql_pattern.search(remaining) is None, ( "tests must go through the DAO, not raw SQL, for these tables" - ) \ No newline at end of file + ) + +# --------------------------------------------------------------------------- +# Presence overrides: overlap rejection naming the conflict +# --------------------------------------------------------------------------- + + +def test_override_create_round_trip_single_and_range(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + single = await overrides.create( + child.id, "2026-07-20", "2026-07-20", False + ) + assert (single.start_date, single.end_date) == ("2026-07-20",) * 2 + span = await overrides.create( + child.id, "2026-08-01", "2026-08-05", True, note="traded week" + ) + assert span.is_present is True + assert span.note == "traded week" + # Distinct, non-overlapping ranges coexist. + listed = await overrides.list_by_child_and_range( + child.id, "2026-01-01", "2026-12-31" + ) + assert [row.id for row in listed] == [single.id, span.id] + return listed + + _with_db(tmp_path, "override-round-trip.db")(_body) + + +def test_override_overlap_rejected_and_names_the_conflict(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + existing = await overrides.create( + child.id, "2026-07-20", "2026-07-24", False, note="holiday" + ) + for start, end in ( + ("2026-07-24", "2026-07-26"), # touches the existing end + ("2026-07-18", "2026-07-20"), # touches the existing start + ("2026-07-21", "2026-07-22"), # fully inside + ("2026-07-01", "2026-07-30"), # fully covering + ): + with pytest.raises(ValueError, match="overlaps existing"): + await overrides.create( + child.id, start, end, True, note="swap" + ) + # The error NAMES the conflicting override (id + its range). + with pytest.raises( + ValueError, + match=( + rf"override {existing.id} " + rf"\[2026-07-20, 2026-07-24\].*is_present=False" + ), + ): + await overrides.create(child.id, "2026-07-22", "2026-07-22", True) + # Nothing was written by any rejected attempt. + listed = await overrides.list_by_child_and_range( + child.id, "2026-01-01", "2026-12-31" + ) + assert [row.id for row in listed] == [existing.id] + return existing + + _with_db(tmp_path, "override-overlap.db")(_body) + + +def test_override_overlap_is_per_child(tmp_path) -> None: + """Different children's overrides may cover the same dates: the + conflict rule is per child.""" + async def _body(database, schedules, overrides, children, child): + other = await children.create("Bo", NOW) + await overrides.create(child.id, "2026-07-20", "2026-07-24", False) + twin = await overrides.create( + other.id, "2026-07-20", "2026-07-24", True + ) + assert twin.child_id == other.id + listed = await overrides.list_by_child_and_range( + other.id, "2026-01-01", "2026-12-31" + ) + assert [row.id for row in listed] == [twin.id] + return twin + + _with_db(tmp_path, "override-per-child.db")(_body) + + +def test_override_remove_then_recreate_overlapping(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + first = await overrides.create( + child.id, "2026-07-20", "2026-07-24", False + ) + assert await overrides.delete(first.id) is True + assert await overrides.delete(first.id) is False + replacement = await overrides.create( + child.id, "2026-07-20", "2026-07-24", True, note="swap back" + ) + assert replacement.is_present is True + return replacement + + _with_db(tmp_path, "override-recreate.db")(_body) + + +def test_override_create_racing_conflicts_serialize(tmp_path) -> None: + """Two racing creates for the same child and dates: exactly one + lands, the other is rejected with the overlap error (the check and + the INSERT share one transaction under the connection lock).""" + import asyncio as asyncio_module + + async def _main(): + from unittest.mock import AsyncMock, MagicMock + + from custom_components.nestquest.dao_children import ChildrenDao + from custom_components.nestquest.dao_presence import ( + PresenceOverridesDao, + ) + from custom_components.nestquest.db import NestQuestDatabase + from custom_components.nestquest.migrations import apply_migrations + + hass = MagicMock() + hass.async_add_executor_job = AsyncMock( + side_effect=(lambda fn, *a: fn(*a)) + ) + database = NestQuestDatabase(hass) + await database.open(tmp_path / "override-race.db") + try: + await apply_migrations(database) + children = ChildrenDao(database) + overrides = PresenceOverridesDao(database) + child = await children.create("Ada", NOW) + results = await asyncio_module.gather( + overrides.create( + child.id, "2026-07-20", "2026-07-24", True + ), + overrides.create( + child.id, "2026-07-22", "2026-07-26", False + ), + return_exceptions=True, + ) + outcomes = sorted( + "ok" if not isinstance(r, BaseException) else type(r).__name__ + for r in results + ) + assert outcomes == ["ValueError", "ok"], ( + f"exactly one create must land: {outcomes}" + ) + listed = await overrides.list_by_child_and_range( + child.id, "2026-01-01", "2026-12-31" + ) + assert len(listed) == 1 + finally: + await database.close() + + asyncio.new_event_loop().run_until_complete(_main()) -- 2.55.0 From 51b1388233fc5cb9ed05653985fcec275da273ff Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:03:49 -0400 Subject: [PATCH 31/42] Fix: inverted ranges skip the overlap probe and hit the schema CHECK The matrix test's end-before-start probe (expecting the schema's end>=start IntegrityError) hit the new overlap query first, since an inverted range lexically 'overlaps' the seeded row. The overlap check now runs only for well-ordered ranges; invalid ones fall through to the INSERT and keep the IntegrityError contract. Suite: 990 passed. --- custom_components/nestquest/dao_presence.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/custom_components/nestquest/dao_presence.py b/custom_components/nestquest/dao_presence.py index 6bb5ddd..5279a25 100644 --- a/custom_components/nestquest/dao_presence.py +++ b/custom_components/nestquest/dao_presence.py @@ -205,12 +205,20 @@ class PresenceOverridesDao: ) if child is None: raise ValueError(f"child {child_id} does not exist") - conflicting = await self._database.fetch_one( - f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides " - "WHERE child_id = ? AND end_date >= ? " - "AND start_date <= ? ORDER BY id LIMIT 1", - (child_id, start_date, end_date), - ) + # Only a well-ordered range has overlap semantics: + # inverted ranges fall through to the INSERT and hit + # the schema's end>=start CHECK, preserving the + # IntegrityError contract the matrix tests rely on. + if end_date >= start_date: + conflicting = await self._database.fetch_one( + f"SELECT {_OVERRIDE_COLUMNS} " + "FROM presence_overrides " + "WHERE child_id = ? AND end_date >= ? " + "AND start_date <= ? ORDER BY id LIMIT 1", + (child_id, start_date, end_date), + ) + else: + conflicting = None if conflicting is not None: conflict = _override_from_row(conflicting) raise ValueError( -- 2.55.0 From 94f54523f931dd68b081c4e7071f968485eb0376 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:06:44 -0400 Subject: [PATCH 32/42] Review fix: gate the override race test inside A's transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 (Codex gpt-5.6-terra, LOW): the racing test completed synchronously and proved nothing. The executor now gates task A's INSERT — inside its open transaction, after its overlap probe — so task B queues on the connection lock, re-runs the probe against A's committed row, and is rejected; exactly one lands. Suite: 990 passed. --- tests/test_dao_presence.py | 64 +++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/tests/test_dao_presence.py b/tests/test_dao_presence.py index 85e381d..0d86cb8 100644 --- a/tests/test_dao_presence.py +++ b/tests/test_dao_presence.py @@ -718,13 +718,15 @@ def test_override_remove_then_recreate_overlapping(tmp_path) -> None: def test_override_create_racing_conflicts_serialize(tmp_path) -> None: """Two racing creates for the same child and dates: exactly one - lands, the other is rejected with the overlap error (the check and - the INSERT share one transaction under the connection lock).""" + lands, the other is rejected with the overlap error. DETERMINISTIC + gate: task A is paused at its INSERT — inside its open transaction, + after its overlap probe — so task B is created while A provably + holds the connection lock and must queue; B then re-runs the probe + against A's committed row and is rejected.""" import asyncio as asyncio_module + from unittest.mock import MagicMock async def _main(): - from unittest.mock import AsyncMock, MagicMock - from custom_components.nestquest.dao_children import ChildrenDao from custom_components.nestquest.dao_presence import ( PresenceOverridesDao, @@ -732,10 +734,24 @@ def test_override_create_racing_conflicts_serialize(tmp_path) -> None: from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.migrations import apply_migrations + armed = {"active": False, "gated": False} + gate_open = asyncio_module.Event() + a_started = asyncio_module.Event() + hass = MagicMock() - hass.async_add_executor_job = AsyncMock( - side_effect=(lambda fn, *a: fn(*a)) - ) + + async def _gated_executor(fn, *args): + if armed["active"]: + # Gate the FIRST _execute after arming: task A's INSERT, + # which runs inside its open transaction (BEGIN already + # executed) after the overlap probe. + if getattr(fn, "__name__", "") == "_execute" and not armed["gated"]: + armed["gated"] = True + a_started.set() + await gate_open.wait() + return fn(*args) + + hass.async_add_executor_job = _gated_executor database = NestQuestDatabase(hass) await database.open(tmp_path / "override-race.db") try: @@ -743,26 +759,32 @@ def test_override_create_racing_conflicts_serialize(tmp_path) -> None: children = ChildrenDao(database) overrides = PresenceOverridesDao(database) child = await children.create("Ada", NOW) - results = await asyncio_module.gather( - overrides.create( - child.id, "2026-07-20", "2026-07-24", True - ), - overrides.create( - child.id, "2026-07-22", "2026-07-26", False - ), - return_exceptions=True, + + armed["active"] = True + task_a = asyncio_module.ensure_future( + overrides.create(child.id, "2026-07-20", "2026-07-24", True) ) - outcomes = sorted( - "ok" if not isinstance(r, BaseException) else type(r).__name__ - for r in results + await a_started.wait() + armed["active"] = False + task_b = asyncio_module.ensure_future( + overrides.create(child.id, "2026-07-22", "2026-07-26", False) ) - assert outcomes == ["ValueError", "ok"], ( - f"exactly one create must land: {outcomes}" + await asyncio_module.sleep(0) + gate_open.set() + record_a, error_b = await asyncio_module.gather( + task_a, task_b, return_exceptions=True ) + assert isinstance(record_a, object) and not isinstance( + record_a, BaseException + ), f"A must land: {record_a!r}" + assert isinstance(error_b, ValueError), ( + f"B must be rejected with the overlap error: {error_b!r}" + ) + assert "overlaps existing" in str(error_b) listed = await overrides.list_by_child_and_range( child.id, "2026-01-01", "2026-12-31" ) - assert len(listed) == 1 + assert [row.id for row in listed] == [record_a.id] finally: await database.close() -- 2.55.0 From 8f04d93363d815119df5d658fb80b52c1b3fc677 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:12:16 -0400 Subject: [PATCH 33/42] Overrides take precedence over the pattern (Feature 05, task 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PresenceEngine gains an overrides snapshot (child_id -> list of PresenceOverride): is_present checks covering overrides BEFORE the pattern and returns the override's flag as the answer — an absent override on a normally-present day returns False, a present one on a normally-absent day returns True, and an override on a child with no schedule still applies (holidays work for the always-present sibling). Snapshot validation mirrors the schedules': dict of lists keyed by the override's own child id, exposed read-only. The DAO layer guarantees no overlapping overrides per child; a hand-built snapshot that carries them resolves deterministically to the LATEST start date (documented). Tests: all three precedence cases, span edges (outside days stay on the pattern), snapshot rejection matrix, deterministic overlap tie-break. Suite: 995 passed. --- custom_components/nestquest/presence.py | 51 ++++++++++++- tests/test_presence.py | 98 +++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 2 deletions(-) diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py index 907ad8d..9dabf0d 100644 --- a/custom_components/nestquest/presence.py +++ b/custom_components/nestquest/presence.py @@ -332,7 +332,11 @@ class PresenceEngine: :func:`cycle_week_index`, never ISO week parity). """ - def __init__(self, schedules: dict[int, PresenceSchedule]) -> None: + def __init__( + self, + schedules: dict[int, PresenceSchedule], + overrides: dict[int, list[PresenceOverride]] | None = None, + ) -> None: if not isinstance(schedules, dict): raise ValueError( f"schedules must map child_id to PresenceSchedule, got " @@ -353,16 +357,59 @@ class PresenceEngine: f"schedules is keyed {child_id!r} but holds the " f"schedule of child {schedule.child_id}" ) + if overrides is None: + overrides = {} + if not isinstance(overrides, dict): + raise ValueError( + "overrides must map child_id to a list of " + f"PresenceOverride, got {overrides!r}" + ) + for child_id, entries in overrides.items(): + _validate_child_id(child_id) + if not isinstance(entries, list): + raise ValueError( + f"overrides[{child_id!r}] must be a list of " + f"PresenceOverride, got {entries!r}" + ) + for entry in entries: + if not isinstance(entry, PresenceOverride): + raise ValueError( + f"overrides[{child_id!r}] entries must be " + f"PresenceOverride, got {entry!r}" + ) + if entry.child_id != child_id: + raise ValueError( + f"overrides is keyed {child_id!r} but holds an " + f"override of child {entry.child_id}" + ) # Read-only snapshot: reassignment is refused and no mutator # exists, so a built engine answers consistently for its # lifetime. object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules))) + object.__setattr__( + self, "_overrides", MappingProxyType(dict(overrides)) + ) def is_present(self, child_id: object, date: object) -> bool: - """Return whether ``child_id`` is present on ``date``.""" + """Return whether ``child_id`` is present on ``date``. + + Overrides take precedence over the repeating pattern: when an + override covers the date, its flag IS the answer — including + for a child with no schedule at all. The DAO layer guarantees + a child's overrides never overlap; if a snapshot still carries + two covering the same date, the one with the LATEST start date + wins (deterministic, and matches "the most specific swap wins"). + """ if isinstance(child_id, bool) or not isinstance(child_id, int): raise ValueError(f"child_id must be an integer, got {child_id!r}") target = _parse_date(date, "date") + entries = self._overrides.get(child_id) or [] + covering = [ + entry for entry in entries if entry.covers(target) + ] + if covering: + winner = max(covering, key=lambda entry: entry.start_date) + return winner.is_present schedule = self._schedules.get(child_id) if schedule is None: # No schedule at all: present every day (household default). diff --git a/tests/test_presence.py b/tests/test_presence.py index b38727e..284eadf 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -476,3 +476,101 @@ def test_engine_is_immutable() -> None: engine._schedules = {} with pytest.raises(AttributeError, match="immutable snapshot"): engine.something_new = 1 + + +# --------------------------------------------------------------------------- +# Overrides take precedence over the pattern +# --------------------------------------------------------------------------- + + +def _engine_with_overrides(*overrides_for_declan) -> PresenceEngine: + anchor = datetime.date(2026, 1, 5) # Monday + return PresenceEngine( + { + 1: PresenceSchedule( + 1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} + ) + }, + overrides={1: list(overrides_for_declan)} if overrides_for_declan else {}, + ) + + +def test_absent_override_on_normally_present_day_wins() -> None: + # 2026-01-06 is Tuesday of Declan's week 0: normally present. + engine = _engine_with_overrides( + PresenceOverride(1, "2026-01-06", "2026-01-06", False, note="away") + ) + assert engine.is_present(1, datetime.date(2026, 1, 6)) is False + # The pattern resumes outside the override. + assert engine.is_present(1, datetime.date(2026, 1, 7)) is True + + +def test_present_override_on_normally_absent_day_wins() -> None: + # 2026-01-13 is in Declan's week 1 (away all week); a present + # override on the Tuesday of week 1 brings him home for that day. + engine = _engine_with_overrides( + PresenceOverride(1, "2026-01-13", "2026-01-13", True, note="trade") + ) + assert engine.is_present(1, datetime.date(2026, 1, 13)) is True + assert engine.is_present(1, datetime.date(2026, 1, 14)) is False + + +def test_override_applies_without_a_schedule() -> None: + """An override on a schedule-free child still applies: holidays and + swaps must work for the always-present sibling too.""" + engine = PresenceEngine( + {}, + overrides={ + 3: [ + PresenceOverride( + 3, "2026-07-20", "2026-07-24", False, note="holiday" + ) + ] + }, + ) + assert engine.is_present(3, datetime.date(2026, 7, 22)) is False + assert engine.is_present(3, datetime.date(2026, 7, 19)) is True + assert engine.is_present(3, datetime.date(2026, 7, 25)) is True + + +def test_override_range_beats_pattern_for_its_span() -> None: + engine = _engine_with_overrides( + PresenceOverride(1, "2026-01-13", "2026-01-17", True, note="traded week") + ) + for offset in range(12, 17): + day = datetime.date(2026, 1, 1) + datetime.timedelta(days=offset) + assert engine.is_present(1, day) is True, f"{day}" + # Days outside the span stay on the pattern (week 1: absent). + assert engine.is_present(1, datetime.date(2026, 1, 12)) is False + assert engine.is_present(1, datetime.date(2026, 1, 18)) is False + + +def test_engine_override_snapshot_validation() -> None: + schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}}) + entry = PresenceOverride(1, "2026-07-20", "2026-07-20", True) + with pytest.raises(ValueError, match="must map child_id"): + PresenceEngine({1: schedule}, overrides=[entry]) + with pytest.raises(ValueError, match="must be a list"): + PresenceEngine({1: schedule}, overrides={1: entry}) + with pytest.raises(ValueError, match="must be"): + PresenceEngine({1: schedule}, overrides={1: ["nope"]}) + with pytest.raises(ValueError, match="holds an override"): + PresenceEngine({1: schedule}, overrides={2: [entry]}) + with pytest.raises(ValueError, match="child_id must be an integer"): + PresenceEngine({1: schedule}, overrides={True: [entry]}) + + +def test_engine_overlapping_snapshot_uses_latest_start() -> None: + """The DAO layer refuses overlapping overrides per child; if a + snapshot carries them anyway (hand-built), the latest start date + wins deterministically.""" + engine = _engine_with_overrides( + PresenceOverride(1, "2026-01-06", "2026-01-10", False), + PresenceOverride(1, "2026-01-08", "2026-01-12", True), + ) + # Jan 6-7: only the earlier override covers -> absent. + assert engine.is_present(1, datetime.date(2026, 1, 6)) is False + # Jan 8-10: both cover; the LATER start (present) wins. + assert engine.is_present(1, datetime.date(2026, 1, 9)) is True + # Jan 11-12: only the later override covers -> present. + assert engine.is_present(1, datetime.date(2026, 1, 11)) is True -- 2.55.0 From bff2f4ff5e8c0702a489b2cd9280c63876836ce7 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:13:48 -0400 Subject: [PATCH 34/42] Review fix: freeze the engine's overrides snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 (Codex gpt-5.6-terra, MEDIUM): the overrides mapping was shallow-copied, leaving caller-owned lists mutable through _overrides — a later append would change is_present results. Each validated list is copied into an immutable tuple before wrapping; tests prove post-construction appends change nothing and the snapshot refuses mutation. Suite: 996 passed. --- custom_components/nestquest/presence.py | 11 +++++++++-- tests/test_presence.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py index 9dabf0d..ff233b4 100644 --- a/custom_components/nestquest/presence.py +++ b/custom_components/nestquest/presence.py @@ -384,10 +384,17 @@ class PresenceEngine: ) # Read-only snapshot: reassignment is refused and no mutator # exists, so a built engine answers consistently for its - # lifetime. + # lifetime. The overrides lists are copied to immutable + # tuples — a shallow mapping copy alone would leave caller- + # owned lists reachable through _overrides, and mutating one + # later would change is_present results. object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules))) object.__setattr__( - self, "_overrides", MappingProxyType(dict(overrides)) + self, + "_overrides", + MappingProxyType( + {child_id: tuple(entries) for child_id, entries in overrides.items()} + ), ) def is_present(self, child_id: object, date: object) -> bool: diff --git a/tests/test_presence.py b/tests/test_presence.py index 284eadf..c47ada6 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -574,3 +574,19 @@ def test_engine_overlapping_snapshot_uses_latest_start() -> None: assert engine.is_present(1, datetime.date(2026, 1, 9)) is True # Jan 11-12: only the later override covers -> present. assert engine.is_present(1, datetime.date(2026, 1, 11)) is True + + +def test_engine_override_lists_are_frozen() -> None: + """The overrides snapshot must be truly read-only: mutating the + caller's list after construction must not change answers.""" + entry = PresenceOverride(1, "2026-01-06", "2026-01-06", False) + entries = [entry] + engine = _engine_with_overrides(*entries) + assert engine.is_present(1, datetime.date(2026, 1, 6)) is False + entries.append(PresenceOverride(1, "2026-01-07", "2026-01-07", False)) + # The late append must NOT change the engine's answers... + assert engine.is_present(1, datetime.date(2026, 1, 7)) is True + with pytest.raises(TypeError): + engine._overrides[1].append(entry) + with pytest.raises(AttributeError, match="immutable snapshot"): + engine._overrides = {} -- 2.55.0 From 830d663a457c9b0a12364dc18459458cb98972de Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:15:13 -0400 Subject: [PATCH 35/42] Test fix: tuples raise AttributeError for append, not TypeError The frozen-snapshot test expected TypeError from tuple.append; tuples have no append at all. Suite green BEFORE commit: 997 passed. --- tests/test_presence.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_presence.py b/tests/test_presence.py index c47ada6..d08b108 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -586,7 +586,9 @@ def test_engine_override_lists_are_frozen() -> None: entries.append(PresenceOverride(1, "2026-01-07", "2026-01-07", False)) # The late append must NOT change the engine's answers... assert engine.is_present(1, datetime.date(2026, 1, 7)) is True - with pytest.raises(TypeError): + # Tuples have no append at all (AttributeError), and the mapping + # itself refuses reassignment. + with pytest.raises(AttributeError): engine._overrides[1].append(entry) with pytest.raises(AttributeError, match="immutable snapshot"): engine._overrides = {} -- 2.55.0 From bdf3513609065fdb16af4b12be29466991923954 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:16:40 -0400 Subject: [PATCH 36/42] Test fix: build the frozen-snapshot engine on the caller's list Review round 3 (Codex gpt-5.6-terra, LOW): the helper copied the list, so the mutation could never have affected even a shallow snapshot. The engine is now constructed directly on the caller's list, making the freeze regression-proof. Suite green before commit: 997 passed. --- tests/test_presence.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/test_presence.py b/tests/test_presence.py index d08b108..ffcb7c7 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -577,11 +577,18 @@ def test_engine_overlapping_snapshot_uses_latest_start() -> None: def test_engine_override_lists_are_frozen() -> None: - """The overrides snapshot must be truly read-only: mutating the - caller's list after construction must not change answers.""" + """The overrides snapshot must be truly read-only: the engine is + built directly on the CALLER's list, so mutating that list after + construction would change answers under a shallow copy — under the + frozen snapshot it must not.""" entry = PresenceOverride(1, "2026-01-06", "2026-01-06", False) entries = [entry] - engine = _engine_with_overrides(*entries) + schedule = PresenceSchedule( + 1, 2, ANCHOR, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} + ) + engine = PresenceEngine( + {1: schedule}, overrides={1: entries} # caller-owned list + ) assert engine.is_present(1, datetime.date(2026, 1, 6)) is False entries.append(PresenceOverride(1, "2026-01-07", "2026-01-07", False)) # The late append must NOT change the engine's answers... -- 2.55.0 From 1bb061747122c5da0bc193dc7578ff8e53cfdff3 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:19:53 -0400 Subject: [PATCH 37/42] Next-N-present-days preview (Feature 05, task 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PresenceEngine.next_present_dates(child_id, start_date, count): walks forward from the INCLUSIVE start date through is_present, so overrides and the pattern are honoured identically — this is the preview the admin Schedule tab renders before a parent saves a custody pattern. Count must be a positive int; the scan is capped at two years (MAX_PREVIEW_SCAN_DAYS) and an always-absent child raises a clear ValueError instead of looping forever. Tests: known two-week pattern (straight five-day week, mid-week start skipping the absent week), inclusive start, override blocking the preview start, schedule-free child, input validation, always-absent error. Suite: 1002 passed. --- custom_components/nestquest/presence.py | 46 ++++++++++ tests/test_presence.py | 117 ++++++++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py index ff233b4..465c105 100644 --- a/custom_components/nestquest/presence.py +++ b/custom_components/nestquest/presence.py @@ -319,6 +319,13 @@ def cycle_week_index( return (days // 7) % schedule.cycle_length_weeks +#: Search cap for :meth:`PresenceEngine.next_present_dates`: two full +#: years of daily checks. A child absent every day (an all-absent +#: pattern with a blocking override) would otherwise loop forever; the +#: cap bounds the scan and surfaces the situation as an error instead. +MAX_PREVIEW_SCAN_DAYS = 366 * 2 + + class PresenceEngine: """Answers "is this child at this house on this date?". @@ -424,6 +431,45 @@ class PresenceEngine: week = cycle_week_index(schedule, target) return target.weekday() in schedule.pattern[week] + def next_present_dates( + self, + child_id: object, + start_date: object, + count: int, + ) -> list[datetime.date]: + """Return the next ``count`` dates the child is present. + + Walks forward from ``start_date`` (INCLUSIVE — a parent + previewing "starting Monday" expects Monday itself when the + child is present) checking :meth:`is_present` for every day, + so overrides and the pattern are honoured identically. The + admin Schedule tab renders this preview so a parent can confirm + a custody pattern is right before saving it. + + A child absent every scanned day raises ValueError after the + two-year search cap — an always-absent child would otherwise + loop forever. ``count`` must be a positive integer. + """ + _validate_child_id(child_id) + start = _parse_date(start_date, "start_date") + if isinstance(count, bool) or not isinstance(count, int): + raise ValueError(f"count must be an integer, got {count!r}") + if count < 1: + raise ValueError(f"count must be at least 1, got {count}") + found: list[datetime.date] = [] + day = start + for _ in range(MAX_PREVIEW_SCAN_DAYS): + if self.is_present(child_id, day): + found.append(day) + if len(found) == count: + return found + day += datetime.timedelta(days=1) + raise ValueError( + f"child {child_id} is not present on any of the " + f"{MAX_PREVIEW_SCAN_DAYS} days from {start.isoformat()}; " + f"{count} present day(s) cannot be previewed" + ) + def __setattr__(self, name: object, value: object) -> None: raise AttributeError( "PresenceEngine is an immutable snapshot; build a new engine " diff --git a/tests/test_presence.py b/tests/test_presence.py index ffcb7c7..6ff8d06 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -599,3 +599,120 @@ def test_engine_override_lists_are_frozen() -> None: engine._overrides[1].append(entry) with pytest.raises(AttributeError, match="immutable snapshot"): engine._overrides = {} + + +# --------------------------------------------------------------------------- +# next_present_dates: the parent's pattern preview +# --------------------------------------------------------------------------- + + +def _preview_engine() -> PresenceEngine: + """Declan: home all of week 0, away all of week 1 (anchor + 2026-01-05, a Monday); an away override Tue Jan 13 (his away week) + would matter only for the override test.""" + return PresenceEngine( + { + 1: PresenceSchedule( + 1, 2, datetime.date(2026, 1, 5), + {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}, + ) + } + ) + + +def test_preview_returns_the_next_present_days_of_a_known_pattern() -> None: + engine = _preview_engine() + # Starting Monday Jan 5 (present week 0): five straight days. + preview = engine.next_present_dates(1, "2026-01-05", 5) + assert preview == [ + datetime.date(2026, 1, 5), + datetime.date(2026, 1, 6), + datetime.date(2026, 1, 7), + datetime.date(2026, 1, 8), + datetime.date(2026, 1, 9), + ] + # Starting mid-week: the rest of week 0 first, then the absent + # week 1 is skipped and the next present week picks up. + preview = engine.next_present_dates(1, "2026-01-07", 3) + assert preview == [ + datetime.date(2026, 1, 7), + datetime.date(2026, 1, 8), + datetime.date(2026, 1, 9), + ] + preview = engine.next_present_dates(1, "2026-01-07", 6) + assert preview == [ + datetime.date(2026, 1, 7), + datetime.date(2026, 1, 8), + datetime.date(2026, 1, 9), + datetime.date(2026, 1, 14), + datetime.date(2026, 1, 15), + datetime.date(2026, 1, 16), + ] + + +def test_preview_start_date_is_inclusive() -> None: + engine = _preview_engine() + # Jan 12 (Monday of the away week) is absent: the first present day + # is Jan 19 — the start date itself when present. + preview = engine.next_present_dates(1, "2026-01-19", 1) + assert preview == [datetime.date(2026, 1, 19)] + + +def test_preview_honours_overrides() -> None: + schedule = PresenceSchedule( + 1, 2, datetime.date(2026, 1, 5), + {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}, + ) + engine = PresenceEngine( + {1: schedule}, + overrides={ + 1: [ + PresenceOverride( + 1, "2026-01-05", "2026-01-06", False, note="holiday" + ) + ] + }, + ) + # Jan 5-6 blocked by the override: the preview starts Jan 7. + preview = engine.next_present_dates(1, "2026-01-04", 3) + assert preview == [ + datetime.date(2026, 1, 7), + datetime.date(2026, 1, 8), + datetime.date(2026, 1, 9), + ] + + +def test_preview_schedule_free_child_returns_consecutive_days() -> None: + engine = PresenceEngine({}) + preview = engine.next_present_dates(3, "2026-03-01", 3) + assert preview == [ + datetime.date(2026, 3, 1), + datetime.date(2026, 3, 2), + datetime.date(2026, 3, 3), + ] + + +def test_preview_rejects_bad_inputs() -> None: + engine = _preview_engine() + with pytest.raises(ValueError, match="count must be an integer"): + engine.next_present_dates(1, "2026-01-05", "3") + with pytest.raises(ValueError, match="count must be at least 1"): + engine.next_present_dates(1, "2026-01-05", 0) + with pytest.raises(ValueError, match="child_id must be an integer"): + engine.next_present_dates(True, "2026-01-05", 1) + with pytest.raises(ValueError, match="plain calendar date"): + engine.next_present_dates(1, datetime.datetime(2026, 1, 5), 1) + + +def test_preview_always_absent_child_raises_not_loops() -> None: + """A child absent every scanned day must surface as an error, not + hang the admin preview.""" + engine = PresenceEngine( + { + 1: PresenceSchedule( + 1, 1, datetime.date(2026, 1, 5), {0: set()} + ) + } + ) + with pytest.raises(ValueError, match="not present on any"): + engine.next_present_dates(1, "2026-01-05", 2) -- 2.55.0 From e9dc0a144564050bad7234f304b472dbfc5749ed Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:20:42 -0400 Subject: [PATCH 38/42] Test fix: week 0 runs Mon Jan 5 through Sun Jan 11 The six-day preview expectation missed that Jan 10-11 are still in the present week (the away week starts Jan 12). Suite green before commit: 1003 passed. --- tests/test_presence.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_presence.py b/tests/test_presence.py index 6ff8d06..250c7a8 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -640,13 +640,15 @@ def test_preview_returns_the_next_present_days_of_a_known_pattern() -> None: datetime.date(2026, 1, 9), ] preview = engine.next_present_dates(1, "2026-01-07", 6) + # Week 0 runs Mon Jan 5 .. Sun Jan 11 (all present); the away week + # (Jan 12-18) is skipped; week 2 resumes Jan 19. assert preview == [ datetime.date(2026, 1, 7), datetime.date(2026, 1, 8), datetime.date(2026, 1, 9), - datetime.date(2026, 1, 14), - datetime.date(2026, 1, 15), - datetime.date(2026, 1, 16), + datetime.date(2026, 1, 10), + datetime.date(2026, 1, 11), + datetime.date(2026, 1, 19), ] -- 2.55.0 From 20d7efb04bf96e4515eb88ca3bb42b506f102f00 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:25:32 -0400 Subject: [PATCH 39/42] Year-boundary and leap-year regression guard (Feature 05, task 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The week-parity regression test (D-004): walks a two-week alternating rotation day by day across a FULL 366-day leap year (2020) and a full 365-day year with 53 ISO weeks (2015), asserting the present-day sequence never breaks or inverts, including January 1st. The expectation is derived from the anchor's 7-day blocks — each block uniform, consecutive blocks alternating — not from the engine's own index formula, so a parity-based implementation that resets on January 1st breaks block uniformity or alternation (most visibly in the block spanning the boundary). Year facts asserted: 2020 = 366 days and ISO week 53; 2015 = 365 days and ISO week 53. The test docstring names itself as the regression guard. Suite: 1004 passed. --- tests/test_presence.py | 74 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/test_presence.py b/tests/test_presence.py index 250c7a8..d545548 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -718,3 +718,77 @@ def test_preview_always_absent_child_raises_not_loops() -> None: ) with pytest.raises(ValueError, match="not present on any"): engine.next_present_dates(1, "2026-01-05", 2) + + +# --------------------------------------------------------------------------- +# The week-parity regression guard (Feature 05, D-004) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("year", "length", "reason"), + [ + (2020, 366, "leap year, 53 ISO weeks"), + (2015, 365, "365 days, 53 ISO weeks"), + ], +) +def test_custody_rotation_walks_full_years_without_inversion( + year, length, reason +) -> None: + """REGRESSION GUARD for the week-parity bug (D-004). + + Walks a two-week alternating rotation day by day across a FULL + {length}-day year ({year} — {reason}), asserting the present/absent + sequence never breaks or inverts at any point, including January + 1st. ISO week parity silently flips in 53-ISO-week years and would + invert every custody schedule on January 1st; anchor-date + arithmetic must not. + """.format(length=length, year=year, reason=reason) + if year == 2020: + assert datetime.date(2020, 12, 31).isocalendar()[1] == 53 + assert (datetime.date(2021, 1, 1) - datetime.date(2020, 1, 1)).days == 366 + if year == 2015: + assert datetime.date(2015, 12, 31).isocalendar()[1] == 53 + assert (datetime.date(2016, 1, 1) - datetime.date(2015, 1, 1)).days == 365 + + anchor = datetime.date(year, 1, 6) # a Monday of an on-week + rotation = PresenceSchedule( + 1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} + ) + engine = PresenceEngine({1: rotation}) + + # Walk EVERY day of the year. The expectation is derived from the + # anchor's 7-day BLOCKS, not from the engine: each block of seven + # days starting at the anchor is uniformly present (even block + # index) or uniformly absent (odd block index), and consecutive + # blocks alternate. A parity-based implementation resets on + # January 1st and breaks either the uniformity of a block or the + # alternation between them — most visibly in the block spanning + # December 31st -> January 1st of a 53-ISO-week year. + day = datetime.date(year, 1, 1) + days_seen = 0 + while day.year == year: + days_since_anchor = (day - anchor).days + block = days_since_anchor // 7 # which 7-day block of the cycle + block_start = anchor + datetime.timedelta(days=block * 7) + # Uniformity within the block: every day of this 7-day block + # must answer exactly like the block's first day. + expected = ((block % 2) == 0) + assert engine.is_present(1, day) is expected, ( + f"{day} (block {block}, day offset " + f"{(day - block_start).days}): present=" + f"{engine.is_present(1, day)}, expected {expected}" + ) + # The block's OTHER days (even outside the year) must agree — + # this is what catches an inversion hidden at a year boundary. + for offset in range(7): + block_day = block_start + datetime.timedelta(days=offset) + if block_day.year == year: + assert engine.is_present(1, block_day) is expected, ( + f"{block_day} disagrees with its block {block}" + ) + days_seen += 1 + day += datetime.timedelta(days=1) + assert days_seen == length, ( + f"walked {days_seen} days of {year}, expected {length}" + ) -- 2.55.0 From a18c0060e4af88ab563bf886b9fd2e79b94c8d31 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:28:39 -0400 Subject: [PATCH 40/42] Review fix: extend the parity walk through the following January Review round 1 (Codex gpt-5.6-terra, MEDIUM): the walk stopped at December 31st, so the boundary block's next-January days went unchecked and a year-resetting implementation would pass. The walk now runs through January 14th of the following year, asserting block uniformity across the December 31st -> January 1st transition (including the post-53-week jump), and the walked-year day count is verified independently of the walk. Suite: 1005 passed. --- tests/test_presence.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/tests/test_presence.py b/tests/test_presence.py index d545548..dcfe73b 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -765,9 +765,16 @@ def test_custody_rotation_walks_full_years_without_inversion( # January 1st and breaks either the uniformity of a block or the # alternation between them — most visibly in the block spanning # December 31st -> January 1st of a 53-ISO-week year. - day = datetime.date(year, 1, 1) + # Walk the FULL year AND the first two weeks of the following + # January: the block spanning December 31st -> January 1st must + # stay uniform ACROSS the boundary, so an implementation that + # resets when the target year differs from the anchor's fails + # here (the post-boundary days of its final block disagree). + first_day = datetime.date(year, 1, 1) + last_day = datetime.date(year + 1, 1, 14) days_seen = 0 - while day.year == year: + day = first_day + while day <= last_day: days_since_anchor = (day - anchor).days block = days_since_anchor // 7 # which 7-day block of the cycle block_start = anchor + datetime.timedelta(days=block * 7) @@ -779,16 +786,22 @@ def test_custody_rotation_walks_full_years_without_inversion( f"{(day - block_start).days}): present=" f"{engine.is_present(1, day)}, expected {expected}" ) - # The block's OTHER days (even outside the year) must agree — - # this is what catches an inversion hidden at a year boundary. + # The block's OTHER days must agree — including the days on + # the far side of January 1st. for offset in range(7): block_day = block_start + datetime.timedelta(days=offset) - if block_day.year == year: + if first_day <= block_day <= last_day: assert engine.is_present(1, block_day) is expected, ( f"{block_day} disagrees with its block {block}" ) days_seen += 1 day += datetime.timedelta(days=1) - assert days_seen == length, ( - f"walked {days_seen} days of {year}, expected {length}" + # The walked year itself contributed exactly its full length. + year_days = sum( + 1 + for offset in range((last_day - first_day).days + 1) + if (first_day + datetime.timedelta(days=offset)).year == year + ) + assert year_days == length, ( + f"walked {year_days} days of {year}, expected {length}" ) -- 2.55.0 From fec607f039f3e58a7e88ae7411959f7e7844cdbe Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:31:38 -0400 Subject: [PATCH 41/42] Review fixes: correct 2015 anchor weekday and ISO rollover facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 (Codex gpt-5.6-terra, 2 LOW): 2015-01-06 is a Tuesday, not a Monday — the anchor is now the computed first Monday of January (asserted weekday 0); the docstring names the ISO-week rollover precisely (the Monday on or after January 4th — January 4th in both walked years, which end ISO week 53) instead of 'January 1st'. Suite: 1005 passed. --- tests/test_presence.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/test_presence.py b/tests/test_presence.py index dcfe73b..14db444 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -738,10 +738,12 @@ def test_custody_rotation_walks_full_years_without_inversion( """REGRESSION GUARD for the week-parity bug (D-004). Walks a two-week alternating rotation day by day across a FULL - {length}-day year ({year} — {reason}), asserting the present/absent - sequence never breaks or inverts at any point, including January - 1st. ISO week parity silently flips in 53-ISO-week years and would - invert every custody schedule on January 1st; anchor-date + {length}-day year ({year} — {reason}) plus the following January, + asserting the present/absent sequence never breaks or inverts at + any point, including January 1st. ISO week parity silently flips + at the ISO-week rollover (the Monday on or after January 4th — + January 4th itself in both of these years, which end ISO week 53) + and would invert every custody schedule there; anchor-date arithmetic must not. """.format(length=length, year=year, reason=reason) if year == 2020: @@ -751,7 +753,12 @@ def test_custody_rotation_walks_full_years_without_inversion( assert datetime.date(2015, 12, 31).isocalendar()[1] == 53 assert (datetime.date(2016, 1, 1) - datetime.date(2015, 1, 1)).days == 365 - anchor = datetime.date(year, 1, 6) # a Monday of an on-week + # The first Monday of January (2020: Jan 6; 2015: Jan 5) — an + # on-week anchor, whatever the year. + anchor = datetime.date(year, 1, 1) + datetime.timedelta( + days=(7 - datetime.date(year, 1, 1).weekday()) % 7 + ) + assert anchor.weekday() == 0 rotation = PresenceSchedule( 1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} ) -- 2.55.0 From b2f55ffb90b34f88b244bca98e1da133116fbf55 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 19:05:37 -0400 Subject: [PATCH 42/42] release: advance version to 0.4.0 and add Features 14/03/05 release notes --- RELEASE_NOTES.md | 44 +++++++++++++++++++++++ custom_components/nestquest/manifest.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 6de8b0c..45cdc0b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,49 @@ # NestQuest Release Notes +## Version 0.4.0 — 2026-09-15 + +### Scope + +Fourth release of NestQuest, shipping three completed features on top of the data layer and recurrence engine: + +- **Feature 14 — quest domain model (BREAKING rename, D-007/D-008):** `task_definitions`/`task_instances` are now `quest_definitions`/`quest_instances` everywhere; definitions are multi-assignee (`quest_definition_assignees`, no single child column) and multi-window (`quest_definition_windows` — morning/afternoon/evening, clock ranges in `const.py`); the instance key widens to `(definition_id, child_id, due_date, window)` so twice-daily and shared quests materialise one instance per child per window per day; `completion_events` gains `actor_child_id` (the tapped panel profile; admin events stay NULL) with a second actor-pair CHECK. Schema version advances to **6**. +- **Feature 03 — children registry & admin allowlist:** typed business layer for child profiles (create/edit with trimmed required names and duplicate-name warnings, whole-table reorder, deactivation as the only removal path — history is never touched); the admin allowlist with a fail-closed `is_admin` resolver (empty allowlist denies everyone; the last admin cannot be removed); automatic seeding on first setup (the config-flow user is persisted in entry data, with the options-flow list and HA owner accounts as fallbacks) so the owner is never locked out; the options flow gains a multi-select picker of existing Home Assistant users. +- **Feature 05 — presence & custody engine:** a pure `PresenceSchedule`/`PresenceOverride` model with total validation and lossless round-trips; anchor-date cycle arithmetic (D-004 — never ISO week numbers or parity); `PresenceEngine.is_present` with the always-present default for schedule-free children; date overrides beat the repeating pattern; a `next_present_dates` preview for the admin schedule UI; a full-year regression guard walking 2020 (366 days, ISO week 53) and 2015 (365 days, ISO week 53) proving the rotation never inverts at a year boundary. + +### Requirements + +- Minimum Home Assistant version: **2024.6.0**. + +### Breaking changes + +- The database schema advances from version 1 to version 6. Upgrades are automatic and data-preserving (legacy `task_*` tables are renamed; the definitions table is rebuilt for multi-assignee; legacy instances pin to the morning window; panel events backfill `actor_child_id`). **Back up `nestquest.db` before upgrading** — see Rollback. +- Dev installs from before 2026-09-15 migrate automatically on first start. + +### Known Limitations + +- Still no entities, services, calendar platforms, or panel cards: nothing is user-visible in Home Assistant yet (Features 06-10 land the business layer, materialisation, permission gate, and entity surface). +- No materialisation yet: instances are not generated from definitions + presence yet (Feature 07). +- Single-instance only. + +### Rollback + +**Back up `nestquest.db` (plus `-wal`/`-shm` sidecars) BEFORE upgrading.** This release migrates the database to schema version 6 with renamed tables; an older NestQuest build refuses a version-6 database ("newer than this integration understands") and cannot read renamed tables at all — a plain downgrade is NOT possible once the database has been migrated. + +**Rollback procedure (after a migrated database):** + +1. Stop Home Assistant. +2. Restore the pre-upgrade `nestquest.db` backup (and sidecars) you made before upgrading. +3. Downgrade the integration (HACS > Integrations > NestQuest > Redownload > 0.3.0) or replace `custom_components/nestquest/` manually. +4. Restart Home Assistant. + +Without a backup, the only path is staying on 0.4.0 (recommended) or deleting the integration and its database to start fresh (Settings > Devices & Services > NestQuest > Delete, then remove `nestquest.db`). + +**Repository operators:** instead of uninstalling, git-revert the release merge on `main` and restore the pre-upgrade database. + +## Version 0.3.0 — 2026-09-14 + +### Scope + ## Version 0.3.0 — 2026-09-14 ### Scope diff --git a/custom_components/nestquest/manifest.json b/custom_components/nestquest/manifest.json index ffef4b2..f65c64c 100644 --- a/custom_components/nestquest/manifest.json +++ b/custom_components/nestquest/manifest.json @@ -10,5 +10,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/talon2king/NestQuest/issues", "requirements": [], - "version": "0.3.0" + "version": "0.4.0" } diff --git a/pyproject.toml b/pyproject.toml index cd7c2c2..49140f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nestquest" -version = "0.3.0" +version = "0.4.0" description = "NestQuest Home Assistant Integration" readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 13bd8ec..164d325 100644 --- a/uv.lock +++ b/uv.lock @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "nestquest" -version = "0.3.0" +version = "0.4.0" source = { virtual = "." } [package.dev-dependencies] -- 2.55.0