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:
+313
-148
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user