Feature: multiple presence patterns per child (schema 9) (#232)

Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
This commit was merged in pull request #232.
This commit is contained in:
2026-09-24 20:15:27 -04:00
committed by approver_agent
parent d3c52974f5
commit c313e1c56f
33 changed files with 4157 additions and 1381 deletions
+313 -148
View File
@@ -1,4 +1,4 @@
"""Tests for dao_presence.py: presence schedules and overrides DAO."""
"""Tests for dao_presence.py: presence patterns and overrides DAO."""
from __future__ import annotations
import asyncio
@@ -10,8 +10,8 @@ from custom_components.nestquest.core.dao_children import ChildrenDao
from custom_components.nestquest.core.dao_presence import (
PresenceOverrideRecord,
PresenceOverridesDao,
PresenceScheduleRecord,
PresenceSchedulesDao,
PresencePatternRecord,
PresencePatternsDao,
)
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations
@@ -36,22 +36,22 @@ async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
await apply_migrations(database)
schedules = PresenceSchedulesDao(database)
patterns = PresencePatternsDao(database)
overrides = PresenceOverridesDao(database)
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
return database, schedules, overrides, children, child
return database, patterns, overrides, children, child
def _with_db(tmp_path, name):
def _run_test(body):
async def _main():
database, schedules, overrides, children, child = await _prepare(
database, patterns, overrides, children, child = await _prepare(
tmp_path / name
)
try:
return await body(
database, schedules, overrides, children, child
database, patterns, overrides, children, child
)
finally:
await database.close()
@@ -62,105 +62,152 @@ def _with_db(tmp_path, name):
# ---------------------------------------------------------------------------
# presence_schedules: upsert by child
# presence_patterns: create / list by child
# ---------------------------------------------------------------------------
def test_upsert_creates_then_updates_same_row(tmp_path) -> None:
"""Second upsert for the same child UPDATES, not raises."""
async def _body(database, schedules, overrides, children, child):
first = await schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2,4|1,3"
)
assert isinstance(first, PresenceScheduleRecord)
assert first.child_id == child.id
second = await schedules.upsert_by_child(
child.id, 2, "2026-09-14", "1,3|0,2,4"
)
assert second.id == first.id, "upsert must reuse the existing row"
fetched = await schedules.get_by_child(child.id)
assert fetched.anchor_date == "2026-09-14"
assert fetched.pattern == "1,3|0,2,4"
rows = await database.fetch_one(
"SELECT COUNT(*) FROM presence_schedules"
)
assert rows == (1,)
return second
_with_db(tmp_path, "upsert.db")(_body)
def test_upsert_typed_record_fields(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
record = await schedules.upsert_by_child(
child.id, 4, "2026-09-07", "0|1|2|3"
def test_create_returns_typed_record_fields(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
record = await patterns.create(
child.id, "Mum's rota", "home", 4, "2026-09-07", "0|1|2|3"
)
assert isinstance(record, PresencePatternRecord)
assert record.id == 1
assert record.child_id == child.id
assert record.name == "Mum's rota"
assert record.kind == "home"
assert record.cycle_length_weeks == 4
assert record.anchor_date == "2026-09-07"
assert record.pattern == "0|1|2|3"
assert await patterns.get(record.id) == record
return record
_with_db(tmp_path, "upsert-fields.db")(_body)
_with_db(tmp_path, "create-fields.db")(_body)
def test_upsert_unknown_child_raises_value_error(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
with pytest.raises(ValueError, match="does not exist"):
await schedules.upsert_by_child(
999, 2, "2026-09-07", "0,2|1,3"
def test_create_many_patterns_for_one_child(tmp_path) -> None:
"""Schema 9 drops the one-per-child UNIQUE: a second create for the
same child adds a row instead of raising or replacing."""
async def _body(database, patterns, overrides, children, child):
first = await patterns.create(
child.id, "Home weeks", "home", 2, "2026-09-07", "0,2,4|1,3"
)
second = await patterns.create(
child.id, "Thu/Fri away", "away", 1, "2026-09-07", "3,4"
)
third = await patterns.create(
child.id, "Home weeks", "home", 2, "2026-09-07", "0,2,4|1,3"
)
assert len({first.id, second.id, third.id}) == 3
listed = await patterns.list_by_child(child.id)
assert listed == [first, second, third]
rows = await database.fetch_one(
"SELECT COUNT(*) FROM presence_patterns"
)
assert rows == (3,)
return listed
_with_db(tmp_path, "create-many.db")(_body)
def test_create_unknown_child_raises_value_error(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
with pytest.raises(ValueError, match="child 999 does not exist"):
await patterns.create(
999, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
return None
_with_db(tmp_path, "upsert-bad-child.db")(_body)
_with_db(tmp_path, "create-bad-child.db")(_body)
def test_upsert_malformed_anchor_date_raises(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
def test_create_malformed_anchor_date_raises(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
for bad in ("not-a-date", "2026-9-7", "2026-13-01", ""):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
await schedules.upsert_by_child(child.id, 2, bad, "0|1")
await patterns.create(child.id, "Home", "home", 2, bad, "0|1")
assert await patterns.list_by_child(child.id) == []
return None
_with_db(tmp_path, "upsert-bad-date.db")(_body)
_with_db(tmp_path, "create-bad-date.db")(_body)
def test_upsert_malformed_pattern_rejected_by_schema(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
def test_create_malformed_pattern_rejected_by_schema(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2"
await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2"
)
return None
_with_db(tmp_path, "upsert-bad-pattern.db")(_body)
_with_db(tmp_path, "create-bad-pattern.db")(_body)
def test_upsert_two_children_have_distinct_rows(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
@pytest.mark.parametrize("bad_kind", ["present", "Home", "AWAY", ""])
def test_create_unknown_kind_rejected_by_schema(tmp_path, bad_kind) -> None:
async def _body(database, patterns, overrides, children, child):
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await patterns.create(
child.id, "Home", bad_kind, 1, "2026-09-07", "0"
)
assert await patterns.list_by_child(child.id) == []
return None
_with_db(tmp_path, "create-bad-kind.db")(_body)
def test_list_by_child_two_children_are_isolated(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
other = await children.create("Bo", NOW)
await schedules.upsert_by_child(child.id, 2, "2026-09-07", "0,2|1,3")
await schedules.upsert_by_child(other.id, 2, "2026-09-07", "1,3|0,2")
mine = await schedules.get_by_child(child.id)
theirs = await schedules.get_by_child(other.id)
assert mine.id != theirs.id
assert mine.pattern == "0,2|1,3"
assert theirs.pattern == "1,3|0,2"
await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
await patterns.create(
other.id, "Home", "home", 2, "2026-09-07", "1,3|0,2"
)
mine = await patterns.list_by_child(child.id)
theirs = await patterns.list_by_child(other.id)
assert [r.child_id for r in mine] == [child.id]
assert [r.child_id for r in theirs] == [other.id]
assert mine[0].id != theirs[0].id
assert mine[0].pattern == "0,2|1,3"
assert theirs[0].pattern == "1,3|0,2"
return mine
_with_db(tmp_path, "upsert-two-children.db")(_body)
_with_db(tmp_path, "list-two-children.db")(_body)
def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
"""Racing upserts for one child both succeed; exactly one row.
def test_list_by_child_orders_by_id(tmp_path) -> None:
"""Ordering is by id (creation order), not by name, kind or anchor."""
async def _body(database, patterns, overrides, children, child):
created = [
await patterns.create(
child.id, "Zed", "away", 1, "2026-09-21", "6"
),
await patterns.create(
child.id, "Alpha", "home", 1, "2026-09-07", "0"
),
await patterns.create(
child.id, "Mid", "away", 2, "2026-09-14", "1|2"
),
]
listed = await patterns.list_by_child(child.id)
assert [r.id for r in listed] == sorted(r.id for r in created)
assert [r.name for r in listed] == ["Zed", "Alpha", "Mid"]
return listed
_with_db(tmp_path, "list-order.db")(_body)
def test_create_concurrent_same_child_both_land(tmp_path) -> None:
"""Racing creates for one child both succeed as DISTINCT rows.
Deterministic interleaving: a controllable executor gates task A's
job AFTER it has acquired the connection lock and begun its
transaction, so task B is created while A provably owns the
transaction and must queue on the lock. Both upserts land, and
the table holds exactly one row (the ON CONFLICT upsert, not two
plain INSERTs, is what avoids a UNIQUE violation).
transaction and must queue on the lock. With no UNIQUE on
``child_id`` (schema 9) both INSERTs land and the child lists two
patterns in id order.
"""
async def _main():
from unittest.mock import MagicMock
@@ -180,13 +227,6 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
a_started = asyncio.Event()
async def _gated_executor(fn, *args):
# Gate the SECOND gated-window job: the first is the
# transaction's BEGIN (submitted and awaited by
# transaction() before upsert_by_child's child-SELECT
# runs). Pausing the child-existence SELECT proves the
# BEGIN has EXECUTED — SQLite is actually inside the open
# transaction, with A holding the connection lock — when
# task B gets scheduled and must queue.
if armed["active"]:
name = getattr(fn, "__name__", "")
if name == "_fetch_one" and not armed["gated"]:
@@ -197,17 +237,17 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
hass.async_add_executor_job = _gated_executor
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "upsert-race.db")
await database.open(tmp_path / "create-race.db")
try:
await apply_migrations(database)
children = ChildrenDao(database)
schedules = PresenceSchedulesDao(database)
patterns = PresencePatternsDao(database)
child = await children.create("Ada", NOW)
armed["active"] = True
task_a = asyncio.ensure_future(
schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2|1,3"
patterns.create(
child.id, "A", "home", 2, "2026-09-07", "0,2|1,3"
)
)
# Wait until task A's child-existence SELECT (inside its
@@ -217,81 +257,197 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
await a_started.wait()
armed["active"] = False
task_b = asyncio.ensure_future(
schedules.upsert_by_child(
child.id, 2, "2026-09-14", "1,3|0,2"
patterns.create(
child.id, "B", "away", 1, "2026-09-14", "5,6"
)
)
await asyncio.sleep(0)
# Release A; B's upsert must wait for A's commit and then
# land as the ON CONFLICT update path.
# Release A; B must wait for A's commit and then insert its
# own row.
gate_open.set()
record_a, record_b = await asyncio.gather(task_a, task_b)
assert record_a.id == record_b.id
fetched = await schedules.get_by_child(child.id)
assert fetched is not None
assert record_a.id != record_b.id
assert (record_a.name, record_b.name) == ("A", "B")
listed = await patterns.list_by_child(child.id)
rows = await database.fetch_one(
"SELECT COUNT(*) FROM presence_schedules"
"SELECT COUNT(*) FROM presence_patterns"
)
assert rows == (1,)
return fetched
assert rows == (2,)
return record_a, record_b, listed
finally:
await database.close()
fetched = _run(_main())
assert fetched.pattern in {"0,2|1,3", "1,3|0,2"}
record_a, record_b, listed = _run(_main())
assert listed == [record_a, record_b]
# ---------------------------------------------------------------------------
# presence_schedules: get / delete
# presence_patterns: list / get / update / delete
# ---------------------------------------------------------------------------
def test_get_by_child_missing_schedule_returns_none(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
return await schedules.get_by_child(child.id)
def test_list_by_child_without_patterns_returns_empty(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
return await patterns.list_by_child(child.id)
assert _with_db(tmp_path, "list-none.db")(_body) == []
def test_list_by_child_unknown_child_raises(tmp_path) -> None:
"""[] must mean ONLY 'no pattern rows': an unknown child id is a
caller bug, and silently reading a nonexistent child as 'present
every day' would be wrong — so list raises instead.
"""
async def _body(database, patterns, overrides, children, child):
with pytest.raises(ValueError, match="child 999 does not exist"):
await patterns.list_by_child(999)
return None
_with_db(tmp_path, "list-unknown.db")(_body)
def test_get_missing_pattern_returns_none(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
return await patterns.get(999)
assert _with_db(tmp_path, "get-none.db")(_body) is None
def test_get_by_child_unknown_child_raises(tmp_path) -> None:
"""None must mean ONLY 'no schedule row': an unknown child id is a
caller bug, and silently reading a nonexistent child as 'present
every day' would be wrong — so get raises instead.
"""
async def _body(database, schedules, overrides, children, child):
with pytest.raises(ValueError, match="does not exist"):
await schedules.get_by_child(999)
def test_update_rewrites_every_field_but_child(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
record = await patterns.create(
child.id, "Home weeks", "home", 2, "2026-09-07", "0,2,4|1,3"
)
updated = await patterns.update(
record.id, "Weekends away", "away", 1, "2026-09-12", "5,6"
)
assert updated == PresencePatternRecord(
id=record.id,
child_id=child.id,
name="Weekends away",
kind="away",
cycle_length_weeks=1,
anchor_date="2026-09-12",
pattern="5,6",
)
assert await patterns.get(record.id) == updated
assert await patterns.list_by_child(child.id) == [updated]
return updated
_with_db(tmp_path, "update.db")(_body)
def test_update_touches_only_the_addressed_pattern(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
first = await patterns.create(
child.id, "Home", "home", 1, "2026-09-07", "0,1,2"
)
second = await patterns.create(
child.id, "Away", "away", 1, "2026-09-07", "5"
)
updated = await patterns.update(
second.id, "Away", "away", 1, "2026-09-07", "5,6"
)
assert await patterns.list_by_child(child.id) == [first, updated]
return updated
_with_db(tmp_path, "update-one.db")(_body)
def test_update_unknown_id_returns_none(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
result = await patterns.update(
999, "Home", "home", 1, "2026-09-07", "0"
)
assert await patterns.list_by_child(child.id) == []
return result
assert _with_db(tmp_path, "update-unknown.db")(_body) is None
def test_update_malformed_anchor_date_raises(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
record = await patterns.create(
child.id, "Home", "home", 1, "2026-09-07", "0"
)
for bad in ("not-a-date", "2026-9-7", "2026-13-01", ""):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
await patterns.update(record.id, "Home", "home", 1, bad, "0")
assert await patterns.get(record.id) == record
return None
_with_db(tmp_path, "get-unknown.db")(_body)
_with_db(tmp_path, "update-bad-date.db")(_body)
def test_update_bad_kind_and_pattern_rejected_by_schema(tmp_path) -> None:
"""The CHECKs apply on UPDATE exactly as on INSERT, and a rejected
update leaves the stored row unchanged."""
async def _body(database, patterns, overrides, children, child):
record = await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await patterns.update(
record.id, "Home", "present", 2, "2026-09-07", "0,2|1,3"
)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await patterns.update(
record.id, "Home", "home", 2, "2026-09-07", "0,2"
)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await patterns.update(
record.id, "Home", "home", 5, "2026-09-07", "0|1|2|3|4"
)
assert await patterns.get(record.id) == record
return None
_with_db(tmp_path, "update-bad-check.db")(_body)
def test_delete_removes_and_returns_true_then_false(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
await schedules.upsert_by_child(child.id, 2, "2026-09-07", "0,2|1,3")
assert await schedules.delete(child.id) is True
assert await schedules.get_by_child(child.id) is None
assert await schedules.delete(child.id) is False
async def _body(database, patterns, overrides, children, child):
record = await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
assert await patterns.delete(record.id) is True
assert await patterns.get(record.id) is None
assert await patterns.list_by_child(child.id) == []
assert await patterns.delete(record.id) is False
return None
_with_db(tmp_path, "delete.db")(_body)
def test_delete_child_without_schedule_returns_false(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
return await schedules.delete(child.id)
def test_delete_missing_pattern_returns_false(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
return await patterns.delete(999)
assert _with_db(tmp_path, "delete-none.db")(_body) is False
def test_delete_then_upsert_creates_fresh_row(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
first = await schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2|1,3"
def test_delete_leaves_the_childs_other_patterns(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
first = await patterns.create(
child.id, "Home", "home", 1, "2026-09-07", "0,1,2"
)
await schedules.delete(child.id)
second = await schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2|1,3"
second = await patterns.create(
child.id, "Away", "away", 1, "2026-09-07", "5"
)
assert await patterns.delete(first.id) is True
assert await patterns.list_by_child(child.id) == [second]
return None
_with_db(tmp_path, "delete-others.db")(_body)
def test_delete_then_create_makes_fresh_row(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
first = await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
await patterns.delete(first.id)
second = await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
assert second.id != first.id
return second
@@ -305,7 +461,7 @@ def test_delete_then_upsert_creates_fresh_row(tmp_path) -> None:
def test_override_create_returns_typed_record(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
record = await overrides.create(
child.id, "2026-12-21", "2027-01-04", False,
note="Holiday abroad",
@@ -322,7 +478,7 @@ def test_override_create_returns_typed_record(tmp_path) -> None:
def test_override_create_single_day_same_start_and_end(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
record = await overrides.create(
child.id, "2026-09-14", "2026-09-14", True
)
@@ -335,7 +491,7 @@ def test_override_create_single_day_same_start_and_end(tmp_path) -> None:
def test_override_create_unknown_child_raises(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
with pytest.raises(ValueError, match="does not exist"):
await overrides.create(
999, "2026-09-14", "2026-09-14", True
@@ -346,7 +502,7 @@ def test_override_create_unknown_child_raises(tmp_path) -> None:
def test_override_create_malformed_dates_raise(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
for bad in ("not-a-date", "2026-9-14", ""):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
await overrides.create(child.id, bad, "2026-09-14", True)
@@ -358,7 +514,7 @@ def test_override_create_malformed_dates_raise(tmp_path) -> None:
def test_override_end_before_start_rejected_by_schema(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await overrides.create(
child.id, "2026-09-14", "2026-09-13", True
@@ -371,7 +527,7 @@ def test_override_end_before_start_rejected_by_schema(tmp_path) -> None:
def test_override_list_by_child_and_range_filters_correctly(
tmp_path,
) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
await overrides.create(
child.id, "2026-09-01", "2026-09-05", False, note="before"
)
@@ -400,7 +556,7 @@ def test_override_list_by_child_and_range_filters_correctly(
def test_override_list_orders_by_start_date(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
late = await overrides.create(
child.id, "2026-09-20", "2026-09-21", True
)
@@ -417,7 +573,7 @@ def test_override_list_orders_by_start_date(tmp_path) -> None:
def test_override_list_per_child_isolation(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
other = await children.create("Bo", NOW)
await overrides.create(child.id, "2026-09-10", "2026-09-11", True)
await overrides.create(other.id, "2026-09-10", "2026-09-11", False)
@@ -432,7 +588,7 @@ def test_override_list_per_child_isolation(tmp_path) -> None:
def test_override_list_malformed_range_raises(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
await overrides.list_by_child_and_range(
child.id, "oops", "2026-09-30"
@@ -447,7 +603,7 @@ def test_override_list_malformed_range_raises(tmp_path) -> None:
def test_override_list_unknown_child_returns_empty(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
return await overrides.list_by_child_and_range(
999, "2026-09-01", "2026-09-30"
)
@@ -456,7 +612,7 @@ def test_override_list_unknown_child_returns_empty(tmp_path) -> None:
def test_override_get_round_trip(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
record = await overrides.create(
child.id, "2026-09-10", "2026-09-11", True, note="camp"
)
@@ -470,7 +626,7 @@ def test_override_get_round_trip(tmp_path) -> None:
def test_override_delete_round_trip(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
record = await overrides.create(
child.id, "2026-09-10", "2026-09-11", True
)
@@ -486,14 +642,14 @@ def test_override_delete_round_trip(tmp_path) -> None:
def test_override_delete_missing_returns_false(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
return await overrides.delete(999)
assert _with_db(tmp_path, "override-delete-missing.db")(_body) is False
def test_override_delete_leaves_other_overrides(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
first = await overrides.create(
child.id, "2026-09-10", "2026-09-11", True
)
@@ -510,18 +666,25 @@ def test_override_delete_leaves_other_overrides(tmp_path) -> None:
_with_db(tmp_path, "override-delete-others.db")(_body)
def test_schedule_and_override_independent_for_same_child(tmp_path) -> None:
"""Deleting the schedule does not touch overrides and vice versa."""
async def _body(database, schedules, overrides, children, child):
await schedules.upsert_by_child(child.id, 2, "2026-09-07", "0,2|1,3")
def test_pattern_and_override_independent_for_same_child(tmp_path) -> None:
"""Deleting a pattern does not touch overrides and vice versa."""
async def _body(database, patterns, overrides, children, child):
pattern = await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
override = await overrides.create(
child.id, "2026-09-10", "2026-09-11", True
)
assert await schedules.delete(child.id) is True
assert await patterns.delete(pattern.id) is True
found = await overrides.list_by_child_and_range(
child.id, "2026-09-01", "2026-09-30"
)
assert [r.id for r in found] == [override.id]
second = await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
assert await overrides.delete(override.id) is True
assert await patterns.list_by_child(child.id) == [second]
return found
_with_db(tmp_path, "independent.db")(_body)
@@ -533,7 +696,7 @@ def test_schedule_and_override_independent_for_same_child(tmp_path) -> None:
def test_presence_sql_lives_only_in_dao_module() -> None:
"""Guardrail: presence_schedules/presence_overrides SQL may appear
"""Guardrail: presence_patterns/presence_overrides SQL may appear
only in the DAO module (and the schema DDL declarations).
RECURSIVE scan of package and tests, FROM/INTO/UPDATE/DELETE FROM/
JOIN pattern, with exact repo-relative exclusions justified by
@@ -562,7 +725,7 @@ def test_presence_sql_lives_only_in_dao_module() -> None:
}
sql_pattern = re.compile(
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?"
r"(presence_schedules|presence_overrides)\b",
r"(presence_patterns|presence_overrides)\b",
re.IGNORECASE,
)
offenders: list[str] = []
@@ -574,7 +737,7 @@ def test_presence_sql_lives_only_in_dao_module() -> None:
if sql_pattern.search(py.read_text()):
offenders.append(relative)
assert offenders == [], (
f"SQL touching presence_schedules/presence_overrides leaked "
f"SQL touching presence_patterns/presence_overrides leaked "
f"into: {offenders}"
)
@@ -582,8 +745,9 @@ def test_presence_sql_lives_only_in_dao_module() -> None:
def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None:
"""This test module must exercise the DAO, not raw SQL, for these
tables. Exemptions are EXACT lines, not whole-function spans: the
two single-statement COUNT(*) row probes (the only honest way to
prove 'exactly one row exists' without a listing method) and the
single-statement COUNT(*) row probes (the only honest way to prove
how many rows the whole table holds, which a per-child listing
cannot) and the
guard functions' own regex spans. Any other raw SQL added anywhere
in this file fails the guard.
"""
@@ -615,7 +779,7 @@ def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None:
isinstance(sub, ast.Constant)
and isinstance(sub.value, str)
and sub.value.strip()
== "SELECT COUNT(*) FROM presence_schedules"
== "SELECT COUNT(*) FROM presence_patterns"
and sub.lineno is not None
):
excluded.add(sub.lineno)
@@ -627,7 +791,8 @@ def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None:
)
sql_pattern = re.compile(
r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|"
r"FROM|JOIN)\s+[`'\"]*(\[)?(presence_schedules|presence_overrides)"
r"FROM|JOIN)\s+[`'\"]*(\[)?"
r"(presence_patterns|presence_overrides)"
r"\b",
re.IGNORECASE,
)
@@ -641,7 +806,7 @@ def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None:
def test_override_create_round_trip_single_and_range(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
single = await overrides.create(
child.id, "2026-07-20", "2026-07-20", False
)
@@ -662,7 +827,7 @@ def test_override_create_round_trip_single_and_range(tmp_path) -> None:
def test_override_overlap_rejected_and_names_the_conflict(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
existing = await overrides.create(
child.id, "2026-07-20", "2026-07-24", False, note="holiday"
)
@@ -698,7 +863,7 @@ def test_override_overlap_rejected_and_names_the_conflict(tmp_path) -> None:
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):
async def _body(database, patterns, 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(
@@ -715,7 +880,7 @@ def test_override_overlap_is_per_child(tmp_path) -> None:
def test_override_remove_then_recreate_overlapping(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
first = await overrides.create(
child.id, "2026-07-20", "2026-07-24", False
)