Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8d2b1af9d | ||
|
|
b67dda8655 | ||
|
|
e5b9146bab | ||
|
|
fa502730ef | ||
|
|
fe8ceb2f40 | ||
|
|
d1e4603a15 | ||
|
|
86bd4023f4 | ||
|
|
d34c08b5b7 | ||
|
|
020b3679e1 | ||
|
|
2265b5c3f1 | ||
|
|
c41e28cb29 |
+265
-6
@@ -504,18 +504,23 @@ def test_children_and_admin_sql_lives_only_in_dao_module() -> None:
|
||||
re.IGNORECASE,
|
||||
)
|
||||
offenders: list[str] = []
|
||||
# test_dao_matrix.py is exempted for exactly its two sanctioned
|
||||
# raw constraint-violation probes (NOT NULL on children, PRIMARY
|
||||
# KEY on admin_users) — the Feature-02 closing suite must prove
|
||||
# the schema surfaces violations to the DAO layer; its guard below
|
||||
# covers everything else in that file.
|
||||
probe_names = {
|
||||
"test_matrix_children_crud_and_constraints",
|
||||
"test_matrix_admin_users_crud_and_constraints",
|
||||
}
|
||||
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
|
||||
text = py.read_text()
|
||||
if relative == "tests/test_startup_db.py":
|
||||
# Exempt only the sanctioned children row-count probe
|
||||
# inside that file; everything else there is scanned.
|
||||
text = _strip_function_spans(
|
||||
text, {"test_setup_with_valid_file_preserves_rows"}
|
||||
)
|
||||
if relative == "tests/test_dao_matrix.py":
|
||||
text = _strip_function_spans(text, probe_names)
|
||||
if sql_pattern.search(text):
|
||||
offenders.append(relative)
|
||||
assert offenders == [], (
|
||||
@@ -569,6 +574,259 @@ def test_startup_db_test_file_uses_dao_elsewhere() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_dao_matrix_test_file_raw_probes_are_only_constraint_probes() -> None:
|
||||
"""Compensating self-scan for the matrix-file exemption, structurally
|
||||
exact: parse the two sanctioned functions' ASTs and require that
|
||||
(a) they contain NO other raw SQL naming children/admin_users, and
|
||||
(b) every raw table insert among them is an expression nested inside
|
||||
a ``pytest.raises`` context whose matcher mentions IntegrityError.
|
||||
Any additional raw write elsewhere in the file fails this guard.
|
||||
"""
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
matrix = Path(__file__).parent / "test_dao_matrix.py"
|
||||
text = matrix.read_text()
|
||||
stripped = _strip_function_spans(
|
||||
text,
|
||||
{
|
||||
"test_matrix_children_crud_and_constraints",
|
||||
"test_matrix_admin_users_crud_and_constraints",
|
||||
},
|
||||
)
|
||||
sql_pattern = re.compile(
|
||||
r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|"
|
||||
r"FROM|JOIN)\s+[`'\"]*(\[)?(children|admin_users)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
assert sql_pattern.search(stripped) is None, (
|
||||
"test_dao_matrix.py raw children/admin_users SQL is limited to "
|
||||
"the two sanctioned constraint probes"
|
||||
)
|
||||
|
||||
tree = ast.parse(text)
|
||||
probe_tables = {
|
||||
"test_matrix_children_crud_and_constraints": ("children", "INSERT"),
|
||||
"test_matrix_admin_users_crud_and_constraints": (
|
||||
"admin_users",
|
||||
"INSERT",
|
||||
),
|
||||
}
|
||||
any_table = re.compile(
|
||||
r"\b(children|admin_users)\b", re.IGNORECASE
|
||||
)
|
||||
allowed_stmt = re.compile(
|
||||
r"^(INSERT|SELECT)", re.IGNORECASE
|
||||
)
|
||||
#: The sanctioned probe per function: exactly one raw statement
|
||||
#: INSERTing that table, inside a pytest.raises whose matcher is
|
||||
#: IntegrityError (checked as an AST Name/Attribute chain).
|
||||
probes_per_function: dict[str, int] = {name: 0 for name in probe_tables}
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
or node.name not in probe_tables
|
||||
):
|
||||
continue
|
||||
table, expected_verb = probe_tables[node.name]
|
||||
|
||||
def _unquote(rendered: str) -> str:
|
||||
rendered = rendered.strip()
|
||||
if len(rendered) >= 2 and rendered[0] == rendered[-1] and (
|
||||
rendered[0] in "'\""
|
||||
):
|
||||
return rendered[1:-1]
|
||||
return rendered
|
||||
|
||||
# Collect EVERY database SQL entry point (execute, fetch_one,
|
||||
# fetch_all, execute_many) wherever it appears in the awaited
|
||||
# expression tree: a bypass through fetch_one/execute_many OR a
|
||||
# call wrapped in another awaitable (asyncio.gather, a helper
|
||||
# coroutine, a Task) must be caught the same as a bare await.
|
||||
raw_statements: list[tuple[ast.AST, str]] = []
|
||||
for sub in ast.walk(node):
|
||||
if (
|
||||
isinstance(sub, ast.Call)
|
||||
and isinstance(sub.value if False else sub.func, ast.Attribute)
|
||||
and sub.func.attr
|
||||
in {"execute", "fetch_one", "fetch_all", "execute_many"}
|
||||
):
|
||||
sql_arg = sub.args[0] if sub.args else None
|
||||
rendered = ast.unparse(sql_arg) if sql_arg else ""
|
||||
raw_statements.append((sub, _unquote(rendered)))
|
||||
|
||||
def _raises_blocks():
|
||||
"""Yield (block, is_pytest_integrity) for pytest.raises blocks.
|
||||
|
||||
Structurally exact: the call func must resolve to
|
||||
pytest.raises — Attribute(pytest, raises) or a bare Name
|
||||
whose id is bound to pytest via the file's own import
|
||||
section (validated below). The matcher argument must be an
|
||||
AST chain whose final segment is exactly IntegrityError (so
|
||||
NotIntegrityError and fake.IntegrityError fail).
|
||||
"""
|
||||
# Names bound to pytest's raises: parse the FILE's actual
|
||||
# Import nodes in TWO passes so order cannot matter — pass
|
||||
# one collects every binding pytest's raises gets (whatever
|
||||
# alias), pass two rejects ANY competing binding of those
|
||||
# names (or of 'pytest' itself) from a non-pytest import,
|
||||
# wherever it appears. A spoof imported BEFORE the genuine
|
||||
# binding therefore still fails.
|
||||
pytest_raises_aliases: set[str] = set()
|
||||
for file_node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(file_node, ast.ImportFrom)
|
||||
and file_node.module == "pytest"
|
||||
):
|
||||
for alias in file_node.names:
|
||||
if alias.name == "raises":
|
||||
pytest_raises_aliases.add(
|
||||
alias.asname or alias.name
|
||||
)
|
||||
raises_bindings: set[str] = set(pytest_raises_aliases)
|
||||
for file_node in ast.walk(tree):
|
||||
if isinstance(file_node, ast.ImportFrom):
|
||||
if file_node.module == "pytest":
|
||||
continue
|
||||
for alias in file_node.names:
|
||||
bound = alias.asname or alias.name
|
||||
if bound in raises_bindings | {"pytest"}:
|
||||
raise AssertionError(
|
||||
f"shadowed import of {bound!r} from "
|
||||
f"{file_node.module!r} defeats the "
|
||||
"pytest.raises probe validation"
|
||||
)
|
||||
elif isinstance(file_node, ast.Import):
|
||||
# import fake_module as pytest (or as any raises
|
||||
# alias) fails closed. A plain `import pytest`
|
||||
# (alias.name == 'pytest', no asname) is the genuine
|
||||
# module binding and is allowed.
|
||||
for alias in file_node.names:
|
||||
bound = alias.asname or alias.name
|
||||
genuine_pytest = (
|
||||
alias.name == "pytest" and alias.asname is None
|
||||
)
|
||||
if bound in raises_bindings | {"pytest"} and (
|
||||
not genuine_pytest
|
||||
):
|
||||
raise AssertionError(
|
||||
f"import shadowing {bound!r} defeats the "
|
||||
"pytest.raises probe validation"
|
||||
)
|
||||
for sub in ast.walk(node):
|
||||
if not (
|
||||
isinstance(sub, ast.With)
|
||||
and sub.items
|
||||
and isinstance(sub.items[0].context_expr, ast.Call)
|
||||
):
|
||||
continue
|
||||
call = sub.items[0].context_expr
|
||||
func = call.func
|
||||
if isinstance(func, ast.Attribute):
|
||||
if (
|
||||
func.attr != "raises"
|
||||
or not isinstance(func.value, ast.Name)
|
||||
or func.value.id != "pytest"
|
||||
):
|
||||
continue
|
||||
elif isinstance(func, ast.Name):
|
||||
if func.id not in raises_bindings:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
matcher_ok = False
|
||||
if call.args:
|
||||
matcher = call.args[0]
|
||||
matcher_ok = _is_integrity_error_node(matcher)
|
||||
yield sub, matcher_ok
|
||||
|
||||
def _is_integrity_error_node(matcher) -> bool:
|
||||
"""True for an AST chain of Name/Attribute ending exactly at
|
||||
IntegrityError: every Attribute base must itself be a Name
|
||||
or Attribute (a Call base like __import__('x') fails)."""
|
||||
node = matcher
|
||||
while isinstance(node, ast.Attribute):
|
||||
node = node.value
|
||||
if not isinstance(node, ast.Name):
|
||||
return False
|
||||
final = matcher
|
||||
while isinstance(final, ast.Attribute):
|
||||
final = final.attr
|
||||
return (
|
||||
isinstance(final, str)
|
||||
and ast.unparse(matcher).split(".")[-1] == "IntegrityError"
|
||||
)
|
||||
|
||||
# INSERT OR variant / quoted / qualified / schema-qualified
|
||||
# names: normalize the SQL so ANY spelling of an insert into
|
||||
# the table counts as a table insert and requires the raises
|
||||
# block. Strip quotes/brackets/backticks from identifiers and
|
||||
# drop any schema qualifier, whatever it is.
|
||||
def _is_insert_into_table(rendered: str, table: str) -> bool:
|
||||
insert_match = re.search(
|
||||
r"\bINSERT(\s+OR\s+\w+)?\s+INTO\s+(.+)",
|
||||
rendered,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if not insert_match:
|
||||
return False
|
||||
target = insert_match.group(2)
|
||||
# Tokenize the target's leading identifier: strip quotes
|
||||
# and brackets, take the LAST dot-separated segment,
|
||||
# casefolded so 'CHILDREN' cannot bypass the lowercase
|
||||
# table name the scan looks for.
|
||||
identifier = re.split(r"[\s(]", target, maxsplit=1)[0]
|
||||
segments = re.split(r"\.", identifier)
|
||||
last = segments[-1].strip("`'\"[]")
|
||||
return last.casefold() == table.casefold()
|
||||
|
||||
# In a sanctioned function, EVERY raw statement naming either
|
||||
# table must be exactly that function's one expected-table
|
||||
# constraint insert — an admin_users insert inside the children
|
||||
# function (or vice versa) is a bypass attempt and fails here,
|
||||
# as does any second insert of the expected table.
|
||||
for stmt_node, rendered in raw_statements:
|
||||
if not any_table.search(rendered):
|
||||
continue
|
||||
assert _is_insert_into_table(rendered, table), (
|
||||
f"{node.name}: raw statement naming the other table is "
|
||||
f"forbidden; only the {table} probe is sanctioned: "
|
||||
f"{rendered[:80]!r}"
|
||||
)
|
||||
# This is the sanctioned probe: require pytest.raises
|
||||
# with an exact IntegrityError matcher around it.
|
||||
inside = any(
|
||||
block.lineno <= stmt_node.lineno
|
||||
and stmt_node.end_lineno <= block.end_lineno
|
||||
and matcher_ok
|
||||
for block, matcher_ok in _raises_blocks()
|
||||
)
|
||||
assert inside, (
|
||||
f"the raw {table} insert in {node.name} must be nested "
|
||||
"in a pytest.raises(...IntegrityError...) block "
|
||||
"(expected-to-fail constraint probe, not a write)"
|
||||
)
|
||||
probes_per_function[node.name] += 1
|
||||
# UPDATE/DELETE naming the tables anywhere in a sanctioned
|
||||
# function is forbidden outright.
|
||||
for _, rendered in raw_statements:
|
||||
if any_table.search(rendered) and re.match(
|
||||
r"(UPDATE|DELETE)", rendered.strip(), re.IGNORECASE
|
||||
):
|
||||
raise AssertionError(
|
||||
f"{node.name} must not hold UPDATE/DELETE SQL for "
|
||||
f"children/admin_users: {rendered[:80]!r}"
|
||||
)
|
||||
assert all(
|
||||
count == 1 for count in probes_per_function.values()
|
||||
) and len(probes_per_function) == 2, (
|
||||
"each sanctioned function must hold exactly one raw constraint "
|
||||
f"probe; found {probes_per_function}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
def test_dao_test_file_uses_dao_not_raw_table_sql() -> None:
|
||||
"""This test module must exercise the DAO, not raw SQL, for these
|
||||
tables. The two guard functions in this file necessarily mention
|
||||
@@ -591,6 +849,7 @@ def test_dao_test_file_uses_dao_not_raw_table_sql() -> None:
|
||||
guard_names = {
|
||||
"test_children_and_admin_sql_lives_only_in_dao_module",
|
||||
"test_dao_test_file_uses_dao_not_raw_table_sql",
|
||||
"test_dao_matrix_test_file_raw_probes_are_only_constraint_probes",
|
||||
}
|
||||
excluded: set[int] = set()
|
||||
for node in ast.walk(tree):
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
"""Cross-table DAO unit suite: CRUD and constraint coverage for all tables.
|
||||
|
||||
The per-DAO test modules (test_dao_children, test_dao_rules,
|
||||
test_dao_presence, test_dao_instances) cover each table's operations in
|
||||
depth; this module is the FEATURE-02 closing sweep: a single systematic
|
||||
matrix proving that for EVERY table, the DAO layer supports insert,
|
||||
read, update and surfaces constraint violations — plus the instance
|
||||
upsert idempotency and the completion_events no-mutation guarantee —
|
||||
all against a temporary database file from a clean checkout.
|
||||
|
||||
No raw SQL touches the DAO-owned tables here except the sanctioned
|
||||
COUNT(*) probes (each guarded by the per-DAO leak guards); everything
|
||||
else goes through the typed DAO methods.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import sqlite3
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.dao_children import (
|
||||
AdminUsersDao,
|
||||
ChildrenDao,
|
||||
)
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
CompletionEventsDao,
|
||||
TaskInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
PresenceSchedulesDao,
|
||||
)
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
ScheduleRulesDao,
|
||||
TaskDefinitionsDao,
|
||||
)
|
||||
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():
|
||||
hass = MagicMock()
|
||||
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
|
||||
return hass
|
||||
|
||||
|
||||
def _future_day(offset: int) -> str:
|
||||
return (
|
||||
datetime.date.today() + datetime.timedelta(days=offset)
|
||||
).isoformat()
|
||||
|
||||
|
||||
def _stamp() -> str:
|
||||
return f"{datetime.date.today().isoformat()}T08:00:00+00:00"
|
||||
|
||||
|
||||
D1 = _future_day(1)
|
||||
D2 = _future_day(2)
|
||||
D20 = _future_day(20)
|
||||
|
||||
#: Every table the v1 schema owns, with the DAO that governs it.
|
||||
ALL_TABLES = (
|
||||
"children",
|
||||
"admin_users",
|
||||
"schedule_rules",
|
||||
"task_definitions",
|
||||
"presence_schedules",
|
||||
"presence_overrides",
|
||||
"task_instances",
|
||||
"completion_events",
|
||||
)
|
||||
|
||||
|
||||
class World:
|
||||
"""All DAOs over one migrated temporary database."""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase):
|
||||
self.database = database
|
||||
self.children = ChildrenDao(database)
|
||||
self.admins = AdminUsersDao(database)
|
||||
self.rules = ScheduleRulesDao(database)
|
||||
self.definitions = TaskDefinitionsDao(database)
|
||||
self.schedules = PresenceSchedulesDao(database)
|
||||
self.overrides = PresenceOverridesDao(database)
|
||||
self.instances = TaskInstancesDao(database)
|
||||
self.events = CompletionEventsDao(database)
|
||||
|
||||
async def seed(self) -> None:
|
||||
"""Populate one valid fixture chain through the DAO layer."""
|
||||
self.child = await self.children.create("Ada", _stamp())
|
||||
self.child2 = await self.children.create("Bo", _stamp())
|
||||
assert await self.admins.add("user-1", _stamp()) is True
|
||||
self.rule = await self.rules.create("daily", D1)
|
||||
self.definition = await self.definitions.create(
|
||||
"Brush teeth", self.child.id, self.rule.id, _stamp()
|
||||
)
|
||||
self.schedule = await self.schedules.upsert_by_child(
|
||||
self.child.id, 2, D1, "0,2,4|1,3"
|
||||
)
|
||||
self.override = await self.overrides.create(
|
||||
self.child.id, D1, D2, True, note="test"
|
||||
)
|
||||
self.instance = await self.instances.upsert(
|
||||
self.definition.id, self.child.id, D1, _stamp()
|
||||
)
|
||||
|
||||
|
||||
async def _world(path) -> World:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
world = World(database)
|
||||
await world.seed()
|
||||
return world
|
||||
|
||||
|
||||
def _with_world(name):
|
||||
def _run_test(body):
|
||||
async def _main():
|
||||
world = await _world(name)
|
||||
try:
|
||||
return await body(world)
|
||||
finally:
|
||||
await world.database.close()
|
||||
|
||||
return _run(_main())
|
||||
|
||||
return _run_test
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The matrix: every table gets insert / read / update / constraint check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_matrix_children_crud_and_constraints(tmp_path) -> None:
|
||||
async def _body(w: World):
|
||||
# insert (seeded) + read
|
||||
fetched = await w.children.get(w.child.id)
|
||||
assert fetched is not None and fetched.display_name == "Ada"
|
||||
# update
|
||||
await w.children.update(w.child.id, display_name="Ada L.")
|
||||
assert (await w.children.get(w.child.id)).display_name == "Ada L."
|
||||
# constraint: NOT NULL display_name
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
await w.database.execute(
|
||||
"INSERT INTO children (display_name, created_at) "
|
||||
"VALUES (NULL, ?)",
|
||||
(_stamp(),),
|
||||
)
|
||||
return True
|
||||
|
||||
assert _with_world(tmp_path / "m-children.db")(_body) is True
|
||||
|
||||
|
||||
def test_matrix_admin_users_crud_and_constraints(tmp_path) -> None:
|
||||
async def _body(w: World):
|
||||
# insert + read
|
||||
assert await w.admins.exists("user-1") is True
|
||||
records = await w.admins.list()
|
||||
assert len(records) == 1
|
||||
# update path: idempotent add is the allowlist's update semantics
|
||||
assert await w.admins.add("user-1", _stamp()) is False
|
||||
# constraint: ha_user_id PRIMARY KEY
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
await w.database.execute(
|
||||
"INSERT INTO admin_users (ha_user_id, added_at) "
|
||||
"VALUES (?, ?)",
|
||||
("user-1", _stamp()),
|
||||
)
|
||||
# remove
|
||||
assert await w.admins.remove("user-1") is True
|
||||
assert await w.admins.exists("user-1") is False
|
||||
return True
|
||||
|
||||
assert _with_world(tmp_path / "m-admins.db")(_body) is True
|
||||
|
||||
|
||||
def test_matrix_schedule_rules_crud_and_constraints(tmp_path) -> None:
|
||||
async def _body(w: World):
|
||||
# insert (seeded) + read
|
||||
fetched = await w.rules.get(w.rule.id)
|
||||
assert fetched.rule_type == "daily"
|
||||
# update
|
||||
await w.rules.update(w.rule.id, interval=3)
|
||||
assert (await w.rules.get(w.rule.id)).interval == 3
|
||||
# constraint: rule_type CHECK
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
await w.rules.create("sometimes", D1)
|
||||
# delete-if-unreferenced: referenced -> rejected
|
||||
assert await w.rules.delete_if_unreferenced(w.rule.id) is False
|
||||
return True
|
||||
|
||||
assert _with_world(tmp_path / "m-rules.db")(_body) is True
|
||||
|
||||
|
||||
def test_matrix_task_definitions_crud_and_constraints(tmp_path) -> None:
|
||||
async def _body(w: World):
|
||||
# insert (seeded) + read
|
||||
fetched = await w.definitions.get(w.definition.id)
|
||||
assert fetched.title == "Brush teeth"
|
||||
# update
|
||||
await w.definitions.update(w.definition.id, title="Brush TEETH")
|
||||
assert (await w.definitions.get(w.definition.id)).title == (
|
||||
"Brush TEETH"
|
||||
)
|
||||
# constraint: FK to a nonexistent child rejected with ValueError
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
await w.definitions.create("X", 999, w.rule.id, _stamp())
|
||||
# constraint: inactive child rejected
|
||||
await w.children.set_active(w.child2.id, False)
|
||||
with pytest.raises(ValueError, match="inactive"):
|
||||
await w.definitions.set_assignee(w.definition.id, w.child2.id)
|
||||
# set_active: deactivate, never delete
|
||||
assert await w.definitions.set_active(w.definition.id, False) == 1
|
||||
assert (await w.definitions.get(w.definition.id)).is_active is False
|
||||
return True
|
||||
|
||||
assert _with_world(tmp_path / "m-definitions.db")(_body) is True
|
||||
|
||||
|
||||
def test_matrix_presence_schedules_crud_and_constraints(tmp_path) -> None:
|
||||
async def _body(w: World):
|
||||
# insert (seeded) + read
|
||||
fetched = await w.schedules.get_by_child(w.child.id)
|
||||
assert fetched.pattern == "0,2,4|1,3"
|
||||
# update path: upsert replaces in place (same row id)
|
||||
replaced = await w.schedules.upsert_by_child(
|
||||
w.child.id, 2, D2, "1,3|0,2,4"
|
||||
)
|
||||
assert replaced.id == fetched.id
|
||||
assert (await w.schedules.get_by_child(w.child.id)).pattern == (
|
||||
"1,3|0,2,4"
|
||||
)
|
||||
# constraint: malformed pattern CHECK
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
await w.schedules.upsert_by_child(w.child.id, 2, D1, "0,2")
|
||||
# delete: row gone, child present every day
|
||||
assert await w.schedules.delete(w.child.id) is True
|
||||
assert await w.schedules.get_by_child(w.child.id) is None
|
||||
return True
|
||||
|
||||
assert _with_world(tmp_path / "m-schedules.db")(_body) is True
|
||||
|
||||
|
||||
def test_matrix_presence_overrides_crud_and_constraints(tmp_path) -> None:
|
||||
async def _body(w: World):
|
||||
# insert (seeded) + read
|
||||
found = await w.overrides.list_by_child_and_range(
|
||||
w.child.id, D1, D2
|
||||
)
|
||||
assert [r.id for r in found] == [w.override.id]
|
||||
# update path: delete + recreate (overrides are append-oriented)
|
||||
assert await w.overrides.delete(w.override.id) is True
|
||||
replacement = await w.overrides.create(
|
||||
w.child.id, D1, D2, False, note="updated"
|
||||
)
|
||||
assert replacement.is_present is False
|
||||
# constraint: end before start CHECK
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
await w.overrides.create(w.child.id, D2, D1, True)
|
||||
return True
|
||||
|
||||
assert _with_world(tmp_path / "m-overrides.db")(_body) is True
|
||||
|
||||
|
||||
def test_matrix_task_instances_crud_and_constraints(tmp_path) -> None:
|
||||
async def _body(w: World):
|
||||
# insert (seeded) + read
|
||||
fetched = await w.instances.get(w.definition.id, D1)
|
||||
assert fetched == w.instance
|
||||
# update path: conflict refresh on the OPEN instance — same row
|
||||
# id, snapshot columns (due_time) actually rewritten.
|
||||
refreshed = await w.instances.upsert(
|
||||
w.definition.id, w.child.id, D1, _stamp(), due_time="17:00"
|
||||
)
|
||||
assert refreshed.id == fetched.id, (
|
||||
"the conflict path must refresh the existing row in place"
|
||||
)
|
||||
assert refreshed.due_time == "17:00"
|
||||
assert fetched.due_time is None
|
||||
assert await w.instances.get_by_id(fetched.id) is not None
|
||||
return True
|
||||
|
||||
assert _with_world(tmp_path / "m-instances.db")(_body) is True
|
||||
|
||||
|
||||
def test_matrix_task_instances_constraints(tmp_path) -> None:
|
||||
async def _body(w: World):
|
||||
# D20 has no instance yet: a raw insert succeeds, then an
|
||||
# identical second insert must fail on the UNIQUE constraint —
|
||||
# proving the schema backs the DAO's idempotency.
|
||||
await w.database.execute(
|
||||
"INSERT INTO task_instances (definition_id, child_id, "
|
||||
"due_date, due_time, generated_at) "
|
||||
"VALUES (?, ?, ?, NULL, ?)",
|
||||
(w.definition.id, w.child.id, D20, _stamp()),
|
||||
)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
await w.database.execute(
|
||||
"INSERT INTO task_instances (definition_id, child_id, "
|
||||
"due_date, due_time, generated_at) "
|
||||
"VALUES (?, ?, ?, NULL, ?)",
|
||||
(w.definition.id, w.child.id, D20, _stamp()),
|
||||
)
|
||||
# delete future uncompleted removes D20's open instance
|
||||
deleted = await w.instances.delete_future_uncompleted(
|
||||
w.definition.id, D20
|
||||
)
|
||||
assert deleted == 1
|
||||
return True
|
||||
|
||||
assert _with_world(tmp_path / "m-instances-constraints.db")(_body) is True
|
||||
|
||||
|
||||
def test_matrix_task_instances_upsert_idempotency(tmp_path) -> None:
|
||||
async def _body(w: World):
|
||||
first = await w.instances.upsert(
|
||||
w.definition.id, w.child.id, D2, _stamp()
|
||||
)
|
||||
second = await w.instances.upsert(
|
||||
w.definition.id, w.child.id, D2, _stamp(), due_time="17:00"
|
||||
)
|
||||
assert second.id == first.id
|
||||
count = await w.database.fetch_one(
|
||||
"SELECT COUNT(*) FROM task_instances"
|
||||
)
|
||||
# Seed created one (D1) plus this one (D2) — no duplicate.
|
||||
assert count == (2,)
|
||||
return True
|
||||
|
||||
assert _with_world(tmp_path / "m-upsert-idempotent.db")(_body) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# completion_events: append-only by construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_matrix_completion_events_append_read_and_no_mutation(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
async def _body(w: World):
|
||||
# insert: append a completion then a reversal, then re-complete
|
||||
await w.events.append(
|
||||
w.instance.id, w.child.id, "completed", "panel", _stamp(), True
|
||||
)
|
||||
await w.events.append(
|
||||
w.instance.id, w.child.id, "uncompleted", "user", _stamp(), None,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
await w.events.append(
|
||||
w.instance.id, w.child.id, "completed", "panel", _stamp(), False
|
||||
)
|
||||
# read: full ordered history + latest
|
||||
history = await w.events.list_by_instance(w.instance.id)
|
||||
assert [e.event_type for e in history] == [
|
||||
"completed",
|
||||
"uncompleted",
|
||||
"completed",
|
||||
]
|
||||
latest = await w.events.get_latest_for_instance(w.instance.id)
|
||||
assert latest.event_type == "completed"
|
||||
assert latest.was_on_time is False
|
||||
# constraint: unknown instance FK + bad actor pair surface as
|
||||
# ValueError from the DAO's atomic validation
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
await w.events.append(
|
||||
999, w.child.id, "completed", "user", _stamp(), True,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
return history
|
||||
|
||||
assert _with_world(tmp_path / "m-events.db")(_body) is not None
|
||||
|
||||
|
||||
def test_completion_events_absolutely_no_mutation_path() -> None:
|
||||
"""The structural half of the done-condition: the DAO class exposes
|
||||
no update/delete, AND no module in the package holds mutation SQL
|
||||
for the table (already asserted package-wide in test_dao_instances;
|
||||
re-asserted here so THIS suite stands alone in a clean checkout).
|
||||
"""
|
||||
import inspect
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
methods = {
|
||||
name
|
||||
for name, _ in inspect.getmembers(
|
||||
CompletionEventsDao, inspect.isfunction
|
||||
)
|
||||
if not name.startswith("__")
|
||||
}
|
||||
assert methods == {
|
||||
"append",
|
||||
"list_by_instance",
|
||||
"list_by_child_and_date_range",
|
||||
"get_latest_for_instance",
|
||||
}
|
||||
|
||||
package = Path(
|
||||
__import__(
|
||||
"custom_components.nestquest", fromlist=["__file__"]
|
||||
).__file__
|
||||
).parent
|
||||
mutation = re.compile(
|
||||
r"(UPDATE(\s+OR\s+\w+)?|DELETE\s+FROM)\s+"
|
||||
r"((main|temp)\s*\.\s*)?[`'\"]*(\[)?completion_events\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
offenders = [
|
||||
py.name
|
||||
for py in sorted(package.glob("*.py"))
|
||||
if py.name != "__pycache__" and mutation.search(py.read_text())
|
||||
]
|
||||
assert offenders == []
|
||||
Reference in New Issue
Block a user