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