Deactivation preserves every referencing row (Feature 03, task 2)
Tests for the no-data-loss deactivation contract over the existing set_child_active business function: a child with a definition, instance and completion event keeps all three rows byte-identical through deactivate and reactivate, is excluded from (and restored to) the active list, and stays in the full list. A structural guard proves the no-delete property: no module carries DELETE targeting children and ChildrenDao exposes no delete-shaped method. Suite: 853 passed.
This commit is contained in:
@@ -575,3 +575,117 @@ def test_list_children_orders_by_sort_order_then_id(tmp_path) -> None:
|
||||
return listed
|
||||
|
||||
_with_db(tmp_path, "list-order.db")(_body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deactivation keeps every referencing row intact (Feature 03)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_deactivating_child_preserves_definitions_instances_events(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
async def _body(database):
|
||||
from datetime import date, timedelta
|
||||
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
QuestDefinitionsDao,
|
||||
ScheduleRulesDao,
|
||||
)
|
||||
|
||||
children = ChildrenDao(database)
|
||||
rules = ScheduleRulesDao(database)
|
||||
definitions = QuestDefinitionsDao(database)
|
||||
instances = QuestInstancesDao(database)
|
||||
events = CompletionEventsDao(database)
|
||||
|
||||
child = await create_child(database, "Ada")
|
||||
rule = await rules.create("daily", "2026-01-01")
|
||||
definition = await definitions.create(
|
||||
"Brush teeth",
|
||||
rule.id,
|
||||
"2026-09-15T12:00:00+00:00",
|
||||
assignee_child_ids=[child.id],
|
||||
)
|
||||
due = (date.today() + timedelta(days=1)).isoformat()
|
||||
stamp = f"{due}T08:00:00+00:00"
|
||||
instance = await instances.upsert(
|
||||
definition.id, child.id, due, stamp, window="morning"
|
||||
)
|
||||
event = await events.append(
|
||||
instance.id,
|
||||
child.id,
|
||||
"completed",
|
||||
"panel",
|
||||
f"{due}T09:00:00+00:00",
|
||||
True,
|
||||
actor_child_id=child.id,
|
||||
)
|
||||
|
||||
# Deactivate: the referencing rows must not move.
|
||||
deactivated = await set_child_active(database, child.id, False)
|
||||
assert deactivated.is_active is False
|
||||
assert await children.get(child.id) == deactivated
|
||||
assert (await definitions.get(definition.id)) == definition
|
||||
assert (await instances.get_by_id(instance.id)) == instance
|
||||
assert await events.list_by_instance(instance.id) == [event]
|
||||
# Excluded from the active list, still present in the full list.
|
||||
assert await list_children(database, active_only=True) == []
|
||||
assert [c.id for c in await list_children(database)] == [child.id]
|
||||
|
||||
# Reactivate: fully restored.
|
||||
reactivated = await set_child_active(database, child.id, True)
|
||||
assert reactivated.is_active is True
|
||||
assert [c.id for c in await list_children(database, active_only=True)] == [
|
||||
child.id
|
||||
]
|
||||
assert (await instances.get_by_id(instance.id)) == instance
|
||||
return reactivated
|
||||
|
||||
_with_db(tmp_path, "deactivate-data-intact.db")(_body)
|
||||
|
||||
|
||||
def test_no_code_path_deletes_a_child_row() -> None:
|
||||
"""Structural guardrail: children are deactivated, never deleted.
|
||||
|
||||
The DAO exposes no delete method at all, and no module in the
|
||||
package (nor any test outside the sanctioned DDL/migration layers,
|
||||
which create the tables) carries SQL that deletes from children —
|
||||
the leak guard in test_dao_children enforces the file allowlist,
|
||||
this asserts the no-delete property itself across every file that
|
||||
may legitimately name the table.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
package = Path(
|
||||
__import__(
|
||||
"custom_components.nestquest", fromlist=["__file__"]
|
||||
).__file__
|
||||
).parent
|
||||
|
||||
delete_pattern = re.compile(
|
||||
r"DELETE\s+(FROM\s+)?((main|temp)\s*\.\s*)?[`'\"]*(\[)?children\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
for py in sorted(package.rglob("*.py")):
|
||||
assert delete_pattern.search(py.read_text()) is None, (
|
||||
f"a code path in {py.name} deletes child rows"
|
||||
)
|
||||
|
||||
import inspect
|
||||
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
|
||||
methods = {
|
||||
name
|
||||
for name, _ in inspect.getmembers(ChildrenDao, inspect.isfunction)
|
||||
}
|
||||
assert not any(
|
||||
name.startswith("delete") or name == "remove" for name in methods
|
||||
), f"ChildrenDao exposed a delete-shaped method: {methods}"
|
||||
|
||||
Reference in New Issue
Block a user