diff --git a/custom_components/nestquest/dao_presence.py b/custom_components/nestquest/dao_presence.py new file mode 100644 index 0000000..c19e821 --- /dev/null +++ b/custom_components/nestquest/dao_presence.py @@ -0,0 +1,247 @@ +"""Typed DAO layer for the presence_schedules and presence_overrides tables. + +All SQL for these tables lives in this module per the feature +guardrails: callers get typed dataclasses back and never see raw rows +or SQL. Every method is async and runs through +:class:`~.db.NestQuestDatabase`, so each statement executes on the HA +executor and the event loop never blocks. + +Semantics per the done-condition and Feature 05: + +- A child has AT MOST ONE presence schedule (schema UNIQUE); upserting + by child updates the existing row rather than raising. +- A child with NO schedule row is present every day — the absence of a + row is meaningful state, expressed by :meth:`PresenceSchedulesDao.get_by_child` + returning None, never by an empty pattern row. +- Overrides beat the pattern for their date range (that resolution is + the presence engine's job, Feature 05); the DAO only stores and + lists them. A single-day override stores the same date as both + start and end (schema CHECK requires end >= start). + +Date arguments are validated as strict YYYY-MM-DD per the same policy +the rules DAO applies: the schema's lexical CHECKs cannot prove +calendar validity. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from .dao_children import _connection_lock +from .dao_rules import _UNSET, _validate_date +from .db import NestQuestDatabase + + +@dataclass(frozen=True) +class PresenceScheduleRecord: + """One row of ``presence_schedules``.""" + + id: int + child_id: int + cycle_length_weeks: int + anchor_date: str + pattern: str + + +@dataclass(frozen=True) +class PresenceOverrideRecord: + """One row of ``presence_overrides``.""" + + id: int + child_id: int + start_date: str + end_date: str + is_present: bool + note: str | None + + +_SCHEDULE_COLUMNS = ( + "id, child_id, cycle_length_weeks, anchor_date, pattern" +) +_OVERRIDE_COLUMNS = ( + "id, child_id, start_date, end_date, is_present, note" +) + + +def _schedule_from_row(row: tuple) -> PresenceScheduleRecord: + return PresenceScheduleRecord( + id=row[0], + child_id=row[1], + cycle_length_weeks=row[2], + anchor_date=row[3], + pattern=row[4], + ) + + +def _override_from_row(row: tuple) -> PresenceOverrideRecord: + return PresenceOverrideRecord( + id=row[0], + child_id=row[1], + start_date=row[2], + end_date=row[3], + is_present=bool(row[4]), + note=row[5], + ) + + +class PresenceSchedulesDao: + """Typed async access to the ``presence_schedules`` table.""" + + def __init__(self, database: NestQuestDatabase) -> None: + self._database = database + + async def upsert_by_child( + self, + child_id: int, + cycle_length_weeks: int, + anchor_date: str, + pattern: str, + ) -> PresenceScheduleRecord: + """Set the child's presence schedule, replacing any existing one. + + Upsert semantics are the done-condition: a second schedule for + the same child updates the existing row rather than raising. + Runs under the connection lock so a concurrent upsert/delete + for the same child serializes instead of colliding on the + schema's UNIQUE(child_id). Pattern validity (segment shape, + segment count = cycle_length_weeks) is enforced by the schema + CHECKs; the child must exist (FK) — validated atomically here + so a deleted child cannot pass a stale check. + """ + _validate_date(anchor_date, "anchor_date") + async with _connection_lock(self._database): + async with self._database.transaction(): + child = await self._database.fetch_one( + "SELECT 1 FROM children WHERE id = ?", (child_id,) + ) + if child is None: + raise ValueError(f"child {child_id} does not exist") + await self._database.execute( + "INSERT INTO presence_schedules (child_id, " + "cycle_length_weeks, anchor_date, pattern) " + "VALUES (?, ?, ?, ?) " + "ON CONFLICT (child_id) DO UPDATE SET " + "cycle_length_weeks = excluded.cycle_length_weeks, " + "anchor_date = excluded.anchor_date, " + "pattern = excluded.pattern", + (child_id, cycle_length_weeks, anchor_date, pattern), + ) + schedule = await self.get_by_child(child_id) + assert schedule is not None + return schedule + + async def get_by_child( + self, child_id: int + ) -> PresenceScheduleRecord | None: + """Return the child's schedule, or None when the child has none. + + None is meaningful and UNAMBIGUOUS here: the child exists and + has no schedule row, i.e. the child is present every day + (Feature 05 guardrail). An unknown child id raises ValueError + instead of returning None, because "nonexistent child" must + never be silently read as "present every day". + """ + child = await self._database.fetch_one( + "SELECT 1 FROM children WHERE id = ?", (child_id,) + ) + if child is None: + raise ValueError(f"child {child_id} does not exist") + row = await self._database.fetch_one( + f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules " + "WHERE child_id = ?", + (child_id,), + ) + return _schedule_from_row(row) if row is not None else None + + async def delete(self, child_id: int) -> bool: + """Remove the child's schedule; True if a row was removed. + + After deletion the child is present every day (no row means + no restriction). Deleting a schedule the child never had + returns False. + """ + result = await self._database.execute( + "DELETE FROM presence_schedules WHERE child_id = ?", + (child_id,), + ) + return result.rowcount > 0 + + +class PresenceOverridesDao: + """Typed async access to the ``presence_overrides`` table.""" + + def __init__(self, database: NestQuestDatabase) -> None: + self._database = database + + async def create( + self, + child_id: int, + start_date: str, + end_date: str, + is_present: bool, + *, + note: str | None = None, + ) -> PresenceOverrideRecord: + """Insert one override and return the record as stored. + + The child must exist (validated atomically under the + connection lock). Date shape is validated strictly, and the + schema CHECK rejects end < start. + """ + _validate_date(start_date, "start_date") + _validate_date(end_date, "end_date") + async with _connection_lock(self._database): + async with self._database.transaction(): + child = await self._database.fetch_one( + "SELECT 1 FROM children WHERE id = ?", (child_id,) + ) + if child is None: + raise ValueError(f"child {child_id} does not exist") + result = await self._database.execute( + "INSERT INTO presence_overrides (child_id, " + "start_date, end_date, is_present, note) " + "VALUES (?, ?, ?, ?, ?)", + (child_id, start_date, end_date, int(is_present), note), + ) + override = await self._database.fetch_one( + f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides " + "WHERE id = ?", + (result.lastrowid,), + ) + assert override is not None + return _override_from_row(override) + + async def list_by_child_and_range( + self, + child_id: int, + range_start: str, + range_end: str, + ) -> list[PresenceOverrideRecord]: + """Return the child's overrides overlapping [range_start, range_end]. + + An override overlaps the range when it ends on or after the + range starts and starts on or before the range ends (inclusive + boundary dates, matching the storage model where overrides are + closed date ranges). Ordered by start date. + """ + _validate_date(range_start, "range_start") + _validate_date(range_end, "range_end") + if range_end < range_start: + raise ValueError( + "range_end must be on or after range_start, got " + f"{range_end!r} < {range_start!r}" + ) + rows = await self._database.fetch_all( + f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides " + "WHERE child_id = ? AND end_date >= ? AND start_date <= ? " + "ORDER BY start_date", + (child_id, range_start, range_end), + ) + return [_override_from_row(row) for row in rows] + + async def delete(self, override_id: int) -> bool: + """Remove one override by id; True if a row was removed.""" + result = await self._database.execute( + "DELETE FROM presence_overrides WHERE id = ?", + (override_id,), + ) + return result.rowcount > 0 \ No newline at end of file diff --git a/tests/test_dao_children.py b/tests/test_dao_children.py index b40a0cb..89d4911 100644 --- a/tests/test_dao_children.py +++ b/tests/test_dao_children.py @@ -481,12 +481,16 @@ def test_children_and_admin_sql_lives_only_in_dao_module() -> None: "custom_components/nestquest/dao_rules.py", # validates child # activeness on assignment (FK to children, done-condition # 'validates that the assigned child is active') + "custom_components/nestquest/dao_presence.py", # validates the + # schedule/override child exists before writing (same FK shape + # as dao_rules: one existence SELECT per write) "custom_components/nestquest/schema.py", # declares the DDL "custom_components/nestquest/migrations.py", # applies the DDL "tests/test_schema.py", # tests the DDL "tests/test_migrations.py", # tests migration application "tests/test_dao_children.py", # this file, scanned separately "tests/test_dao_rules.py", # rules/definitions guard, own scope + "tests/test_dao_presence.py", # presence guard, own scope } sql_pattern = re.compile( r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+" diff --git a/tests/test_dao_presence.py b/tests/test_dao_presence.py new file mode 100644 index 0000000..c4d183e --- /dev/null +++ b/tests/test_dao_presence.py @@ -0,0 +1,622 @@ +"""Tests for dao_presence.py: presence schedules and overrides DAO.""" +from __future__ import annotations + +import asyncio +import sqlite3 + +import pytest + +from custom_components.nestquest.dao_children import ChildrenDao +from custom_components.nestquest.dao_presence import ( + PresenceOverrideRecord, + PresenceOverridesDao, + PresenceScheduleRecord, + PresenceSchedulesDao, +) +from custom_components.nestquest.db import NestQuestDatabase +from custom_components.nestquest.migrations import apply_migrations + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +def _make_hass_mock(): + from unittest.mock import AsyncMock, MagicMock + + hass = MagicMock() + hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) + return hass + + +NOW = "2026-09-14T12:00:00+00:00" + + +async def _prepare(path) -> tuple: + database = NestQuestDatabase(_make_hass_mock()) + await database.open(path) + await apply_migrations(database) + schedules = PresenceSchedulesDao(database) + overrides = PresenceOverridesDao(database) + children = ChildrenDao(database) + child = await children.create("Ada", NOW) + return database, schedules, overrides, children, child + + +def _with_db(tmp_path, name): + def _run_test(body): + async def _main(): + database, schedules, overrides, children, child = await _prepare( + tmp_path / name + ) + try: + return await body( + database, schedules, overrides, children, child + ) + finally: + await database.close() + + return _run(_main()) + + return _run_test + + +# --------------------------------------------------------------------------- +# presence_schedules: upsert 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" + ) + assert record.id == 1 + assert record.child_id == child.id + assert record.cycle_length_weeks == 4 + assert record.anchor_date == "2026-09-07" + assert record.pattern == "0|1|2|3" + return record + + _with_db(tmp_path, "upsert-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" + ) + return None + + _with_db(tmp_path, "upsert-bad-child.db")(_body) + + +def test_upsert_malformed_anchor_date_raises(tmp_path) -> None: + async def _body(database, schedules, 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") + return None + + _with_db(tmp_path, "upsert-bad-date.db")(_body) + + +def test_upsert_malformed_pattern_rejected_by_schema(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + with pytest.raises(sqlite3.IntegrityError, match="CHECK"): + await schedules.upsert_by_child( + child.id, 2, "2026-09-07", "0,2" + ) + return None + + _with_db(tmp_path, "upsert-bad-pattern.db")(_body) + + +def test_upsert_two_children_have_distinct_rows(tmp_path) -> None: + async def _body(database, schedules, 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" + return mine + + _with_db(tmp_path, "upsert-two-children.db")(_body) + + +def test_upsert_concurrent_same_child_serializes(tmp_path) -> None: + """Racing upserts for one child both succeed; exactly one row. + + 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). + """ + async def _main(): + from unittest.mock import MagicMock + + hass = MagicMock() + # Gate exactly ONE job: the first _fetch_one issued after + # arming, which is task A's child-existence SELECT inside its + # transaction. The transaction's BEGIN runs first and is NOT + # gated, so when the gated fetch signals, BEGIN has executed + # and SQLite is inside the open transaction (A holds the + # connection lock from BEGIN until commit). Everything before + # arming (open, migrations, child create) + # and everything after (B's jobs, A's remaining jobs, reads) + # pass straight through. + armed = {"active": False, "gated": False} + gate_open = asyncio.Event() + 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"]: + armed["gated"] = True + a_started.set() + await gate_open.wait() + return fn(*args) + + hass.async_add_executor_job = _gated_executor + database = NestQuestDatabase(hass) + await database.open(tmp_path / "upsert-race.db") + try: + await apply_migrations(database) + children = ChildrenDao(database) + schedules = PresenceSchedulesDao(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" + ) + ) + # Wait until task A's child-existence SELECT (inside its + # open transaction, BEGIN already executed) is paused, then + # schedule task B so it must queue behind the open + # transaction. + 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" + ) + ) + await asyncio.sleep(0) + # Release A; B's upsert must wait for A's commit and then + # land as the ON CONFLICT update path. + 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 + rows = await database.fetch_one( + "SELECT COUNT(*) FROM presence_schedules" + ) + assert rows == (1,) + return fetched + finally: + await database.close() + + fetched = _run(_main()) + assert fetched.pattern in {"0,2|1,3", "1,3|0,2"} + + +# --------------------------------------------------------------------------- +# presence_schedules: get / 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) + + 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) + return None + + _with_db(tmp_path, "get-unknown.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 + 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) + + 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" + ) + await schedules.delete(child.id) + second = await schedules.upsert_by_child( + child.id, 2, "2026-09-07", "0,2|1,3" + ) + assert second.id != first.id + return second + + _with_db(tmp_path, "delete-recreate.db")(_body) + + +# --------------------------------------------------------------------------- +# presence_overrides: create / list / delete +# --------------------------------------------------------------------------- + + +def test_override_create_returns_typed_record(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + record = await overrides.create( + child.id, "2026-12-21", "2027-01-04", False, + note="Holiday abroad", + ) + assert isinstance(record, PresenceOverrideRecord) + assert record.child_id == child.id + assert record.start_date == "2026-12-21" + assert record.end_date == "2027-01-04" + assert record.is_present is False + assert record.note == "Holiday abroad" + return record + + _with_db(tmp_path, "override-create.db")(_body) + + +def test_override_create_single_day_same_start_and_end(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + record = await overrides.create( + child.id, "2026-09-14", "2026-09-14", True + ) + assert record.start_date == record.end_date + assert record.is_present is True + assert record.note is None + return record + + _with_db(tmp_path, "override-single-day.db")(_body) + + +def test_override_create_unknown_child_raises(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + with pytest.raises(ValueError, match="does not exist"): + await overrides.create( + 999, "2026-09-14", "2026-09-14", True + ) + return None + + _with_db(tmp_path, "override-bad-child.db")(_body) + + +def test_override_create_malformed_dates_raise(tmp_path) -> None: + async def _body(database, schedules, 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) + with pytest.raises(ValueError, match="YYYY-MM-DD"): + await overrides.create(child.id, "2026-09-14", bad, True) + return None + + _with_db(tmp_path, "override-bad-dates.db")(_body) + + +def test_override_end_before_start_rejected_by_schema(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + with pytest.raises(sqlite3.IntegrityError, match="CHECK"): + await overrides.create( + child.id, "2026-09-14", "2026-09-13", True + ) + return None + + _with_db(tmp_path, "override-end-before-start.db")(_body) + + +def test_override_list_by_child_and_range_filters_correctly( + tmp_path, +) -> None: + async def _body(database, schedules, overrides, children, child): + await overrides.create( + child.id, "2026-09-01", "2026-09-05", False, note="before" + ) + inside = await overrides.create( + child.id, "2026-09-10", "2026-09-12", False, note="inside" + ) + overlapping = await overrides.create( + child.id, "2026-09-20", "2026-09-25", True, note="overlap" + ) + after = await overrides.create( + child.id, "2026-10-01", "2026-10-02", False, note="after" + ) + found = await overrides.list_by_child_and_range( + child.id, "2026-09-09", "2026-09-22" + ) + assert [r.id for r in found] == [inside.id, overlapping.id] + # Inclusive boundaries: a range touching the override's edges + # still matches. + edge = await overrides.list_by_child_and_range( + child.id, "2026-09-20", "2026-09-20" + ) + assert [r.id for r in edge] == [overlapping.id] + return found + + _with_db(tmp_path, "override-list-range.db")(_body) + + +def test_override_list_orders_by_start_date(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + late = await overrides.create( + child.id, "2026-09-20", "2026-09-21", True + ) + early = await overrides.create( + child.id, "2026-09-10", "2026-09-11", False + ) + found = await overrides.list_by_child_and_range( + child.id, "2026-09-01", "2026-09-30" + ) + assert [r.id for r in found] == [early.id, late.id] + return found + + _with_db(tmp_path, "override-list-order.db")(_body) + + +def test_override_list_per_child_isolation(tmp_path) -> None: + async def _body(database, schedules, 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) + mine = await overrides.list_by_child_and_range( + child.id, "2026-09-01", "2026-09-30" + ) + assert len(mine) == 1 + assert mine[0].is_present is True + return mine + + _with_db(tmp_path, "override-isolation.db")(_body) + + +def test_override_list_malformed_range_raises(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + with pytest.raises(ValueError, match="YYYY-MM-DD"): + await overrides.list_by_child_and_range( + child.id, "oops", "2026-09-30" + ) + with pytest.raises(ValueError, match="on or after"): + await overrides.list_by_child_and_range( + child.id, "2026-09-30", "2026-09-01" + ) + return None + + _with_db(tmp_path, "override-list-bad-range.db")(_body) + + +def test_override_list_unknown_child_returns_empty(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + return await overrides.list_by_child_and_range( + 999, "2026-09-01", "2026-09-30" + ) + + assert _with_db(tmp_path, "override-list-unknown.db")(_body) == [] + + +def test_override_delete_round_trip(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + record = await overrides.create( + child.id, "2026-09-10", "2026-09-11", True + ) + assert await overrides.delete(record.id) is True + found = await overrides.list_by_child_and_range( + child.id, "2026-09-01", "2026-09-30" + ) + assert found == [] + assert await overrides.delete(record.id) is False + return None + + _with_db(tmp_path, "override-delete.db")(_body) + + +def test_override_delete_missing_returns_false(tmp_path) -> None: + async def _body(database, schedules, 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): + first = await overrides.create( + child.id, "2026-09-10", "2026-09-11", True + ) + second = await overrides.create( + child.id, "2026-09-20", "2026-09-21", False + ) + assert await overrides.delete(first.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] == [second.id] + return found + + _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") + override = await overrides.create( + child.id, "2026-09-10", "2026-09-11", True + ) + assert await schedules.delete(child.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] + return found + + _with_db(tmp_path, "independent.db")(_body) + + +# --------------------------------------------------------------------------- +# No SQL for these tables outside the DAO module +# --------------------------------------------------------------------------- + + +def test_presence_sql_lives_only_in_dao_module() -> None: + """Guardrail: presence_schedules/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 + role: schema.py declares the DDL, migrations.py applies it, and + the test files for exactly those layers verify DDL, not data + paths. + """ + import re + from pathlib import Path + + package = Path( + __import__( + "custom_components.nestquest", fromlist=["__file__"] + ).__file__ + ).parent + repo_root = package.parent.parent + scan_roots = [package, repo_root / "tests"] + + allowed = { + "custom_components/nestquest/dao_presence.py", # this DAO + "custom_components/nestquest/schema.py", # declares the DDL + "custom_components/nestquest/migrations.py", # applies the DDL + "tests/test_schema.py", # tests the DDL + "tests/test_migrations.py", # tests migration application + "tests/test_dao_presence.py", # this file, scanned separately + } + sql_pattern = re.compile( + r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?" + r"(presence_schedules|presence_overrides)\b", + re.IGNORECASE, + ) + offenders: list[str] = [] + for root in scan_roots: + for py in sorted(root.rglob("*.py")): + relative = py.relative_to(repo_root).as_posix() + if relative in allowed: + continue + if sql_pattern.search(py.read_text()): + offenders.append(relative) + assert offenders == [], ( + f"SQL touching presence_schedules/presence_overrides leaked " + f"into: {offenders}" + ) + + +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 + guard functions' own regex spans. Any other raw SQL added anywhere + in this file fails the guard. + """ + import ast + import re + from pathlib import Path + + path = Path(__file__) + text = path.read_text() + lines = text.splitlines(keepends=True) + tree = ast.parse(text) + guard_names = { + "test_presence_sql_lives_only_in_dao_module", + "test_dao_presence_test_file_uses_dao_not_raw_table_sql", + } + excluded: set[int] = set() + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if node.name in guard_names: + excluded.update( + range(node.lineno, (node.end_lineno or node.lineno) + 1) + ) + continue + # Narrow probe exemption: only the string-literal line holding + # the sanctioned COUNT(*) query, not the enclosing function. + for sub in ast.walk(node): + if ( + isinstance(sub, ast.Constant) + and isinstance(sub.value, str) + and sub.value.strip() + == "SELECT COUNT(*) FROM presence_schedules" + and sub.lineno is not None + ): + excluded.add(sub.lineno) + + remaining = "".join( + line + for number, line in enumerate(lines, start=1) + if number not in excluded + ) + sql_pattern = re.compile( + r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|" + r"FROM|JOIN)\s+[`'\"]*(\[)?(presence_schedules|presence_overrides)" + r"\b", + re.IGNORECASE, + ) + 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