Files
NestQuest/tests/test_dao_presence.py
T

556 lines
20 KiB
Python

"""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.
Without serialization the UNIQUE(child_id) would make the second
plain INSERT raise; the serialized ON CONFLICT upserts both land.
"""
async def _main():
database = NestQuestDatabase(_make_hass_mock())
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)
task_a = asyncio.ensure_future(
schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2|1,3"
)
)
await asyncio.sleep(0)
task_b = asyncio.ensure_future(
schedules.upsert_by_child(
child.id, 2, "2026-09-14", "1,3|0,2"
)
)
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_returns_none(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
return await schedules.get_by_child(999)
assert _with_db(tmp_path, "get-unknown.db")(_body) is None
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 — except the upsert row-count probe (COUNT(*) of the whole
table, needed to prove exactly-one-row) and the guard functions'
own spans. Scanned as ONE blob so multiline SQL cannot evade it.
"""
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",
}
probe_names = {"test_upsert_creates_then_updates_same_row",
"test_upsert_concurrent_same_child_serializes"}
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 or node.name in probe_names:
excluded.update(
range(node.lineno, (node.end_lineno or node.lineno) + 1)
)
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"
)