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
|
The child must exist (validated atomically under the
|
||||||
connection lock). Date shape is validated strictly, and the
|
connection lock). Date shape is validated strictly, and the
|
||||||
schema CHECK rejects end < start.
|
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(start_date, "start_date")
|
||||||
_validate_date(end_date, "end_date")
|
_validate_date(end_date, "end_date")
|
||||||
@@ -196,6 +205,21 @@ class PresenceOverridesDao:
|
|||||||
)
|
)
|
||||||
if child is None:
|
if child is None:
|
||||||
raise ValueError(f"child {child_id} does not exist")
|
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(
|
result = await self._database.execute(
|
||||||
"INSERT INTO presence_overrides (child_id, "
|
"INSERT INTO presence_overrides (child_id, "
|
||||||
"start_date, end_date, is_present, note) "
|
"start_date, end_date, is_present, note) "
|
||||||
|
|||||||
@@ -620,3 +620,150 @@ def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None:
|
|||||||
assert sql_pattern.search(remaining) is None, (
|
assert sql_pattern.search(remaining) is None, (
|
||||||
"tests must go through the DAO, not raw SQL, for these tables"
|
"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