diff --git a/tests/test_quest_definitions.py b/tests/test_quest_definitions.py index 88d4ea8..9266305 100644 --- a/tests/test_quest_definitions.py +++ b/tests/test_quest_definitions.py @@ -2,11 +2,16 @@ from __future__ import annotations import asyncio +import datetime import re import pytest 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, @@ -73,6 +78,15 @@ def _daily_rule() -> ScheduleRule: return ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-14") +def _today_iso() -> str: + """Today's date in ISO form, computed at call time (never cached). + + ``QuestInstancesDao.upsert`` refuses past due dates, so seeded + instances use a date that is always today-or-later at test time. + """ + return datetime.date.today().isoformat() + + # --------------------------------------------------------------------------- # create_quest_definition: successful creates # --------------------------------------------------------------------------- @@ -1247,8 +1261,8 @@ def test_set_quest_definition_active_nonexistent_definition_names_field( def test_set_quest_definition_active_never_deletes_or_touches_instances( tmp_path, ) -> None: - """Deactivation hides the definition but deletes nothing, and the - instance/history tables stay exactly as they were.""" + """Deactivation hides the definition but deletes nothing, and + pre-existing instances and completion history survive unchanged.""" async def _body(database, children, rules, definitions, child): created = await create_quest_definition( database, "Brush teeth", _daily_rule(), [child.id], ["morning"] @@ -1256,6 +1270,25 @@ def test_set_quest_definition_active_never_deletes_or_touches_instances( definition_id = created.definition.id rule_id = created.definition.schedule_rule_id + # Seed a real instance and a real completion event against the + # definition BEFORE deactivation, so the test can detect a + # delete or mutation of pre-existing data (not just an empty + # table staying empty). + 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" ) @@ -1277,7 +1310,10 @@ def test_set_quest_definition_active_never_deletes_or_touches_instances( assert [w.window for w in await definitions.list_windows( definition_id )] == ["morning"] - # No instances or completion events were created or removed. + # The pre-existing instance and completion event survive with + # unchanged ids and contents, and no new rows appeared. + 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 @@ -1287,6 +1323,8 @@ def test_set_quest_definition_active_never_deletes_or_touches_instances( await set_quest_definition_active(database, definition_id, True) assert (await definitions.get(definition_id)).is_active is True + 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 @@ -1296,3 +1334,89 @@ def test_set_quest_definition_active_never_deletes_or_touches_instances( return None _with_db(tmp_path, "set-active-never-deletes.db")(_body) + + +def _gated_hass( + armed: dict, gate_open: asyncio.Event, a_started: asyncio.Event +): + """A hass mock whose executor gates the FIRST ``_execute`` job after + arming — the UPDATE of whichever ``set_quest_definition_active`` + call starts first — proving the caller holds the connection lock at + that moment and the second caller must queue behind it.""" + from unittest.mock import MagicMock + + hass = MagicMock() + + async def _gated_executor(fn, *args): + if armed["active"]: + if getattr(fn, "__name__", "") == "_execute" and not armed["gated"]: + armed["gated"] = True + a_started.set() + await gate_open.wait() + return fn(*args) + + hass.async_add_executor_job = _gated_executor + return hass + + +def test_set_quest_definition_active_concurrent_opposite_transitions_return_own( + tmp_path, +) -> None: + """Racing deactivate/reactivate calls each report their own request. + + Deterministic gate on task A's UPDATE: task B queues behind the + connection lock and lands the opposite transition after A. Without + the lock, A's readback could see B's write and report B's value. + """ + async def _main(): + armed = {"active": False, "gated": False} + gate_open = asyncio.Event() + a_started = asyncio.Event() + hass = _gated_hass(armed, gate_open, a_started) + database = NestQuestDatabase(hass) + await database.open(tmp_path / "active-race.db") + try: + await apply_migrations(database) + children = ChildrenDao(database) + child = await children.create("Ada", NOW) + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + + armed["active"] = True + task_a = asyncio.ensure_future( + set_quest_definition_active( + database, created.definition.id, False + ) + ) + await a_started.wait() + armed["active"] = False + task_b = asyncio.ensure_future( + set_quest_definition_active( + database, created.definition.id, True + ) + ) + await asyncio.sleep(0) + gate_open.set() + record_a, record_b = await asyncio.gather(task_a, task_b) + + # Each caller gets back exactly the state it requested. + assert record_a.definition.is_active is False + assert record_b.definition.is_active is True + # Both snapshots stay coherent: the decoded rule and the + # assignees are those of the definition they were read from. + assert record_a.rule == _daily_rule() + assert record_b.rule == _daily_rule() + assert [c.id for c in record_a.assignees] == [child.id] + assert [c.id for c in record_b.assignees] == [child.id] + # B landed last: the stored row is B's (read via the DAO — + # the four-table leak guard forbids raw probes here). + stored = await QuestDefinitionsDao(database).get( + created.definition.id + ) + assert stored is not None + assert stored.is_active is True + finally: + await database.close() + + _run(_main())