Admin hybrid delete/retire of a quest definition (Delete Task) (#262)

Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
This commit was merged in pull request #262.
This commit is contained in:
2026-09-26 12:47:34 -04:00
committed by approver_agent
parent b1c14ed7d9
commit 02f4d8c8aa
11 changed files with 822 additions and 45 deletions
+187
View File
@@ -22,8 +22,10 @@ from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations
from custom_components.nestquest.core.quest_definitions import (
CreatedQuestDefinition,
DeletedQuestDefinition,
assign_child,
create_quest_definition,
delete_quest_definition,
edit_quest_definition,
list_active_definitions,
list_definitions_firing_on,
@@ -2126,3 +2128,188 @@ def test_wrappers_forward_horizon_days(tmp_path, mode) -> None:
return None
_with_db(tmp_path, f"wrapper-horizon-{mode}.db")(_body)
# ---------------------------------------------------------------------------
# delete_quest_definition: hybrid delete (owner decision 2026-09-26)
# ---------------------------------------------------------------------------
async def _count(database, sql, definition_id) -> int:
row = await database.fetch_one(sql, (definition_id,))
return row[0]
def test_delete_without_history_hard_deletes_everything(tmp_path) -> None:
"""No completion event on any instance: the definition, its
instances, assignees, windows and unreferenced rule are all gone."""
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database,
"Brush teeth",
_daily_rule(),
[child.id],
["morning", "evening"],
)
definition_id = created.definition.id
rule_id = created.definition.schedule_rule_id
instances_sql = (
"SELECT COUNT(*) FROM quest_instances WHERE definition_id = ?"
)
# Create materialized future instances, so the delete has
# instance rows to remove (not just an empty table).
assert await _count(database, instances_sql, definition_id) > 0
result = await delete_quest_definition(database, definition_id)
assert result == DeletedQuestDefinition(
definition_id=definition_id,
deleted=True,
retired=False,
definition=None,
)
assert await definitions.get(definition_id) is None
assert await rules.get(rule_id) is None
assert await _count(database, instances_sql, definition_id) == 0
for table in (
"quest_definition_assignees",
"quest_definition_windows",
):
assert await _count(
database,
f"SELECT COUNT(*) FROM {table} WHERE definition_id = ?",
definition_id,
) == 0
# The child itself is untouched.
assert (await children.get(child.id)) is not None
return None
_with_db(tmp_path, "delete-no-history.db")(_body)
def test_delete_without_history_keeps_a_shared_rule(tmp_path) -> None:
"""The rule is deleted only when no other definition references it."""
async def _body(database, children, rules, definitions, child):
rule = await rules.create("daily", "2026-09-14")
first = await definitions.create(
"First", rule.id, NOW, assignee_child_ids=[child.id]
)
second = await definitions.create(
"Second", rule.id, NOW, assignee_child_ids=[child.id]
)
result = await delete_quest_definition(database, first.id)
assert result.deleted is True
assert await definitions.get(first.id) is None
assert await definitions.get(second.id) is not None
assert await rules.get(rule.id) is not None
return None
_with_db(tmp_path, "delete-shared-rule.db")(_body)
def test_delete_with_history_retires_and_preserves_everything(
tmp_path,
) -> None:
"""A completion event on ANY instance: the definition is retired
(deactivated) and its row, rule, assignees, windows and completion
history all remain. The completed instance survives; only future
UNCOMPLETED instances are cleared by the deactivation's regeneration."""
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
)
definition_id = created.definition.id
rule_id = created.definition.schedule_rule_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",
)
result = await delete_quest_definition(database, definition_id)
assert result.deleted is False
assert result.retired is True
assert result.definition_id == definition_id
assert isinstance(result.definition, CreatedQuestDefinition)
assert result.definition.definition.is_active is False
stored = await definitions.get(definition_id)
assert stored is not None
assert stored.is_active is False
assert await rules.get(rule_id) is not None
assert [c.id for c in await definitions.list_assignees(
definition_id
)] == [child.id]
assert [w.window for w in await definitions.list_windows(
definition_id
)] == ["morning"]
assert await instances.get_by_id(instance.id) == instance
assert await events.list_by_instance(instance.id) == [event]
return None
_with_db(tmp_path, "delete-with-history.db")(_body)
def test_delete_with_uncompleted_history_still_retires(tmp_path) -> None:
"""ANY completion event counts as history — even when the latest
event is an un-completion, the definition is retired, not deleted."""
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
)
definition_id = created.definition.id
instances = QuestInstancesDao(database)
events = CompletionEventsDao(database)
instance = await instances.upsert(
definition_id, child.id, _today_iso(), NOW, window="morning"
)
await events.append(
instance.id, child.id, "completed", "user", NOW, True,
actor_user_id="admin-1",
)
await events.append(
instance.id, child.id, "uncompleted", "user", NOW, None,
actor_user_id="admin-1",
)
result = await delete_quest_definition(database, definition_id)
assert result.retired is True
assert await instances.get_by_id(instance.id) == instance
return None
_with_db(tmp_path, "delete-uncompleted-history.db")(_body)
def test_delete_nonexistent_definition_names_field(tmp_path) -> None:
async def _body(database, children, rules, definitions, child):
with pytest.raises(ValueError, match=r"^definition_id:"):
await delete_quest_definition(database, 999)
return None
_with_db(tmp_path, "delete-missing.db")(_body)
@pytest.mark.parametrize("bad", [True, 1.0, "1", None])
def test_delete_rejects_non_int_definition_id(tmp_path, bad) -> None:
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
)
with pytest.raises(ValueError, match="definition_id"):
await delete_quest_definition(database, bad)
assert await definitions.get(created.definition.id) is not None
return None
_with_db(tmp_path, "delete-bad-id.db")(_body)