Multi-assignee definitions via quest_definition_assignees (D-008)

Per IMPLEMENTATION-ORDER.md 1.3: a quest_definition_assignees table
(definition_id, child_id) with a composite primary key and FKs to
quest_definitions and children replaces the single child_id column on
quest_definitions.

- schema.py: definitions DDL loses child_id; new assignees table joins
  SCHEMA_V1_STATEMENTS; the definitions table body is extracted to
  QUEST_DEFINITIONS_TABLE_SQL so the rebuild migration cannot drift
  from the canonical shape.
- migrations.py: callable migrations now own their transaction and
  stamp via stamp_schema_version(database, version), because the
  rebuild must bracket itself with PRAGMA foreign_keys OFF/ON. New
  migration 3 creates the assignees table and, when a pre-D-008
  quest_definitions still carries child_id, rebuilds the table via
  SQLite's canonical procedure (create, copy, move assignees, drop,
  rename), verifies PRAGMA foreign_key_check inside the transaction,
  and stamps. Migration 2 converted to the new callable contract.
- dao_rules.py: QuestDefinitionsDao.create drops the child_id argument
  and applies assignee_child_ids atomically; set_assignee is replaced
  by add_assignee (idempotent, active-child validated under the
  connection lock), remove_assignee and list_assignees; list_by_child
  reads through the assignees table.
- dao_instances.py: upsert validates the child against
  quest_definition_assignees instead of the definitions column.
- tests: schema shape tests for the assignees table (columns, PK, FKs,
  duplicate/unknown constraints); DAO tests rewritten for the new
  assignment API; migration tests for the v2 -> v3 rebuild (rows and
  instances survive, child_id gone, foreign_key_check clean).
