Presence override overlap rejection naming the conflict (Feature 05, task 4)
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.
This commit is contained in:
@@ -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) "
|
||||
|
||||
+148
-1
@@ -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"
|
||||
)
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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())
|
||||
|
||||
Reference in New Issue
Block a user