Compare commits

...
Author SHA1 Message Date
overseer d09ac91ade test: race coverage for list_definitions_for_child and list_active_definitions (F74-002) 2026-09-17 12:04:50 -04:00
overseer e7daecf970 Fix quest-definition query snapshot coherence and edit error field naming
Read definitions, rules, assignees and windows in one locked transaction via DAO snapshot-list methods so concurrent edits cannot interleave; re-raise edit's missing-definition error with a definition_id field prefix.
2026-09-17 11:55:35 -04:00
overseer e60a9892ff Tests for the quest definition business layer (Feature 06, task 7) (#72) 2026-09-17 09:58:15 -04:00
3 changed files with 427 additions and 45 deletions
+60
View File
@@ -907,6 +907,66 @@ class QuestDefinitionsDao:
) )
return [_definition_from_row(row) for row in rows] return [_definition_from_row(row) for row in rows]
async def _snapshots_for(
self, definitions: list[QuestDefinitionRecord]
) -> list[QuestDefinitionSnapshot]:
"""Read each definition's rule, assignees and windows.
MUST be called inside the connection lock (and ideally a
transaction) so the definitions handed in and the rows this
reads back cannot be raced by a concurrent edit between list
and bundle. Each rule is read through
:class:`ScheduleRulesDao` on the same connection, so the
snapshot's rule record is the one the definition references AT
the moment of the locked read.
"""
rules_dao = ScheduleRulesDao(self._database)
snapshots: list[QuestDefinitionSnapshot] = []
for definition in definitions:
rule_record = await rules_dao.get(definition.schedule_rule_id)
assert rule_record is not None
snapshots.append(
QuestDefinitionSnapshot(
definition=definition,
rule=rule_record,
assignees=await self.list_assignees(definition.id),
windows=await self.list_windows(definition.id),
)
)
return snapshots
async def list_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
"""Return every active definition with its rule, assignees and
windows, all read inside one locked transaction.
The definitive list and each definition's linked rows are read
in a single BEGIN..COMMIT span under the connection lock, so a
concurrent atomic edit cannot interleave between the definition
list and the bundle read: the caller receives a coherent
snapshot (each definition's rule, assignees and windows as they
were at one instant), never a mixed old-rule/new-windows state.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
definitions = await self.list_active()
return await self._snapshots_for(definitions)
async def list_snapshots_by_child(
self, child_id: int
) -> list[QuestDefinitionSnapshot]:
"""Return each definition assigned to ``child_id`` with its rule,
assignees and windows, all read inside one locked transaction.
Mirrors :meth:`list_snapshots_active` but selects by assignment
(``list_by_child`` order: newest first). Locks read-and-bundle
into one transaction so the bundle never mixes one edit's
definition with another's windows.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
definitions = await self.list_by_child(child_id)
return await self._snapshots_for(definitions)
async def update( async def update(
self, self,
definition_id: int, definition_id: int,
@@ -44,6 +44,7 @@ from dataclasses import dataclass
from .dao_children import ChildRecord, _connection_lock from .dao_children import ChildRecord, _connection_lock
from .dao_rules import ( from .dao_rules import (
QuestDefinitionRecord, QuestDefinitionRecord,
QuestDefinitionSnapshot,
QuestDefinitionWindowRecord, QuestDefinitionWindowRecord,
QuestDefinitionsDao, QuestDefinitionsDao,
ScheduleRuleStorage, ScheduleRuleStorage,
@@ -318,14 +319,23 @@ async def edit_quest_definition(
window_specs = _normalize_windows(windows) window_specs = _normalize_windows(windows)
dao = QuestDefinitionsDao(database) dao = QuestDefinitionsDao(database)
snapshot = await dao.edit_definition( try:
definition_id, snapshot = await dao.edit_definition(
title=title_value, definition_id,
description=description_value, title=title_value,
icon=icon_value, description=description_value,
rule=rule_storage, icon=icon_value,
windows=window_specs, rule=rule_storage,
) windows=window_specs,
)
except ValueError as error:
# The DAO's missing-definition error names only the id; re-raise
# so the public contract names the field the caller passed (matching
# assign/unassign/deactivate).
message = str(error)
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
raise
decoded_rule = schedule_rule_from_storage( decoded_rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule) schedule_rule_storage_from_record(snapshot.rule)
@@ -455,33 +465,25 @@ async def set_quest_definition_active(
) )
async def _bundle_definition( def _bundle_snapshot(
database: NestQuestDatabase, snapshot: QuestDefinitionSnapshot,
dao: QuestDefinitionsDao,
definition: QuestDefinitionRecord,
) -> CreatedQuestDefinition: ) -> CreatedQuestDefinition:
"""Assemble a definition's rich view: decoded rule + assignees + windows. """Decode a DAO snapshot into the layer's rich view.
Reads the schedule rule through :class:`~.dao_rules.ScheduleRulesDao` The rule record carried by the snapshot is the one the definition
and the assignees and windows through the DAO, then wraps them in the referenced at read time, so decoding it here (rather than re-reading
same :class:`CreatedQuestDefinition` shape the create/edit/activate through :class:`~.dao_rules.ScheduleRulesDao`) preserves the
paths return, so every query helper here reuses one snapshot type coherence the DAO's locked snapshot guarantees: the rule, assignees
rather than inventing a second one. and windows all come from one atomic read, never three separate ones.
""" """
rule_record = await ScheduleRulesDao(database).get(
definition.schedule_rule_id
)
assert rule_record is not None
rule = schedule_rule_from_storage( rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(rule_record) schedule_rule_storage_from_record(snapshot.rule)
) )
assignees = await dao.list_assignees(definition.id)
windows = await dao.list_windows(definition.id)
return CreatedQuestDefinition( return CreatedQuestDefinition(
definition=definition, definition=snapshot.definition,
rule=rule, rule=rule,
assignees=assignees, assignees=snapshot.assignees,
windows=windows, windows=snapshot.windows,
) )
@@ -494,14 +496,14 @@ async def list_active_definitions(
order, so callers get a stable, repeatable sequence. Each entry is order, so callers get a stable, repeatable sequence. Each entry is
the same rich :class:`CreatedQuestDefinition` snapshot as the rest of the same rich :class:`CreatedQuestDefinition` snapshot as the rest of
the layer: the stored row plus its decoded the layer: the stored row plus its decoded
:class:`~.recurrence.ScheduleRule`, assignees and windows. :class:`~.recurrence.ScheduleRule`, assignees and windows. The
definitions and their links are read by the DAO inside one locked
transaction, so each bundle is a coherent snapshot that a concurrent
edit cannot interleave.
""" """
dao = QuestDefinitionsDao(database) dao = QuestDefinitionsDao(database)
definitions = await dao.list_active() snapshots = await dao.list_snapshots_active()
return [ return [_bundle_snapshot(snapshot) for snapshot in snapshots]
await _bundle_definition(database, dao, definition)
for definition in definitions
]
async def list_definitions_for_child( async def list_definitions_for_child(
@@ -515,15 +517,12 @@ async def list_definitions_for_child(
rising definition id, matching ``list_active_definitions`` and rising definition id, matching ``list_active_definitions`` and
``list_definitions_firing_on``. Each entry is a ``list_definitions_firing_on``. Each entry is a
:class:`CreatedQuestDefinition` snapshot bundling the definition with :class:`CreatedQuestDefinition` snapshot bundling the definition with
its decoded rule, assignees and windows. its decoded rule, assignees and windows, read atomically by the DAO.
""" """
_validate_child_id(child_id) _validate_child_id(child_id)
dao = QuestDefinitionsDao(database) dao = QuestDefinitionsDao(database)
definitions = await dao.list_by_child(child_id) snapshots = await dao.list_snapshots_by_child(child_id)
bundles = [ bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
await _bundle_definition(database, dao, definition)
for definition in definitions
]
return sorted(bundles, key=lambda bundle: bundle.definition.id) return sorted(bundles, key=lambda bundle: bundle.definition.id)
@@ -540,13 +539,13 @@ async def list_definitions_firing_on(
definitions whose decoded rule fires on the date are returned, in definitions whose decoded rule fires on the date are returned, in
rising definition-id order, each as the same rich rising definition-id order, each as the same rich
:class:`CreatedQuestDefinition` snapshot as the rest of the layer. :class:`CreatedQuestDefinition` snapshot as the rest of the layer.
The definitions and their rules are read inside one locked
transaction, so a bundle can never filter on one rule while
presenting another state's windows.
""" """
_validate_date(date, "date") _validate_date(date, "date")
target = datetime.datetime.strptime(date, "%Y-%m-%d").date() target = datetime.datetime.strptime(date, "%Y-%m-%d").date()
dao = QuestDefinitionsDao(database) dao = QuestDefinitionsDao(database)
definitions = await dao.list_active() snapshots = await dao.list_snapshots_active()
bundles = [ bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
await _bundle_definition(database, dao, definition)
for definition in definitions
]
return [bundle for bundle in bundles if occurs_on(bundle.rule, target)] return [bundle for bundle in bundles if occurs_on(bundle.rule, target)]
+323
View File
@@ -786,6 +786,24 @@ def test_edit_rejects_nonexistent_definition(tmp_path) -> None:
_with_db(tmp_path, "edit-missing-definition.db")(_body) _with_db(tmp_path, "edit-missing-definition.db")(_body)
def test_edit_nonexistent_definition_names_field(tmp_path) -> None:
"""A missing definition's error names the ``definition_id`` field.
The DAO raises a bare "quest definition <id> does not exist"; the
business layer must re-raise it prefixed with the field the caller
passed, matching assign/unassign/deactivate.
"""
async def _body(database, children, rules, definitions, child):
with pytest.raises(
ValueError,
match="definition_id: quest definition 999 does not exist",
):
await edit_quest_definition(database, 999, title="X")
return None
_with_db(tmp_path, "edit-missing-definition-field.db")(_body)
@pytest.mark.parametrize("bad", [True, 1.5, "1", None]) @pytest.mark.parametrize("bad", [True, 1.5, "1", None])
def test_edit_rejects_non_int_definition_id(tmp_path, bad) -> None: def test_edit_rejects_non_int_definition_id(tmp_path, bad) -> None:
async def _body(database, children, rules, definitions, child): async def _body(database, children, rules, definitions, child):
@@ -1621,3 +1639,308 @@ def test_list_definitions_firing_on_rejects_malformed_date(tmp_path, bad) -> Non
return None return None
_with_db(tmp_path, "list-firing-on-bad-date.db")(_body) _with_db(tmp_path, "list-firing-on-bad-date.db")(_body)
def test_list_query_snapshot_coherent_under_racing_edit(tmp_path) -> None:
"""A racing edit cannot mix states into a query helper's snapshot.
The query helper pauses mid-snapshot (inside its locked transaction)
while an edit that changes BOTH the rule and the windows is queued
behind the connection lock. Because the helper reads definitions,
rules and windows in one locked transaction, its returned bundle is
either the whole old state or the whole new state — never the old
rule paired with the new windows (the exact interleaving the unlocked
reads used to allow). The pause hook holds
``list_windows`` mid-flight so the edit is deterministically queued
against the helper's lock.
"""
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
)
weekly = ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={2},
start_date="2026-09-14",
)
import custom_components.nestquest.dao_rules as dao_rules
original_list = dao_rules.QuestDefinitionsDao.list_windows
started = asyncio.Event()
release = asyncio.Event()
async def _pausing_list(self, definition_id):
if not started.is_set():
started.set()
await release.wait()
return await original_list(self, definition_id)
dao_rules.QuestDefinitionsDao.list_windows = _pausing_list
try:
read_task = asyncio.ensure_future(
list_definitions_firing_on(database, "2026-09-16")
)
await started.wait()
edit_task = asyncio.ensure_future(
edit_quest_definition(
database,
created.definition.id,
rule=weekly,
windows=["evening"],
)
)
await asyncio.sleep(0)
release.set()
result, edited = await asyncio.gather(read_task, edit_task)
finally:
dao_rules.QuestDefinitionsDao.list_windows = original_list
# The helper finished before the queued edit, so its snapshot is
# the whole OLD state: the daily rule with its morning window.
assert [b.definition.id for b in result] == [created.definition.id]
assert result[0].rule == _daily_rule()
assert [w.window for w in result[0].windows] == ["morning"]
# The edit then reported the whole NEW state, not a mix either.
assert edited.rule == weekly
assert [w.window for w in edited.windows] == ["evening"]
return result
_with_db(tmp_path, "list-snapshot-concurrent.db")(_body)
def test_list_definitions_for_child_snapshot_coherent_under_racing_unassign(
tmp_path,
) -> None:
"""A racing unassign cannot strip the child from the child-scoped
snapshot that ``list_definitions_for_child`` returns.
The helper pauses mid-snapshot (inside its locked transaction) while
an unassign of the very child it is listing is queued behind the
connection lock. Because the definition set and its assignees are
read in one locked transaction, the returned bundle still shows the
child assigned — the coherent pre-unassign snapshot — rather than the
mixed state (definition returned "for child" but with the child
already gone from its roster) that the unlocked reads used to allow.
The pause hook holds ``list_assignees`` mid-flight so the unassign is
deterministically queued against the helper's lock.
"""
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
)
import custom_components.nestquest.dao_rules as dao_rules
original_list = dao_rules.QuestDefinitionsDao.list_assignees
started = asyncio.Event()
release = asyncio.Event()
async def _pausing_list(self, definition_id):
if not started.is_set():
started.set()
await release.wait()
return await original_list(self, definition_id)
dao_rules.QuestDefinitionsDao.list_assignees = _pausing_list
try:
read_task = asyncio.ensure_future(
list_definitions_for_child(database, child.id)
)
await started.wait()
unassign_task = asyncio.ensure_future(
unassign_child(database, created.definition.id, child.id)
)
for _ in range(5):
await asyncio.sleep(0)
release.set()
result, removed = await asyncio.gather(read_task, unassign_task)
finally:
dao_rules.QuestDefinitionsDao.list_assignees = original_list
# The helper finished before the queued unassign, so its snapshot
# is the whole OLD state: the child still assigned to the
# definition it returned for that child.
assert [b.definition.id for b in result] == [created.definition.id]
assert [c.id for c in result[0].assignees] == [child.id]
assert removed is True
return result
_with_db(tmp_path, "list-for-child-snapshot-concurrent.db")(_body)
def test_list_active_definitions_snapshot_coherent_under_racing_edit(
tmp_path,
) -> None:
"""A racing edit cannot mix states into ``list_active_definitions``.
The helper pauses mid-snapshot (inside its locked transaction) while
an edit that changes BOTH the rule and the windows is queued behind
the connection lock. Because definitions, rules and windows are read
in one locked transaction, the returned bundle is the whole old state
— the daily rule with its morning window — never the old rule paired
with the new windows. The pause hook holds ``list_windows``
mid-flight so the edit is deterministically queued against the
helper's lock.
"""
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
)
weekly = ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={2},
start_date="2026-09-14",
)
import custom_components.nestquest.dao_rules as dao_rules
original_list = dao_rules.QuestDefinitionsDao.list_windows
started = asyncio.Event()
release = asyncio.Event()
async def _pausing_list(self, definition_id):
if not started.is_set():
started.set()
await release.wait()
return await original_list(self, definition_id)
dao_rules.QuestDefinitionsDao.list_windows = _pausing_list
try:
read_task = asyncio.ensure_future(list_active_definitions(database))
await started.wait()
edit_task = asyncio.ensure_future(
edit_quest_definition(
database,
created.definition.id,
rule=weekly,
windows=["evening"],
)
)
await asyncio.sleep(0)
release.set()
result, edited = await asyncio.gather(read_task, edit_task)
finally:
dao_rules.QuestDefinitionsDao.list_windows = original_list
# The helper finished before the queued edit, so its snapshot is
# the whole OLD state: the daily rule with its morning window.
assert [b.definition.id for b in result] == [created.definition.id]
assert result[0].rule == _daily_rule()
assert [w.window for w in result[0].windows] == ["morning"]
# The edit then reported the whole NEW state, not a mix either.
assert edited.rule == weekly
assert [w.window for w in edited.windows] == ["evening"]
return result
_with_db(tmp_path, "list-active-snapshot-concurrent.db")(_body)
# ---------------------------------------------------------------------------
# full-lifecycle integration: one definition through every operation
# ---------------------------------------------------------------------------
def test_full_lifecycle_never_touches_instances_or_history(tmp_path) -> None:
"""End-to-end lifecycle on one definition writes only the four
definition tables; ``quest_instances`` and ``completion_events`` are
left exactly as they were at every stage.
A real instance and completion event are seeded before the lifecycle
begins, so each stage is checked against pre-existing data being
mutated (not an empty table staying empty): create -> edit
rule/windows -> assign a second child -> unassign the original child
-> deactivate -> reactivate.
"""
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database,
"Brush teeth",
_daily_rule(),
[child.id],
[("morning", "07:00")],
)
definition_id = created.definition.id
instances = QuestInstancesDao(database)
events = CompletionEventsDao(database)
instance = await instances.upsert(
definition_id, child.id, _today_iso(), NOW, window="morning"
)
event = await events.append(
instance.id,
child.id,
"completed",
"user",
NOW,
True,
actor_user_id="admin-1",
)
instance_count = await database.fetch_one(
"SELECT COUNT(*) FROM quest_instances"
)
event_count = await database.fetch_one(
"SELECT COUNT(*) FROM completion_events"
)
async def _assert_history_untouched():
assert await instances.get_by_id(instance.id) == instance
assert await events.list_by_instance(instance.id) == [event]
assert await database.fetch_one(
"SELECT COUNT(*) FROM quest_instances"
) == instance_count
assert await database.fetch_one(
"SELECT COUNT(*) FROM completion_events"
) == event_count
await _assert_history_untouched()
# Edit rule and windows together; the definition's rule row is
# rewritten in place and its window set replaced.
edited = await edit_quest_definition(
database,
definition_id,
rule=ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={1, 3},
start_date="2026-09-14",
),
windows=[("morning", "08:00"), ("evening", "19:30")],
)
assert edited.rule == ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={1, 3},
start_date="2026-09-14",
)
by_name = {w.window: w.due_time for w in edited.windows}
assert by_name == {"morning": "08:00", "evening": "19:30"}
await _assert_history_untouched()
# Assign a second child: multi-assignee, both present.
bo = await children.create("Bo", NOW)
roster = await assign_child(database, definition_id, bo.id)
assert sorted(c.id for c in roster) == sorted([child.id, bo.id])
await _assert_history_untouched()
# Unassign the original child: only the link drops.
assert await unassign_child(database, definition_id, child.id) is True
assert [
c.id for c in await definitions.list_assignees(definition_id)
] == [bo.id]
await _assert_history_untouched()
# Deactivate, then reactivate: the definition survives both.
deactivated = await set_quest_definition_active(
database, definition_id, False
)
assert deactivated.definition.is_active is False
await _assert_history_untouched()
reactivated = await set_quest_definition_active(
database, definition_id, True
)
assert reactivated.definition.is_active is True
await _assert_history_untouched()
return reactivated
_with_db(tmp_path, "full-lifecycle.db")(_body)