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.
This commit is contained in:
Joshua King
2026-09-15 09:35:21 -04:00
parent 3ecca2d212
commit c988f6508b
11 changed files with 546 additions and 222 deletions
+28 -28
View File
@@ -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]
+23 -23
View File
@@ -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),
)
+84 -13
View File
@@ -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
+11 -11
View File
@@ -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,
]
+15 -15
View File
@@ -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)
+13 -13
View File
@@ -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,)
+25 -25
View File
@@ -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, (
+2 -2
View File
@@ -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)
+283 -32
View File
@@ -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:
+56 -56
View File
@@ -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}"))
+6 -4
View File
@@ -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