From 744a40dcb5d216b980218ed74a6c3bb8d5f8ef55 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:03:00 -0400 Subject: [PATCH 1/3] Presence override overlap rejection naming the conflict (Feature 05, task 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PresenceOverridesDao.create now refuses an override that overlaps any existing override of the SAME child (inclusive boundaries): the check runs inside the same transaction as the INSERT under the connection lock, so a racing create cannot slip a conflict between check and write — a deterministic race test proves exactly one of two concurrent overlapping creates lands. The ValueError names the conflicting override's id, its range, and its flag. Round-trip, per-child scope, and remove-then-recreate tests added. Suite: 990 passed. --- custom_components/nestquest/dao_presence.py | 24 ++++ tests/test_dao_presence.py | 149 +++++++++++++++++++- 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/custom_components/nestquest/dao_presence.py b/custom_components/nestquest/dao_presence.py index c19e821..6bb5ddd 100644 --- a/custom_components/nestquest/dao_presence.py +++ b/custom_components/nestquest/dao_presence.py @@ -186,6 +186,15 @@ class PresenceOverridesDao: The child must exist (validated atomically under the connection lock). Date shape is validated strictly, and the schema CHECK rejects end < start. + + Overlap policy (Feature 05): an override for a child may not + overlap another override of the SAME child — two different + present/absent answers for one date are unresolvable. The + overlap check (inclusive boundaries) runs inside the same + transaction as the INSERT, so a racing create cannot slip a + conflicting override between the check and the write; a + conflict raises ValueError naming the conflicting override's + id and range. """ _validate_date(start_date, "start_date") _validate_date(end_date, "end_date") @@ -196,6 +205,21 @@ class PresenceOverridesDao: ) if child is None: raise ValueError(f"child {child_id} does not exist") + conflicting = await self._database.fetch_one( + f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides " + "WHERE child_id = ? AND end_date >= ? " + "AND start_date <= ? ORDER BY id LIMIT 1", + (child_id, start_date, end_date), + ) + if conflicting is not None: + conflict = _override_from_row(conflicting) + raise ValueError( + f"override for child {child_id} " + f"[{start_date}, {end_date}] overlaps existing " + f"override {conflict.id} " + f"[{conflict.start_date}, {conflict.end_date}] " + f"(is_present={conflict.is_present})" + ) result = await self._database.execute( "INSERT INTO presence_overrides (child_id, " "start_date, end_date, is_present, note) " diff --git a/tests/test_dao_presence.py b/tests/test_dao_presence.py index c4d183e..85e381d 100644 --- a/tests/test_dao_presence.py +++ b/tests/test_dao_presence.py @@ -619,4 +619,151 @@ def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None: ) assert sql_pattern.search(remaining) is None, ( "tests must go through the DAO, not raw SQL, for these tables" - ) \ No newline at end of file + ) + +# --------------------------------------------------------------------------- +# Presence overrides: overlap rejection naming the conflict +# --------------------------------------------------------------------------- + + +def test_override_create_round_trip_single_and_range(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + single = await overrides.create( + child.id, "2026-07-20", "2026-07-20", False + ) + assert (single.start_date, single.end_date) == ("2026-07-20",) * 2 + span = await overrides.create( + child.id, "2026-08-01", "2026-08-05", True, note="traded week" + ) + assert span.is_present is True + assert span.note == "traded week" + # Distinct, non-overlapping ranges coexist. + listed = await overrides.list_by_child_and_range( + child.id, "2026-01-01", "2026-12-31" + ) + assert [row.id for row in listed] == [single.id, span.id] + return listed + + _with_db(tmp_path, "override-round-trip.db")(_body) + + +def test_override_overlap_rejected_and_names_the_conflict(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + existing = await overrides.create( + child.id, "2026-07-20", "2026-07-24", False, note="holiday" + ) + for start, end in ( + ("2026-07-24", "2026-07-26"), # touches the existing end + ("2026-07-18", "2026-07-20"), # touches the existing start + ("2026-07-21", "2026-07-22"), # fully inside + ("2026-07-01", "2026-07-30"), # fully covering + ): + with pytest.raises(ValueError, match="overlaps existing"): + await overrides.create( + child.id, start, end, True, note="swap" + ) + # The error NAMES the conflicting override (id + its range). + with pytest.raises( + ValueError, + match=( + rf"override {existing.id} " + rf"\[2026-07-20, 2026-07-24\].*is_present=False" + ), + ): + await overrides.create(child.id, "2026-07-22", "2026-07-22", True) + # Nothing was written by any rejected attempt. + listed = await overrides.list_by_child_and_range( + child.id, "2026-01-01", "2026-12-31" + ) + assert [row.id for row in listed] == [existing.id] + return existing + + _with_db(tmp_path, "override-overlap.db")(_body) + + +def test_override_overlap_is_per_child(tmp_path) -> None: + """Different children's overrides may cover the same dates: the + conflict rule is per child.""" + async def _body(database, schedules, overrides, children, child): + other = await children.create("Bo", NOW) + await overrides.create(child.id, "2026-07-20", "2026-07-24", False) + twin = await overrides.create( + other.id, "2026-07-20", "2026-07-24", True + ) + assert twin.child_id == other.id + listed = await overrides.list_by_child_and_range( + other.id, "2026-01-01", "2026-12-31" + ) + assert [row.id for row in listed] == [twin.id] + return twin + + _with_db(tmp_path, "override-per-child.db")(_body) + + +def test_override_remove_then_recreate_overlapping(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + first = await overrides.create( + child.id, "2026-07-20", "2026-07-24", False + ) + assert await overrides.delete(first.id) is True + assert await overrides.delete(first.id) is False + replacement = await overrides.create( + child.id, "2026-07-20", "2026-07-24", True, note="swap back" + ) + assert replacement.is_present is True + return replacement + + _with_db(tmp_path, "override-recreate.db")(_body) + + +def test_override_create_racing_conflicts_serialize(tmp_path) -> None: + """Two racing creates for the same child and dates: exactly one + lands, the other is rejected with the overlap error (the check and + the INSERT share one transaction under the connection lock).""" + import asyncio as asyncio_module + + async def _main(): + from unittest.mock import AsyncMock, MagicMock + + from custom_components.nestquest.dao_children import ChildrenDao + from custom_components.nestquest.dao_presence import ( + PresenceOverridesDao, + ) + from custom_components.nestquest.db import NestQuestDatabase + from custom_components.nestquest.migrations import apply_migrations + + hass = MagicMock() + hass.async_add_executor_job = AsyncMock( + side_effect=(lambda fn, *a: fn(*a)) + ) + database = NestQuestDatabase(hass) + await database.open(tmp_path / "override-race.db") + try: + await apply_migrations(database) + children = ChildrenDao(database) + overrides = PresenceOverridesDao(database) + child = await children.create("Ada", NOW) + results = await asyncio_module.gather( + overrides.create( + child.id, "2026-07-20", "2026-07-24", True + ), + overrides.create( + child.id, "2026-07-22", "2026-07-26", False + ), + return_exceptions=True, + ) + outcomes = sorted( + "ok" if not isinstance(r, BaseException) else type(r).__name__ + for r in results + ) + assert outcomes == ["ValueError", "ok"], ( + f"exactly one create must land: {outcomes}" + ) + listed = await overrides.list_by_child_and_range( + child.id, "2026-01-01", "2026-12-31" + ) + assert len(listed) == 1 + finally: + await database.close() + + asyncio.new_event_loop().run_until_complete(_main()) From 51b1388233fc5cb9ed05653985fcec275da273ff Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:03:49 -0400 Subject: [PATCH 2/3] Fix: inverted ranges skip the overlap probe and hit the schema CHECK The matrix test's end-before-start probe (expecting the schema's end>=start IntegrityError) hit the new overlap query first, since an inverted range lexically 'overlaps' the seeded row. The overlap check now runs only for well-ordered ranges; invalid ones fall through to the INSERT and keep the IntegrityError contract. Suite: 990 passed. --- custom_components/nestquest/dao_presence.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/custom_components/nestquest/dao_presence.py b/custom_components/nestquest/dao_presence.py index 6bb5ddd..5279a25 100644 --- a/custom_components/nestquest/dao_presence.py +++ b/custom_components/nestquest/dao_presence.py @@ -205,12 +205,20 @@ class PresenceOverridesDao: ) if child is None: raise ValueError(f"child {child_id} does not exist") - conflicting = await self._database.fetch_one( - f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides " - "WHERE child_id = ? AND end_date >= ? " - "AND start_date <= ? ORDER BY id LIMIT 1", - (child_id, start_date, end_date), - ) + # Only a well-ordered range has overlap semantics: + # inverted ranges fall through to the INSERT and hit + # the schema's end>=start CHECK, preserving the + # IntegrityError contract the matrix tests rely on. + if end_date >= start_date: + conflicting = await self._database.fetch_one( + f"SELECT {_OVERRIDE_COLUMNS} " + "FROM presence_overrides " + "WHERE child_id = ? AND end_date >= ? " + "AND start_date <= ? ORDER BY id LIMIT 1", + (child_id, start_date, end_date), + ) + else: + conflicting = None if conflicting is not None: conflict = _override_from_row(conflicting) raise ValueError( From 94f54523f931dd68b081c4e7071f968485eb0376 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:06:44 -0400 Subject: [PATCH 3/3] Review fix: gate the override race test inside A's transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 (Codex gpt-5.6-terra, LOW): the racing test completed synchronously and proved nothing. The executor now gates task A's INSERT — inside its open transaction, after its overlap probe — so task B queues on the connection lock, re-runs the probe against A's committed row, and is rejected; exactly one lands. Suite: 990 passed. --- tests/test_dao_presence.py | 64 +++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/tests/test_dao_presence.py b/tests/test_dao_presence.py index 85e381d..0d86cb8 100644 --- a/tests/test_dao_presence.py +++ b/tests/test_dao_presence.py @@ -718,13 +718,15 @@ def test_override_remove_then_recreate_overlapping(tmp_path) -> None: def test_override_create_racing_conflicts_serialize(tmp_path) -> None: """Two racing creates for the same child and dates: exactly one - lands, the other is rejected with the overlap error (the check and - the INSERT share one transaction under the connection lock).""" + lands, the other is rejected with the overlap error. DETERMINISTIC + gate: task A is paused at its INSERT — inside its open transaction, + after its overlap probe — so task B is created while A provably + holds the connection lock and must queue; B then re-runs the probe + against A's committed row and is rejected.""" import asyncio as asyncio_module + from unittest.mock import MagicMock async def _main(): - from unittest.mock import AsyncMock, MagicMock - from custom_components.nestquest.dao_children import ChildrenDao from custom_components.nestquest.dao_presence import ( PresenceOverridesDao, @@ -732,10 +734,24 @@ def test_override_create_racing_conflicts_serialize(tmp_path) -> None: from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.migrations import apply_migrations + armed = {"active": False, "gated": False} + gate_open = asyncio_module.Event() + a_started = asyncio_module.Event() + hass = MagicMock() - hass.async_add_executor_job = AsyncMock( - side_effect=(lambda fn, *a: fn(*a)) - ) + + async def _gated_executor(fn, *args): + if armed["active"]: + # Gate the FIRST _execute after arming: task A's INSERT, + # which runs inside its open transaction (BEGIN already + # executed) after the overlap probe. + 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 database = NestQuestDatabase(hass) await database.open(tmp_path / "override-race.db") try: @@ -743,26 +759,32 @@ def test_override_create_racing_conflicts_serialize(tmp_path) -> None: children = ChildrenDao(database) overrides = PresenceOverridesDao(database) child = await children.create("Ada", NOW) - results = await asyncio_module.gather( - overrides.create( - child.id, "2026-07-20", "2026-07-24", True - ), - overrides.create( - child.id, "2026-07-22", "2026-07-26", False - ), - return_exceptions=True, + + armed["active"] = True + task_a = asyncio_module.ensure_future( + overrides.create(child.id, "2026-07-20", "2026-07-24", True) ) - outcomes = sorted( - "ok" if not isinstance(r, BaseException) else type(r).__name__ - for r in results + await a_started.wait() + armed["active"] = False + task_b = asyncio_module.ensure_future( + overrides.create(child.id, "2026-07-22", "2026-07-26", False) ) - assert outcomes == ["ValueError", "ok"], ( - f"exactly one create must land: {outcomes}" + await asyncio_module.sleep(0) + gate_open.set() + record_a, error_b = await asyncio_module.gather( + task_a, task_b, return_exceptions=True ) + assert isinstance(record_a, object) and not isinstance( + record_a, BaseException + ), f"A must land: {record_a!r}" + assert isinstance(error_b, ValueError), ( + f"B must be rejected with the overlap error: {error_b!r}" + ) + assert "overlaps existing" in str(error_b) listed = await overrides.list_by_child_and_range( child.id, "2026-01-01", "2026-12-31" ) - assert len(listed) == 1 + assert [row.id for row in listed] == [record_a.id] finally: await database.close()