This commit is contained in:
Joshua King
2026-09-15 09:59:29 -04:00
parent c988f6508b
commit 25f0a43e16
9 changed files with 883 additions and 261 deletions
+15 -14
View File
@@ -82,7 +82,7 @@ async def _prepare(path) -> tuple:
child = await children.create("Ada", _now_stamp())
rule = await rules.create("daily", D1)
definition = await definitions.create(
"Brush teeth", child.id, rule.id, _now_stamp()
"Brush teeth", rule.id, _now_stamp(), assignee_child_ids=[child.id]
)
return (
database,
@@ -184,17 +184,15 @@ def test_upsert_rejects_unknown_definition_and_child(tmp_path) -> None:
with pytest.raises(ValueError, match="definition 999"):
await instances.upsert(999, child.id, D1, _now_stamp())
# Unknown child: the definition-assignee check fires first and
# is the one that matters (an instance must carry its
# definition's assignee). To reach the child-existence check
# at all, the definition would have to claim child 999 — an
# FK-impossible state — so the child check only guards direct
# callers with a matching fake assignee; assert the guard
# order instead of forcing the FK-impossible state.
# is the one that matters (an instance must carry an assigned
# child). Under D-008 the assignee check is a membership test
# against quest_definition_assignees, so child 999 simply is
# not an assignee — the error names the pair.
rule = await rules.create("daily", D1)
other_definition = await definitions.create(
"X", child.id, rule.id, _now_stamp()
"X", rule.id, _now_stamp(), assignee_child_ids=[child.id]
)
with pytest.raises(ValueError, match="not 999"):
with pytest.raises(ValueError, match="not assigned to child 999"):
await instances.upsert(other_definition.id, 999, D1, _now_stamp())
return None
@@ -329,7 +327,10 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
child = await children.create("Ada", _now_stamp())
rule = await rules.create("daily", D1)
definition = await definitions.create(
"Brush teeth", child.id, rule.id, _now_stamp()
"Brush teeth",
rule.id,
_now_stamp(),
assignee_child_ids=[child.id],
)
return await _body(
database, children, rules, definitions, instances,
@@ -406,7 +407,7 @@ def test_list_by_child_and_date_filters(tmp_path) -> None:
other = await children.create("Bo", _now_stamp())
rule = await rules.create("daily", D1)
other_definition = await definitions.create(
"Make bed", other.id, rule.id, _now_stamp()
"Make bed", rule.id, _now_stamp(), assignee_child_ids=[other.id]
)
await instances.upsert(definition.id, child.id, D1, _now_stamp())
await instances.upsert(definition.id, child.id, D2, _now_stamp())
@@ -537,8 +538,8 @@ def test_delete_future_uncompleted_other_definitions_untouched(
async def _body(database, children, rules, definitions, instances,
events, child, definition):
other = await definitions.create(
"Make bed", child.id, (await rules.create("daily", D1)).id,
_now_stamp(),
"Make bed", (await rules.create("daily", D1)).id,
_now_stamp(), assignee_child_ids=[child.id],
)
await instances.upsert(definition.id, child.id, D20, _now_stamp())
await instances.upsert(other.id, child.id, D20, _now_stamp())
@@ -1090,7 +1091,7 @@ def test_append_concurrent_events_serialize(tmp_path) -> None:
child = await children.create("Ada", _now_stamp())
rule = await rules.create("daily", D1)
definition = await definitions.create(
"Brush teeth", child.id, rule.id, _now_stamp()
"Brush teeth", rule.id, _now_stamp(), assignee_child_ids=[child.id]
)
return (database, children, rules, definitions, instances,
events, child, definition)
+21 -4
View File
@@ -71,6 +71,7 @@ ALL_TABLES = (
"admin_users",
"schedule_rules",
"quest_definitions",
"quest_definition_assignees",
"presence_schedules",
"presence_overrides",
"quest_instances",
@@ -99,7 +100,10 @@ class World:
assert await self.admins.add("user-1", _stamp()) is True
self.rule = await self.rules.create("daily", D1)
self.definition = await self.definitions.create(
"Brush teeth", self.child.id, self.rule.id, _stamp()
"Brush teeth",
self.rule.id,
_stamp(),
assignee_child_ids=[self.child.id],
)
self.schedule = await self.schedules.upsert_by_child(
self.child.id, 2, D1, "0,2,4|1,3"
@@ -206,6 +210,9 @@ def test_matrix_quest_definitions_crud_and_constraints(tmp_path) -> None:
# insert (seeded) + read
fetched = await w.definitions.get(w.definition.id)
assert fetched.title == "Brush teeth"
assert [c.id for c in await w.definitions.list_assignees(
w.definition.id
)] == [w.child.id]
# update
await w.definitions.update(w.definition.id, title="Brush TEETH")
assert (await w.definitions.get(w.definition.id)).title == (
@@ -213,11 +220,21 @@ def test_matrix_quest_definitions_crud_and_constraints(tmp_path) -> None:
)
# constraint: FK to a nonexistent child rejected with ValueError
with pytest.raises(ValueError, match="does not exist"):
await w.definitions.create("X", 999, w.rule.id, _stamp())
# constraint: inactive child rejected
await w.definitions.create(
"X", w.rule.id, _stamp(), assignee_child_ids=[999]
)
# constraint: inactive child rejected on assignment
await w.children.set_active(w.child2.id, False)
with pytest.raises(ValueError, match="inactive"):
await w.definitions.set_assignee(w.definition.id, w.child2.id)
await w.definitions.add_assignee(w.definition.id, w.child2.id)
# assignee removal + re-adding the active child
assert await w.definitions.remove_assignee(
w.definition.id, w.child.id
) is True
assert await w.definitions.remove_assignee(
w.definition.id, w.child.id
) is False
await w.definitions.add_assignee(w.definition.id, w.child.id)
# set_active: deactivate, never delete
assert await w.definitions.set_active(w.definition.id, False) == 1
assert (await w.definitions.get(w.definition.id)).is_active is False
+148 -60
View File
@@ -266,7 +266,7 @@ def test_rule_delete_unreferenced_succeeds(tmp_path) -> None:
def test_rule_delete_referenced_is_rejected(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
await definitions.create("Brush teeth", child.id, rule.id, NOW)
await definitions.create("Brush teeth", rule.id, NOW)
assert await rules.delete_if_unreferenced(rule.id) is False
fetched = await rules.get(rule.id)
assert fetched is not None, (
@@ -288,7 +288,7 @@ def test_rule_delete_after_last_reference_removed_succeeds(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create(
"Brush teeth", child.id, rule.id, NOW
"Brush teeth", rule.id, NOW
)
assert await rules.delete_if_unreferenced(rule.id) is False
await definitions.set_active(definition.id, False)
@@ -335,12 +335,12 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
import custom_components.nestquest.dao_rules as dao_rules
original_validate = (
dao_rules.QuestDefinitionsDao._validate_assignable
dao_rules.QuestDefinitionsDao._validate_rule_exists
)
release = asyncio.Event()
started = asyncio.Event()
async def _pausing_validate(self, child_id, rule_id):
async def _pausing_validate(self, rule_id):
# Run the real validation FIRST (this is the
# time-of-check), THEN pause: the delete task gets
# scheduled while the create sits between its
@@ -348,19 +348,17 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
# transaction must keep the delete queued until the
# insert commits, so the create must win the race
# and the delete must be rejected as referenced.
await original_validate(self, child_id, rule_id)
await original_validate(self, rule_id)
if rule_id == rule_a.id and not started.is_set():
started.set()
await release.wait()
dao_rules.QuestDefinitionsDao._validate_assignable = (
dao_rules.QuestDefinitionsDao._validate_rule_exists = (
_pausing_validate
)
try:
create_task = asyncio.ensure_future(
definitions.create(
"Brush teeth", child.id, rule_a.id, NOW
)
definitions.create("Brush teeth", rule_a.id, NOW)
)
await started.wait()
delete_task = asyncio.ensure_future(
@@ -382,7 +380,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
async def _create():
return await definitions.create(
"Brush teeth", child.id, rule_a.id, NOW
"Brush teeth", rule_a.id, NOW
)
delete_task = asyncio.ensure_future(_delete())
@@ -434,20 +432,24 @@ def test_definition_create_returns_typed_record(tmp_path) -> None:
rule = await rules.create("weekly", "2026-09-14", weekday_set="0,2")
definition = await definitions.create(
"Brush teeth",
child.id,
rule.id,
NOW,
description="Morning and night",
icon="mdi:tooth",
due_time="08:00",
assignee_child_ids=[child.id],
)
assert isinstance(definition, QuestDefinitionRecord)
assert definition.title == "Brush teeth"
assert definition.child_id == child.id
assert definition.schedule_rule_id == rule.id
assert definition.due_time == "08:00"
assert definition.is_active is True
assert definition.created_at == NOW
assert not hasattr(definition, "child_id"), (
"D-008: assignment lives in quest_definition_assignees"
)
assignees = await definitions.list_assignees(definition.id)
assert [c.id for c in assignees] == [child.id]
return definition
_with_db(tmp_path, "definition-create.db")(_body)
@@ -460,26 +462,30 @@ def test_definition_get_missing_returns_none(tmp_path) -> None:
assert _with_db(tmp_path, "definition-get-missing.db")(_body) is None
def test_definition_create_unknown_child_raises_value_error(
def test_definition_create_unknown_assignee_raises_value_error(
tmp_path,
) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
with pytest.raises(ValueError, match="does not exist"):
await definitions.create("X", 999, rule.id, NOW)
await definitions.create(
"X", rule.id, NOW, assignee_child_ids=[999]
)
return None
_with_db(tmp_path, "definition-bad-child.db")(_body)
def test_definition_create_inactive_child_raises_value_error(
def test_definition_create_inactive_assignee_raises_value_error(
tmp_path,
) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
await children.set_active(child.id, False)
with pytest.raises(ValueError, match="inactive"):
await definitions.create("X", child.id, rule.id, NOW)
await definitions.create(
"X", rule.id, NOW, assignee_child_ids=[child.id]
)
return None
_with_db(tmp_path, "definition-inactive-child.db")(_body)
@@ -490,7 +496,7 @@ def test_definition_create_unknown_rule_raises_value_error(
) -> None:
async def _body(database, rules, definitions, children, child):
with pytest.raises(ValueError, match="does not exist"):
await definitions.create("X", child.id, 999, NOW)
await definitions.create("X", 999, NOW)
return None
_with_db(tmp_path, "definition-bad-rule.db")(_body)
@@ -505,9 +511,11 @@ def test_definition_list_by_child_filters_and_orders(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
other_child = await children.create("Bo", NOW)
await definitions.create("A", child.id, rule.id, NOW)
await definitions.create("B", child.id, rule.id, NOW)
await definitions.create("C", other_child.id, rule.id, NOW)
await definitions.create("A", rule.id, NOW, assignee_child_ids=[child.id])
await definitions.create("B", rule.id, NOW, assignee_child_ids=[child.id])
await definitions.create(
"C", rule.id, NOW, assignee_child_ids=[other_child.id]
)
mine = await definitions.list_by_child(child.id)
assert [d.title for d in mine] == ["B", "A"]
return mine
@@ -527,8 +535,8 @@ def test_definition_list_by_child_missing_child_returns_empty(
def test_definition_list_active_excludes_inactive(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
first = await definitions.create("A", child.id, rule.id, NOW)
await definitions.create("B", child.id, rule.id, NOW)
first = await definitions.create("A", rule.id, NOW)
await definitions.create("B", rule.id, NOW)
await definitions.set_active(first.id, False)
active = await definitions.list_active()
assert [d.title for d in active] == ["B"]
@@ -547,7 +555,7 @@ def test_definition_update_fields_round_trip(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create(
"A", child.id, rule.id, NOW, due_time="08:00"
"A", rule.id, NOW, due_time="08:00"
)
assert await definitions.update(
definition.id,
@@ -572,9 +580,11 @@ def test_definition_update_cannot_change_assignee_or_active(
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
other = await children.create("Bo", NOW)
definition = await definitions.create("A", child.id, rule.id, NOW)
definition = await definitions.create(
"A", rule.id, NOW, assignee_child_ids=[child.id]
)
# title= / child_id= / is_active= are NOT update() parameters;
# this asserts the API shape: reassignment and activation have
# this asserts the API shape: assignment and activation have
# their own explicit methods.
import inspect
@@ -590,7 +600,10 @@ def test_definition_update_cannot_change_assignee_or_active(
definition.id, title="A2"
) == 1
fetched = await definitions.get(definition.id)
assert fetched.child_id == child.id
assert [c.id for c in await definitions.list_assignees(definition.id)] == [
child.id
]
assert other.id not in [c.id for c in await definitions.list_assignees(definition.id)]
assert fetched.is_active is True
return fetched
@@ -600,7 +613,7 @@ def test_definition_update_cannot_change_assignee_or_active(
def test_definition_update_no_fields_returns_zero(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create("A", child.id, rule.id, NOW)
definition = await definitions.create("A", rule.id, NOW)
return await definitions.update(definition.id)
assert _with_db(tmp_path, "definition-update-empty.db")(_body) == 0
@@ -612,7 +625,6 @@ def test_definition_update_can_clear_optional_fields(tmp_path) -> None:
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create(
"A",
child.id,
rule.id,
NOW,
description="d",
@@ -646,7 +658,7 @@ def test_definition_update_missing_returns_zero(tmp_path) -> None:
def test_definition_set_active_round_trip(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create("A", child.id, rule.id, NOW)
definition = await definitions.create("A", rule.id, NOW)
assert await definitions.set_active(definition.id, False) == 1
fetched = await definitions.get(definition.id)
assert fetched.is_active is False
@@ -683,76 +695,152 @@ def test_definition_no_delete_method_exists(tmp_path) -> None:
_with_db(tmp_path, "definition-no-delete.db")(_body)
def test_definition_set_assignee_round_trip(tmp_path) -> None:
def test_definition_add_assignee_round_trip(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
other = await children.create("Bo", NOW)
definition = await definitions.create("A", child.id, rule.id, NOW)
assert await definitions.set_assignee(definition.id, other.id) == 1
fetched = await definitions.get(definition.id)
assert fetched.child_id == other.id
return fetched
definition = await definitions.create(
"A", rule.id, NOW, assignee_child_ids=[child.id]
)
await definitions.add_assignee(definition.id, other.id)
assignees = await definitions.list_assignees(definition.id)
assert sorted(c.id for c in assignees) == sorted([child.id, other.id])
# Idempotent: re-adding an existing assignee stays a no-op.
await definitions.add_assignee(definition.id, other.id)
assignees = await definitions.list_assignees(definition.id)
assert sorted(c.id for c in assignees) == sorted([child.id, other.id])
return assignees
_with_db(tmp_path, "definition-set-assignee.db")(_body)
_with_db(tmp_path, "definition-add-assignee.db")(_body)
def test_definition_set_assignee_inactive_child_raises(tmp_path) -> None:
def test_definition_add_assignee_inactive_child_raises(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
other = await children.create("Bo", NOW)
definition = await definitions.create("A", child.id, rule.id, NOW)
definition = await definitions.create(
"A", rule.id, NOW, assignee_child_ids=[child.id]
)
await children.set_active(other.id, False)
with pytest.raises(ValueError, match="inactive"):
await definitions.set_assignee(definition.id, other.id)
fetched = await definitions.get(definition.id)
assert fetched.child_id == child.id, (
"failed reassignment must leave the current assignee intact"
await definitions.add_assignee(definition.id, other.id)
assignees = await definitions.list_assignees(definition.id)
assert [c.id for c in assignees] == [child.id], (
"failed assignment must leave the current assignees intact"
)
return fetched
return assignees
_with_db(tmp_path, "definition-assignee-inactive.db")(_body)
def test_definition_set_assignee_unknown_child_raises(tmp_path) -> None:
def test_definition_add_assignee_unknown_child_raises(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create("A", child.id, rule.id, NOW)
definition = await definitions.create(
"A", rule.id, NOW, assignee_child_ids=[child.id]
)
with pytest.raises(ValueError, match="does not exist"):
await definitions.set_assignee(definition.id, 999)
await definitions.add_assignee(definition.id, 999)
return None
_with_db(tmp_path, "definition-assignee-unknown.db")(_body)
def test_definition_set_assignee_missing_definition_returns_zero(
def test_definition_add_assignee_missing_definition_raises(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
with pytest.raises(ValueError, match="does not exist"):
await definitions.add_assignee(999, child.id)
return None
_with_db(tmp_path, "definition-assignee-missing.db")(_body)
def test_definition_remove_assignee_round_trip(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
other = await children.create("Bo", NOW)
definition = await definitions.create(
"A", rule.id, NOW, assignee_child_ids=[child.id, other.id]
)
assert await definitions.remove_assignee(definition.id, other.id) is True
assignees = await definitions.list_assignees(definition.id)
assert [c.id for c in assignees] == [child.id]
# Removing a non-assignee reports False.
assert await definitions.remove_assignee(definition.id, other.id) is False
return assignees
_with_db(tmp_path, "definition-remove-assignee.db")(_body)
def test_definition_list_assignees_orders_by_child_sort_order(
tmp_path,
) -> None:
async def _body(database, rules, definitions, children, child):
other = await children.create("Bo", NOW)
return await definitions.set_assignee(999, other.id)
rule = await rules.create("daily", "2026-09-14")
second = await children.create("Bo", NOW)
third = await children.create("Cleo", NOW)
await children.reorder([third.id, child.id, second.id])
definition = await definitions.create(
"A", rule.id, NOW, assignee_child_ids=[second.id, child.id, third.id]
)
assignees = await definitions.list_assignees(definition.id)
assert [c.id for c in assignees] == [third.id, child.id, second.id]
return assignees
assert _with_db(tmp_path, "definition-assignee-missing.db")(_body) == 0
_with_db(tmp_path, "definition-assignees-order.db")(_body)
def test_definition_set_assignee_leaves_history_rows_alone(
def test_definition_unassigned_has_no_assignees(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create("A", rule.id, NOW)
assert await definitions.list_assignees(definition.id) == []
return None
_with_db(tmp_path, "definition-no-assignees.db")(_body)
def test_definition_shared_by_multiple_children_via_list_by_child(
tmp_path,
) -> None:
"""Reassignment writes the definition row only: any instances that
already exist keep their generation-time child_id (Feature 06
"""D-008: one definition covering several children appears for
every assignee through list_by_child, once each."""
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
second = await children.create("Bo", NOW)
definition = await definitions.create(
"A", rule.id, NOW, assignee_child_ids=[child.id, second.id]
)
for assignee in (child, second):
mine = await definitions.list_by_child(assignee.id)
assert [d.id for d in mine] == [definition.id]
return definition
_with_db(tmp_path, "definition-shared.db")(_body)
def test_definition_assignment_leaves_history_rows_alone(
tmp_path,
) -> None:
"""Assignment edits write the assignees table only: any instances
that already exist keep their generation-time child_id (Feature 06
guardrail 'changes future instances only').
"""
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
other = await children.create("Bo", NOW)
definition = await definitions.create("A", child.id, rule.id, NOW)
definition = await definitions.create(
"A", rule.id, NOW, assignee_child_ids=[child.id]
)
# Simulate an already-generated instance (materialization owns
# this table, but its row shape proves reassignment isolation).
# this table, but its row shape proves assignment isolation).
await database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"due_date, generated_at) VALUES (?, ?, ?, ?)",
(definition.id, child.id, "2026-09-15", NOW),
)
await definitions.set_assignee(definition.id, other.id)
await definitions.add_assignee(definition.id, other.id)
await definitions.remove_assignee(definition.id, child.id)
row = await database.fetch_one(
"SELECT child_id FROM quest_instances WHERE definition_id = ?",
(definition.id,),
@@ -801,7 +889,7 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None:
}
sql_pattern = re.compile(
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?"
r"(schedule_rules|quest_definitions)\b",
r"(schedule_rules|quest_definitions|quest_definition_assignees)\b",
re.IGNORECASE,
)
offenders: list[str] = []
@@ -813,7 +901,7 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None:
if sql_pattern.search(py.read_text()):
offenders.append(relative)
assert offenders == [], (
f"SQL touching schedule_rules/quest_definitions leaked into: "
f"SQL touching schedule_rules/quest_definitions/assignees leaked: "
f"{offenders}"
)
@@ -853,7 +941,7 @@ def test_dao_instances_test_file_uses_dao_not_raw_rules_sql() -> None:
)
sql_pattern = re.compile(
r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|"
r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions)\b",
r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions|quest_definition_assignees)\b",
re.IGNORECASE,
)
assert sql_pattern.search(remaining) is None, (
@@ -898,7 +986,7 @@ def test_dao_rules_test_file_uses_dao_not_raw_table_sql() -> None:
)
sql_pattern = re.compile(
r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|"
r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions)\b",
r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions|quest_definition_assignees)\b",
re.IGNORECASE,
)
assert sql_pattern.search(remaining) is None, (
+168 -13
View File
@@ -63,6 +63,7 @@ def _counts(database) -> dict[str, int]:
"admin_users",
"schedule_rules",
"quest_definitions",
"quest_definition_assignees",
"presence_schedules",
"presence_overrides",
"quest_instances",
@@ -99,6 +100,7 @@ def test_fresh_file_migration_creates_all_v1_tables(tmp_path) -> None:
"admin_users",
"schedule_rules",
"quest_definitions",
"quest_definition_assignees",
"presence_schedules",
"presence_overrides",
"quest_instances",
@@ -136,15 +138,18 @@ def test_empty_version_table_reads_as_version_0(tmp_path) -> None:
def test_migration_list_shape() -> None:
"""Migration 1 is the v1 DDL; migration 2 is the legacy rename step.
"""Migration 1 is the v1 DDL; 2 and 3 are the callable steps.
Nothing ever shipped, so migration 1 was rewritten pre-release
(D-007) to create the quest_* tables directly; migration 2 exists
for dev databases stamped version 1 by the pre-rename runner.
for dev databases stamped version 1 by the pre-rename runner, and
migration 3 brings pre-D-008 definitions to the multi-assignee
model.
"""
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS
assert callable(MIGRATIONS[1])
assert len(MIGRATIONS) == 2
assert callable(MIGRATIONS[2])
assert len(MIGRATIONS) == 3
# ---------------------------------------------------------------------------
@@ -312,6 +317,20 @@ def test_legacy_task_tables_are_renamed_and_keep_their_rows(tmp_path) -> None:
)
== ("2026-09-15",)
)
# Migration 3 moved the legacy single-assignee column into the
# assignees table: the definition kept its child, the column is
# gone.
assignees = _run(
database.fetch_one(
"SELECT child_id FROM quest_definition_assignees "
"WHERE definition_id = 1"
)
)
assert assignees == (1,)
columns = _run(
database.fetch_all("PRAGMA table_info(quest_definitions)")
)
assert "child_id" not in {row[1] for row in columns}
# The untouched completion_events table still exists and is empty.
assert "completion_events" in tables
assert (
@@ -380,6 +399,136 @@ def test_fresh_database_never_holds_legacy_table_names(tmp_path) -> None:
_run(database.close())
# ---------------------------------------------------------------------------
# Pre-D-008 database: quest_definitions with child_id stamped version 2
# ---------------------------------------------------------------------------
_V2_DEFINITIONS_DDL = [
"""
CREATE TABLE IF NOT EXISTS quest_definitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
icon TEXT,
child_id INTEGER NOT NULL REFERENCES children(id),
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
due_time TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
)
""",
]
def _seed_v2_multi_assignee_upgrade(database) -> None:
"""Create a database shaped like a post-rename, pre-D-008 dev file.
Fresh quest_* tables — but quest_definitions still carries the
single child_id column and no assignees table exists — stamped at
version 2, with definition and instance rows that must survive.
"""
for sql in SCHEMA_V1_STATEMENTS:
if "quest_definitions " in sql:
continue # replaced by the legacy-shape DDL below
_run(database.execute(sql))
for sql in _V2_DEFINITIONS_DDL:
_run(database.execute(sql))
_run(database.execute(VERSION_TABLE_DDL))
_run(
database.execute(
f"INSERT INTO {VERSION_TABLE} (id, version) VALUES (1, 2)"
)
)
_run(
database.execute(
"INSERT INTO children (display_name, created_at) "
"VALUES ('Ada', '2026-09-14T00:00:00+00:00')"
)
)
_run(
database.execute(
"INSERT INTO children (display_name, created_at) "
"VALUES ('Bo', '2026-09-14T00:00:00+00:00')"
)
)
_run(
database.execute(
"INSERT INTO schedule_rules (rule_type, start_date) "
"VALUES ('daily', '2026-09-14')"
)
)
_run(
database.execute(
"INSERT INTO quest_definitions (title, child_id, "
"schedule_rule_id, created_at) "
"VALUES ('Dishes', 2, 1, '2026-09-14T00:00:00+00:00')"
)
)
_run(
database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"due_date, generated_at) "
"VALUES (1, 2, '2026-09-15', '2026-09-14T00:00:00+00:00')"
)
)
def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
database = _open_db(tmp_path / "v2-upgrade.db")
try:
_seed_v2_multi_assignee_upgrade(database)
final = _migrate(database)
assert final == len(MIGRATIONS)
assert _version(database) == len(MIGRATIONS)
# The definition row survived and lost its child_id column.
row = _run(
database.fetch_one(
"SELECT title, schedule_rule_id, is_active "
"FROM quest_definitions WHERE id = 1"
)
)
assert row == ("Dishes", 1, 1)
columns = _run(
database.fetch_all("PRAGMA table_info(quest_definitions)")
)
assert "child_id" not in {c[1] for c in columns}
# The single assignee moved into the assignees table.
assignees = _run(
database.fetch_all(
"SELECT definition_id, child_id "
"FROM quest_definition_assignees ORDER BY child_id"
)
)
assert assignees == [(1, 2)]
# The instance survived, still referencing the definition.
instance = _run(
database.fetch_one(
"SELECT definition_id, child_id, due_date "
"FROM quest_instances WHERE id = 1"
)
)
assert instance == (1, 2, "2026-09-15")
# No dangling references anywhere.
assert _run(database.fetch_all("PRAGMA foreign_key_check")) == []
# The rebuilt table is live for the new model: a definition
# insert carries no child column at all.
_run(
database.execute(
"INSERT INTO quest_definitions (title, schedule_rule_id, "
"created_at) VALUES ('Laundry', 1, "
"'2026-09-14T00:00:00+00:00')"
)
)
_run(
database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (2, 1)"
)
)
finally:
_run(database.close())
# ---------------------------------------------------------------------------
# Idempotence: second start is a no-op
# ---------------------------------------------------------------------------
@@ -481,22 +630,28 @@ def test_noop_run_is_logged_at_debug_level(caplog, tmp_path) -> None:
def _failing_migrations(prior_statements: list[str]) -> list[list[str]]:
"""Three-migration list where migration 3 fails mid-application.
"""Migration list where the LAST entry fails mid-application.
Migration 2 is a harmless no-op filler so the real runner — which
has already stamped version 2 by the time these tests re-run it —
still has a pending migration to fail on.
Sized one entry longer than the real MIGRATIONS and padded with
harmless no-op fillers, so a fully-migrated database (already at
len(MIGRATIONS)) still has exactly one pending migration — the
failing one — to attempt.
"""
return [
prior_statements,
steps: list[list[str]] = [prior_statements]
steps += [
[
"CREATE TABLE IF NOT EXISTS filler_table (id INTEGER PRIMARY KEY)"
],
f"CREATE TABLE IF NOT EXISTS filler_{index} "
"(id INTEGER PRIMARY KEY)"
]
for index in range(len(MIGRATIONS) - 1)
]
steps.append(
[
"CREATE TABLE IF NOT EXISTS later_table (id INTEGER PRIMARY KEY)",
"CREATE TABLE broken (",
],
]
]
)
return steps
def test_failing_migration_rolls_back_to_prior_version(tmp_path) -> None:
+173 -23
View File
@@ -16,6 +16,7 @@ from custom_components.nestquest.schema import (
SCHEMA_V1_SCHEDULE_RULES_DDL,
SCHEMA_V1_STATEMENTS,
SCHEMA_V1_QUEST_DEFINITIONS_DDL,
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
SCHEMA_V1_QUEST_INSTANCES_DDL,
async_apply_ddl,
)
@@ -73,15 +74,17 @@ def _insert_rule(database, **overrides):
return result.lastrowid
def _insert_definition(database, child_id=1, rule_id=1, **overrides):
"""Insert one valid task_definition, returning the definition id.
def _insert_definition(database, rule_id=1, assignees=(1,), **overrides):
"""Insert one valid quest_definition, returning the definition id.
``assignees`` rows are written to ``quest_definition_assignees``
after the definition insert (the definition itself carries no
child). Pass ``assignees=None`` for an unassigned definition.
Pass ``_OMIT`` as a value to leave that column out of the INSERT so
its DDL DEFAULT applies.
"""
values: dict[str, object] = {
"title": "Brush teeth",
"child_id": child_id,
"schedule_rule_id": rule_id,
"created_at": "2026-09-13T00:00:00+00:00",
}
@@ -96,6 +99,14 @@ def _insert_definition(database, child_id=1, rule_id=1, **overrides):
)
)
assert result.lastrowid is not None
for child_id in assignees or ():
_run(
database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (?, ?)",
(result.lastrowid, child_id),
)
)
return result.lastrowid
@@ -156,12 +167,13 @@ def _insert_override(database, child_id, **overrides):
# ---------------------------------------------------------------------------
def test_schema_v1_statements_compose_the_six_tables() -> None:
def test_schema_v1_statements_compose_the_seven_tables() -> None:
assert SCHEMA_V1_STATEMENTS == [
*SCHEMA_V1_CHILDREN_DDL,
*SCHEMA_V1_ADMIN_USERS_DDL,
*SCHEMA_V1_SCHEDULE_RULES_DDL,
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
*SCHEMA_V1_QUEST_INSTANCES_DDL,
@@ -302,7 +314,6 @@ def test_quest_definitions_columns_types_and_constraints(tmp_path) -> None:
("title", "TEXT", 1, None, 0),
("description", "TEXT", 0, None, 0),
("icon", "TEXT", 0, None, 0),
("child_id", "INTEGER", 1, None, 0),
("schedule_rule_id", "INTEGER", 1, None, 0),
("due_time", "TEXT", 0, None, 0),
("is_active", "INTEGER", 1, "1", 0),
@@ -319,13 +330,66 @@ def test_quest_definitions_foreign_keys_declared(tmp_path) -> None:
fks = _run(database.fetch_all("PRAGMA foreign_key_list(quest_definitions)"))
declared = {(row[2], row[3], row[4]) for row in fks}
assert declared == {
("children", "child_id", "id"),
("schedule_rules", "schedule_rule_id", "id"),
}
finally:
_run(database.close())
def test_quest_definition_assignees_table_exists_after_applying_ddl(
tmp_path,
) -> None:
database = _open_db(tmp_path / "assignees.db")
try:
_apply(database)
row = _run(
database.fetch_one(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name = 'quest_definition_assignees'"
)
)
assert row is not None and row[0] == "quest_definition_assignees"
finally:
_run(database.close())
def test_quest_definition_assignees_columns_types_and_constraints(
tmp_path,
) -> None:
database = _open_db(tmp_path / "assignees-columns.db")
try:
_apply(database)
columns = _run(
database.fetch_all("PRAGMA table_info(quest_definition_assignees)")
)
# cid, name, type, notnull, dflt_value, pk — both columns are
# the two-part primary key, so pk is 1 and 2.
assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [
("definition_id", "INTEGER", 1, None, 1),
("child_id", "INTEGER", 1, None, 2),
]
finally:
_run(database.close())
def test_quest_definition_assignees_foreign_keys_declared(tmp_path) -> None:
database = _open_db(tmp_path / "assignees-fks.db")
try:
_apply(database)
fks = _run(
database.fetch_all(
"PRAGMA foreign_key_list(quest_definition_assignees)"
)
)
declared = {(row[2], row[3], row[4]) for row in fks}
assert declared == {
("quest_definitions", "definition_id", "id"),
("children", "child_id", "id"),
}
finally:
_run(database.close())
# ---------------------------------------------------------------------------
# Constraint enforcement
# ---------------------------------------------------------------------------
@@ -826,28 +890,114 @@ def test_quest_definitions_insert_requires_existing_child_and_rule(
)
)
rule_id = _insert_rule(database)
definition_id = _insert_definition(
database, child_id=1, rule_id=rule_id
)
definition_id = _insert_definition(database, rule_id=rule_id)
row = _run(
database.fetch_one(
"SELECT title, child_id, schedule_rule_id FROM quest_definitions "
"SELECT title, schedule_rule_id FROM quest_definitions "
"WHERE id = ?",
(definition_id,),
)
)
assert row == ("Brush teeth", 1, rule_id)
assert row == ("Brush teeth", rule_id)
assignee = _run(
database.fetch_one(
"SELECT child_id FROM quest_definition_assignees "
"WHERE definition_id = ?",
(definition_id,),
)
)
assert assignee == (1,)
finally:
_run(database.close())
def test_quest_definitions_unknown_child_id_fails(tmp_path) -> None:
database = _open_db(tmp_path / "definitions-bad-child.db")
def test_quest_definition_assignees_unknown_child_fails(tmp_path) -> None:
database = _open_db(tmp_path / "assignees-bad-child.db")
try:
_apply(database)
_insert_rule(database)
definition_id = _insert_definition(database, assignees=None)
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
_insert_definition(database, child_id=999)
_run(
database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (?, 999)",
(definition_id,),
)
)
finally:
_run(database.close())
def test_quest_definition_assignees_unknown_definition_fails(tmp_path) -> None:
database = _open_db(tmp_path / "assignees-bad-definition.db")
try:
_apply(database)
_run(
database.execute(
"INSERT INTO children (display_name, created_at) VALUES (?, ?)",
("Ada", "2026-09-13T00:00:00+00:00"),
)
)
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
_run(
database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (999, 1)"
)
)
finally:
_run(database.close())
def test_quest_definition_assignees_duplicate_pair_fails(tmp_path) -> None:
database = _open_db(tmp_path / "assignees-duplicate.db")
try:
_apply(database)
_run(
database.execute(
"INSERT INTO children (display_name, created_at) VALUES (?, ?)",
("Ada", "2026-09-13T00:00:00+00:00"),
)
)
_insert_rule(database)
definition_id = _insert_definition(database)
with pytest.raises(sqlite3.IntegrityError):
_run(
database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (?, 1)",
(definition_id,),
)
)
finally:
_run(database.close())
def test_quest_definition_assignees_multiple_children_allowed(
tmp_path,
) -> None:
database = _open_db(tmp_path / "assignees-multi.db")
try:
_apply(database)
for name in ("Ada", "Ben", "Cleo"):
_run(
database.execute(
"INSERT INTO children (display_name, created_at) "
"VALUES (?, ?)",
(name, "2026-09-13T00:00:00+00:00"),
)
)
_insert_rule(database)
definition_id = _insert_definition(database, assignees=(1, 2, 3))
rows = _run(
database.fetch_all(
"SELECT child_id FROM quest_definition_assignees "
"WHERE definition_id = ? ORDER BY child_id",
(definition_id,),
)
)
assert [row[0] for row in rows] == [1, 2, 3]
finally:
_run(database.close())
@@ -863,7 +1013,7 @@ def test_quest_definitions_unknown_schedule_rule_id_fails(tmp_path) -> None:
)
)
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
_insert_definition(database, child_id=1, rule_id=999)
_insert_definition(database, rule_id=999)
finally:
_run(database.close())
@@ -874,7 +1024,7 @@ def test_quest_definitions_is_active_check_enforced(tmp_path) -> None:
_apply(database)
_insert_rule(database)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_insert_definition(database, child_id=1, rule_id=1, is_active=2)
_insert_definition(database, rule_id=1, is_active=2)
finally:
_run(database.close())
@@ -895,7 +1045,7 @@ def test_quest_definitions_is_active_non_integer_fails(
_insert_rule(database)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_insert_definition(
database, child_id=1, rule_id=1, is_active=bad_is_active
database, rule_id=1, is_active=bad_is_active
)
finally:
_run(database.close())
@@ -907,7 +1057,7 @@ def test_quest_definitions_title_not_null_enforced(tmp_path) -> None:
_apply(database)
_insert_rule(database)
with pytest.raises(sqlite3.IntegrityError, match="NOT NULL"):
_insert_definition(database, child_id=1, rule_id=1, title=None)
_insert_definition(database, rule_id=1, title=None)
finally:
_run(database.close())
@@ -919,7 +1069,7 @@ def test_quest_definitions_created_at_not_null_enforced(tmp_path) -> None:
_insert_rule(database)
with pytest.raises(sqlite3.IntegrityError, match="NOT NULL"):
_insert_definition(
database, child_id=1, rule_id=1, created_at=None
database, rule_id=1, created_at=None
)
finally:
_run(database.close())
@@ -937,7 +1087,7 @@ def test_quest_definitions_defaults_apply_on_insert(tmp_path) -> None:
)
_insert_rule(database)
definition_id = _insert_definition(
database, child_id=1, rule_id=1, is_active=_OMIT
database, rule_id=1, is_active=_OMIT
)
row = _run(
database.fetch_one(
@@ -966,7 +1116,7 @@ def test_applying_extended_ddl_twice_keeps_new_table_rows(tmp_path) -> None:
)
)
_insert_rule(database)
_insert_definition(database, child_id=1, rule_id=1)
_insert_definition(database, rule_id=1)
_apply(database)
counts = []
for table in ("children", "schedule_rules", "quest_definitions"):
@@ -1469,7 +1619,7 @@ def _setup_definition(database) -> None:
"""Create one child, one schedule rule and one quest definition."""
_child(database)
_insert_rule(database)
_insert_definition(database, child_id=1, rule_id=1)
_insert_definition(database, rule_id=1)
# ---------------------------------------------------------------------------
@@ -1675,7 +1825,7 @@ def test_quest_instances_same_date_different_definitions_allowed(
)
)
rule_id = _insert_rule(database)
_insert_definition(database, child_id=2, rule_id=rule_id)
_insert_definition(database, rule_id=rule_id, assignees=(2,))
_insert_instance(database, definition_id=1, due_date="2026-09-14")
_insert_instance(database, definition_id=2, due_date="2026-09-14")
row = _run(database.fetch_one("SELECT COUNT(*) FROM quest_instances"))