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())