feat: add typed DAO layer for task_instances and append-only completion_events (#21)
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
"""Typed DAO layer for task_instances and the append-only completion_events.
|
||||
|
||||
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.
|
||||
|
||||
Append-only policy (Feature 08, D-005): ``completion_events`` is
|
||||
strictly append-only. This module is where that property is enforced
|
||||
mechanically — the completion-events DAO below exposes NO update or
|
||||
delete method at all; completions append, un-completions append a
|
||||
separate reversal event, and instance state derives from the latest
|
||||
event. A package-wide guard test (bottom of the test module) keeps
|
||||
mutation SQL for this table out of every other module.
|
||||
|
||||
Upsert semantics: ``TaskInstancesDao.upsert`` is idempotent on
|
||||
(definition_id, due_date) — re-running materialization for a date
|
||||
whose instance already exists updates nothing that matters
|
||||
(child_id/due_time are refreshed only in the degenerate re-generation
|
||||
case where they changed) and never duplicates a row. The schema's
|
||||
UNIQUE(definition_id, due_date) is the last line of defense.
|
||||
|
||||
``delete_future_uncompleted`` implements the Feature 06/07 rule that
|
||||
reassignment and schedule edits regenerate future instances: it
|
||||
removes only instances at or after the cutoff that have NO completion
|
||||
event, inside one transaction, so an instance completed mid-call can
|
||||
never be deleted. Instances in the past are never touched (Feature 07
|
||||
guardrail: never delete history), and no method here deletes a single
|
||||
instance that has a completion event.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .dao_children import _connection_lock
|
||||
from .dao_rules import _validate_date
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskInstanceRecord:
|
||||
"""One row of ``task_instances``."""
|
||||
|
||||
id: int
|
||||
definition_id: int
|
||||
child_id: int
|
||||
due_date: str
|
||||
due_time: str | None
|
||||
generated_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompletionEventRecord:
|
||||
"""One row of ``completion_events`` (append-only table)."""
|
||||
|
||||
id: int
|
||||
instance_id: int
|
||||
child_id: int
|
||||
event_type: str
|
||||
actor_source: str
|
||||
actor_user_id: str | None
|
||||
occurred_at: str
|
||||
was_on_time: bool | None
|
||||
|
||||
|
||||
_INSTANCE_COLUMNS = (
|
||||
"id, definition_id, child_id, due_date, due_time, generated_at"
|
||||
)
|
||||
_EVENT_COLUMNS = (
|
||||
"id, instance_id, child_id, event_type, actor_source, actor_user_id, "
|
||||
"occurred_at, was_on_time"
|
||||
)
|
||||
|
||||
#: The only event types the append path accepts; the schema CHECK is
|
||||
#: the backstop, this is the DAO's typed front door.
|
||||
EVENT_COMPLETED = "completed"
|
||||
EVENT_UNCOMPLETED = "uncompleted"
|
||||
|
||||
|
||||
def _instance_from_row(row: tuple) -> TaskInstanceRecord:
|
||||
return TaskInstanceRecord(
|
||||
id=row[0],
|
||||
definition_id=row[1],
|
||||
child_id=row[2],
|
||||
due_date=row[3],
|
||||
due_time=row[4],
|
||||
generated_at=row[5],
|
||||
)
|
||||
|
||||
|
||||
def _event_from_row(row: tuple) -> CompletionEventRecord:
|
||||
return CompletionEventRecord(
|
||||
id=row[0],
|
||||
instance_id=row[1],
|
||||
child_id=row[2],
|
||||
event_type=row[3],
|
||||
actor_source=row[4],
|
||||
actor_user_id=row[5],
|
||||
occurred_at=row[6],
|
||||
was_on_time=None if row[7] is None else bool(row[7]),
|
||||
)
|
||||
|
||||
|
||||
class TaskInstancesDao:
|
||||
"""Typed async access to the ``task_instances`` table."""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
definition_id: int,
|
||||
child_id: int,
|
||||
due_date: str,
|
||||
generated_at: str,
|
||||
*,
|
||||
due_time: str | None = None,
|
||||
) -> TaskInstanceRecord:
|
||||
"""Create the dated instance for (definition_id, due_date) idempotently.
|
||||
|
||||
Running materialization twice for the same definition/date
|
||||
yields ONE row: the ON CONFLICT path refreshes the
|
||||
generation-time snapshot columns (child_id, due_time) and
|
||||
regenerates the stamp, but can never create a duplicate — the
|
||||
schema UNIQUE(definition_id, due_date) backs this up. The
|
||||
definition must exist (validated atomically under the
|
||||
connection lock so a concurrent definition deactivation or
|
||||
child deletion cannot slip between check and insert).
|
||||
"""
|
||||
_validate_date(due_date, "due_date")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
definition = await self._database.fetch_one(
|
||||
"SELECT child_id FROM task_definitions WHERE id = ?",
|
||||
(definition_id,),
|
||||
)
|
||||
if definition is None:
|
||||
raise ValueError(
|
||||
f"task definition {definition_id} does not exist"
|
||||
)
|
||||
if definition[0] != child_id:
|
||||
raise ValueError(
|
||||
f"task definition {definition_id} is assigned to "
|
||||
f"child {definition[0]}, not {child_id}; instance "
|
||||
"must carry the definition's assignee"
|
||||
)
|
||||
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 task_instances (definition_id, child_id, "
|
||||
"due_date, due_time, generated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT (definition_id, due_date) DO UPDATE SET "
|
||||
"child_id = excluded.child_id, "
|
||||
"due_time = excluded.due_time, "
|
||||
"generated_at = excluded.generated_at",
|
||||
(
|
||||
definition_id,
|
||||
child_id,
|
||||
due_date,
|
||||
due_time,
|
||||
generated_at,
|
||||
),
|
||||
)
|
||||
instance = await self.get(definition_id, due_date)
|
||||
assert instance is not None
|
||||
return instance
|
||||
|
||||
async def get(
|
||||
self, definition_id: int, due_date: str
|
||||
) -> TaskInstanceRecord | None:
|
||||
"""Return the instance for (definition_id, due_date), or None."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_INSTANCE_COLUMNS} FROM task_instances "
|
||||
"WHERE definition_id = ? AND due_date = ?",
|
||||
(definition_id, due_date),
|
||||
)
|
||||
return _instance_from_row(row) if row is not None else None
|
||||
|
||||
async def get_by_id(self, instance_id: int) -> TaskInstanceRecord | None:
|
||||
"""Return the instance with ``instance_id``, or None."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_INSTANCE_COLUMNS} FROM task_instances WHERE id = ?",
|
||||
(instance_id,),
|
||||
)
|
||||
return _instance_from_row(row) if row is not None else None
|
||||
|
||||
async def list_by_child_and_date(
|
||||
self, child_id: int, due_date: str
|
||||
) -> list[TaskInstanceRecord]:
|
||||
"""Return the child's instances due exactly on ``due_date``."""
|
||||
_validate_date(due_date, "due_date")
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_INSTANCE_COLUMNS} FROM task_instances "
|
||||
"WHERE child_id = ? AND due_date = ? ORDER BY id",
|
||||
(child_id, due_date),
|
||||
)
|
||||
return [_instance_from_row(row) for row in rows]
|
||||
|
||||
async def list_by_date_range(
|
||||
self,
|
||||
child_id: int,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
) -> list[TaskInstanceRecord]:
|
||||
"""Return the child's instances with due_date in the closed range."""
|
||||
_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 {_INSTANCE_COLUMNS} FROM task_instances "
|
||||
"WHERE child_id = ? AND due_date >= ? AND due_date <= ? "
|
||||
"ORDER BY due_date, id",
|
||||
(child_id, range_start, range_end),
|
||||
)
|
||||
return [_instance_from_row(row) for row in rows]
|
||||
|
||||
async def delete_future_uncompleted(
|
||||
self, definition_id: int, cutoff_date: str
|
||||
) -> int:
|
||||
"""Delete the definition's instances at/after cutoff with no events.
|
||||
|
||||
Implements regeneration after a definition edit or reassignment
|
||||
(Features 06/07): only future, never-completed instances are
|
||||
removed, inside one transaction — an instance that gains a
|
||||
completion event mid-flight cannot be deleted. Instances
|
||||
before the cutoff and any instance with a completion event are
|
||||
never touched. Returns the number deleted.
|
||||
"""
|
||||
_validate_date(cutoff_date, "cutoff_date")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM task_instances WHERE definition_id = ? "
|
||||
"AND due_date >= ? AND NOT EXISTS ("
|
||||
" SELECT 1 FROM completion_events "
|
||||
" WHERE instance_id = task_instances.id"
|
||||
")",
|
||||
(definition_id, cutoff_date),
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
|
||||
class CompletionEventsDao:
|
||||
"""Typed append-only access to the ``completion_events`` table.
|
||||
|
||||
APPEND-ONLY BY CONSTRUCTION: this class exposes ``append`` and the
|
||||
three read methods below, and NOTHING else. There is no update,
|
||||
no delete, no upsert — completions and un-completions both arrive
|
||||
through :meth:`append` as new rows, and history is immutable
|
||||
(Feature 08 done-condition: no code path updates or deletes an
|
||||
event). The package-wide leak-guard test additionally fails if
|
||||
mutation SQL for this table ever appears in any module.
|
||||
"""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
|
||||
async def append(
|
||||
self,
|
||||
instance_id: int,
|
||||
child_id: int,
|
||||
event_type: str,
|
||||
actor_source: str,
|
||||
occurred_at: str,
|
||||
was_on_time: bool,
|
||||
*,
|
||||
actor_user_id: str | None = None,
|
||||
) -> CompletionEventRecord:
|
||||
"""Append one completion or un-completion event.
|
||||
|
||||
``actor_source`` is 'user' (with ``actor_user_id`` set) or
|
||||
'panel' (no user id — the tapped child is ``child_id``); the
|
||||
actor-pair CHECK enforces this shape at the schema level.
|
||||
``was_on_time`` is required for completions; for un-completions
|
||||
it may be None or the reversed completion's flag (Feature 08
|
||||
still deciding), so pass ``was_on_time=...`` or omit via
|
||||
``None``. The instance and child must exist — validated
|
||||
atomically under the connection lock.
|
||||
"""
|
||||
if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {event_type!r}"
|
||||
)
|
||||
if actor_source not in ("user", "panel"):
|
||||
raise ValueError(
|
||||
f"actor_source must be 'user' or 'panel', got "
|
||||
f"{actor_source!r}"
|
||||
)
|
||||
if actor_source == "user" and not actor_user_id:
|
||||
raise ValueError(
|
||||
"actor_source 'user' requires actor_user_id"
|
||||
)
|
||||
if actor_source == "panel" and actor_user_id is not None:
|
||||
raise ValueError(
|
||||
"actor_source 'panel' must not carry actor_user_id"
|
||||
)
|
||||
if event_type == EVENT_COMPLETED and was_on_time is None:
|
||||
raise ValueError(
|
||||
"a completed event requires was_on_time (True/False)"
|
||||
)
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
instance = await self._database.fetch_one(
|
||||
"SELECT child_id FROM task_instances WHERE id = ?",
|
||||
(instance_id,),
|
||||
)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"task instance {instance_id} does not exist"
|
||||
)
|
||||
if instance[0] != child_id:
|
||||
raise ValueError(
|
||||
f"task instance {instance_id} belongs to child "
|
||||
f"{instance[0]}, not {child_id}"
|
||||
)
|
||||
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 completion_events (instance_id, child_id, "
|
||||
"event_type, actor_source, actor_user_id, occurred_at, "
|
||||
"was_on_time) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
instance_id,
|
||||
child_id,
|
||||
event_type,
|
||||
actor_source,
|
||||
actor_user_id,
|
||||
occurred_at,
|
||||
None if was_on_time is None else int(was_on_time),
|
||||
),
|
||||
)
|
||||
event = await self._database.fetch_one(
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
"WHERE id = ?",
|
||||
(result.lastrowid,),
|
||||
)
|
||||
assert event is not None
|
||||
return _event_from_row(event)
|
||||
|
||||
async def list_by_instance(
|
||||
self, instance_id: int
|
||||
) -> list[CompletionEventRecord]:
|
||||
"""Return every event for the instance, in append order."""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
"WHERE instance_id = ? ORDER BY id",
|
||||
(instance_id,),
|
||||
)
|
||||
return [_event_from_row(row) for row in rows]
|
||||
|
||||
async def list_by_child_and_date_range(
|
||||
self,
|
||||
child_id: int,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
*,
|
||||
due_date_scope: bool = True,
|
||||
) -> list[CompletionEventRecord]:
|
||||
"""Return the child's events for instances due in the closed range.
|
||||
|
||||
Scopes by the INSTANCE's due date (the day the task was owed),
|
||||
not the event's wall-clock timestamp: history reporting asks
|
||||
"what happened to the tasks due that day" (Feature 13). Set
|
||||
``due_date_scope=False`` to filter by ``occurred_at`` date
|
||||
instead. Ordered by instance, then event id.
|
||||
"""
|
||||
_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}"
|
||||
)
|
||||
if due_date_scope:
|
||||
predicate = (
|
||||
"SELECT id FROM task_instances WHERE child_id = ? "
|
||||
"AND due_date >= ? AND due_date <= ?"
|
||||
)
|
||||
parameters: list[object] = [child_id, range_start, range_end]
|
||||
else:
|
||||
predicate = (
|
||||
"SELECT id FROM task_instances WHERE child_id = ?"
|
||||
)
|
||||
parameters = [child_id]
|
||||
occurred_start = f"{range_start}T00:00:00"
|
||||
occurred_end = f"{range_end}T23:59:59.999999"
|
||||
query = (
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
"WHERE child_id = ? AND occurred_at >= ? "
|
||||
"AND occurred_at <= ? ORDER BY instance_id, id"
|
||||
)
|
||||
rows = await self._database.fetch_all(
|
||||
query, [child_id, occurred_start, occurred_end]
|
||||
)
|
||||
return [_event_from_row(row) for row in rows]
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
f"WHERE instance_id IN ({predicate}) "
|
||||
"ORDER BY instance_id, id",
|
||||
tuple(parameters),
|
||||
)
|
||||
return [_event_from_row(row) for row in rows]
|
||||
|
||||
async def get_latest_for_instance(
|
||||
self, instance_id: int
|
||||
) -> CompletionEventRecord | None:
|
||||
"""Return the instance's most recent event, or None if untouched.
|
||||
|
||||
Instance current state derives from this event (Feature 08):
|
||||
'completed' means done, 'uncompleted' as the latest means open
|
||||
again, None means never completed.
|
||||
"""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
"WHERE instance_id = ? ORDER BY id DESC LIMIT 1",
|
||||
(instance_id,),
|
||||
)
|
||||
return _event_from_row(row) if row is not None else None
|
||||
@@ -484,6 +484,8 @@ def test_children_and_admin_sql_lives_only_in_dao_module() -> None:
|
||||
"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/dao_instances.py", # validates the
|
||||
# instance/event child exists before writing (same FK shape)
|
||||
"custom_components/nestquest/schema.py", # declares the DDL
|
||||
"custom_components/nestquest/migrations.py", # applies the DDL
|
||||
"tests/test_schema.py", # tests the DDL
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
"""Tests for dao_instances.py: task_instances + append-only completion_events."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
CompletionEventRecord,
|
||||
CompletionEventsDao,
|
||||
TaskInstanceRecord,
|
||||
TaskInstancesDao,
|
||||
)
|
||||
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():
|
||||
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)
|
||||
children = ChildrenDao(database)
|
||||
rules = ScheduleRulesDao(database)
|
||||
definitions = TaskDefinitionsDao(database)
|
||||
instances = TaskInstancesDao(database)
|
||||
events = CompletionEventsDao(database)
|
||||
child = await children.create("Ada", NOW)
|
||||
rule = await rules.create("daily", "2026-09-01")
|
||||
definition = await definitions.create("Brush teeth", child.id, rule.id, NOW)
|
||||
return (
|
||||
database,
|
||||
children,
|
||||
rules,
|
||||
definitions,
|
||||
instances,
|
||||
events,
|
||||
child,
|
||||
definition,
|
||||
)
|
||||
|
||||
|
||||
def _with_db(tmp_path, name):
|
||||
def _run_test(body):
|
||||
async def _main():
|
||||
prepared = await _prepare(tmp_path / name)
|
||||
try:
|
||||
return await body(*prepared)
|
||||
finally:
|
||||
await prepared[0].close()
|
||||
|
||||
return _run(_main())
|
||||
|
||||
return _run_test
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# task_instances: upsert idempotency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_upsert_creates_then_is_idempotent(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
first = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-15", NOW
|
||||
)
|
||||
assert isinstance(first, TaskInstanceRecord)
|
||||
second = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-15", NOW
|
||||
)
|
||||
assert second.id == first.id
|
||||
rows = await database.fetch_one(
|
||||
"SELECT COUNT(*) FROM task_instances"
|
||||
)
|
||||
assert rows == (1,)
|
||||
return second
|
||||
|
||||
_with_db(tmp_path, "upsert-idempotent.db")(_body)
|
||||
|
||||
|
||||
def test_upsert_different_dates_create_distinct_rows(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
a = await instances.upsert(definition.id, child.id, "2026-09-15", NOW)
|
||||
b = await instances.upsert(definition.id, child.id, "2026-09-16", NOW)
|
||||
assert a.id != b.id
|
||||
return (a, b)
|
||||
|
||||
_with_db(tmp_path, "upsert-multi-date.db")(_body)
|
||||
|
||||
|
||||
def test_upsert_refreshes_snapshot_columns_on_conflict(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
first = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-15", NOW, due_time="08:00"
|
||||
)
|
||||
second = await instances.upsert(
|
||||
definition.id,
|
||||
child.id,
|
||||
"2026-09-15",
|
||||
"2026-09-14T13:00:00+00:00",
|
||||
due_time="08:30",
|
||||
)
|
||||
assert second.id == first.id
|
||||
assert second.due_time == "08:30"
|
||||
assert second.generated_at == "2026-09-14T13:00:00+00:00"
|
||||
return second
|
||||
|
||||
_with_db(tmp_path, "upsert-refresh.db")(_body)
|
||||
|
||||
|
||||
def test_upsert_rejects_wrong_child_for_definition(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
other = await children.create("Bo", NOW)
|
||||
with pytest.raises(ValueError, match="assigned to"):
|
||||
await instances.upsert(definition.id, other.id, "2026-09-15", NOW)
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "upsert-wrong-child.db")(_body)
|
||||
|
||||
|
||||
def test_upsert_rejects_unknown_definition_and_child(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
with pytest.raises(ValueError, match="definition 999"):
|
||||
await instances.upsert(999, child.id, "2026-09-15", NOW)
|
||||
# Unknown child: the definition-assignee check fires first and
|
||||
# is the one that matters (an instance must carry its
|
||||
# definition's assignee). To reach the child-existence check
|
||||
# at all, the definition would have to claim child 999 — an
|
||||
# FK-impossible state — so the child check only guards direct
|
||||
# callers with a matching fake assignee; assert the guard
|
||||
# order instead of forcing the FK-impossible state.
|
||||
rule = await rules.create("daily", "2026-09-01")
|
||||
other_definition = await definitions.create(
|
||||
"X", child.id, rule.id, NOW
|
||||
)
|
||||
with pytest.raises(ValueError, match="not 999"):
|
||||
await instances.upsert(other_definition.id, 999, "2026-09-15", NOW)
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "upsert-unknown.db")(_body)
|
||||
|
||||
|
||||
def test_upsert_malformed_due_date_raises(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
for bad in ("not-a-date", "2026-9-15", ""):
|
||||
with pytest.raises(ValueError, match="YYYY-MM-DD"):
|
||||
await instances.upsert(definition.id, child.id, bad, NOW)
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "upsert-bad-date.db")(_body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# task_instances: get / list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_by_definition_and_date_round_trip(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
created = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-15", NOW, due_time="17:30"
|
||||
)
|
||||
fetched = await instances.get(definition.id, "2026-09-15")
|
||||
assert fetched == created
|
||||
assert fetched.due_time == "17:30"
|
||||
assert await instances.get(definition.id, "2026-09-16") is None
|
||||
return fetched
|
||||
|
||||
_with_db(tmp_path, "get-round-trip.db")(_body)
|
||||
|
||||
|
||||
def test_get_by_id_round_trip(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
created = await instances.upsert(definition.id, child.id, "2026-09-15", NOW)
|
||||
fetched = await instances.get_by_id(created.id)
|
||||
assert fetched == created
|
||||
assert await instances.get_by_id(999) is None
|
||||
return fetched
|
||||
|
||||
_with_db(tmp_path, "get-by-id.db")(_body)
|
||||
|
||||
|
||||
def test_list_by_child_and_date_filters(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
other = await children.create("Bo", NOW)
|
||||
rule = await rules.create("daily", "2026-09-01")
|
||||
other_definition = await definitions.create(
|
||||
"Make bed", other.id, rule.id, NOW
|
||||
)
|
||||
await instances.upsert(definition.id, child.id, "2026-09-15", NOW)
|
||||
await instances.upsert(definition.id, child.id, "2026-09-16", NOW)
|
||||
await instances.upsert(
|
||||
other_definition.id, other.id, "2026-09-15", NOW
|
||||
)
|
||||
mine = await instances.list_by_child_and_date(child.id, "2026-09-15")
|
||||
assert len(mine) == 1
|
||||
assert mine[0].child_id == child.id
|
||||
assert mine[0].due_date == "2026-09-15"
|
||||
return mine
|
||||
|
||||
_with_db(tmp_path, "list-by-child-date.db")(_body)
|
||||
|
||||
|
||||
def test_list_by_date_range_filters_and_orders(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
for day in ("2026-09-10", "2026-09-12", "2026-09-14", "2026-09-20"):
|
||||
await instances.upsert(definition.id, child.id, day, NOW)
|
||||
found = await instances.list_by_date_range(
|
||||
child.id, "2026-09-11", "2026-09-15"
|
||||
)
|
||||
assert [i.due_date for i in found] == ["2026-09-12", "2026-09-14"]
|
||||
edge = await instances.list_by_date_range(
|
||||
child.id, "2026-09-12", "2026-09-12"
|
||||
)
|
||||
assert [i.due_date for i in edge] == ["2026-09-12"]
|
||||
return found
|
||||
|
||||
_with_db(tmp_path, "list-by-range.db")(_body)
|
||||
|
||||
|
||||
def test_list_by_date_range_rejects_inverted_range(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
with pytest.raises(ValueError, match="on or after"):
|
||||
await instances.list_by_date_range(
|
||||
child.id, "2026-09-15", "2026-09-10"
|
||||
)
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "list-inverted.db")(_body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# task_instances: delete_future_uncompleted
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delete_future_uncompleted_removes_only_eligible(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
await instances.upsert(definition.id, child.id, "2026-09-10", NOW)
|
||||
future_open = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-20", NOW
|
||||
)
|
||||
await instances.upsert(definition.id, child.id, "2026-09-21", NOW)
|
||||
deleted = await instances.delete_future_uncompleted(
|
||||
definition.id, "2026-09-20"
|
||||
)
|
||||
assert deleted == 2
|
||||
assert await instances.get(definition.id, "2026-09-10") is not None
|
||||
assert await instances.get(definition.id, "2026-09-20") is None
|
||||
assert await instances.get(definition.id, "2026-09-21") is None
|
||||
return deleted
|
||||
|
||||
_with_db(tmp_path, "delete-future.db")(_body)
|
||||
|
||||
|
||||
def test_delete_future_uncompleted_spares_completed(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
completed = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-20", NOW
|
||||
)
|
||||
open_one = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-21", NOW
|
||||
)
|
||||
await events.append(
|
||||
completed.id, child.id, "completed", "user", NOW, True,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
deleted = await instances.delete_future_uncompleted(
|
||||
definition.id, "2026-09-20"
|
||||
)
|
||||
assert deleted == 1
|
||||
assert await instances.get_by_id(completed.id) is not None, (
|
||||
"an instance with a completion event must never be deleted"
|
||||
)
|
||||
assert await instances.get_by_id(open_one.id) is None
|
||||
return deleted
|
||||
|
||||
_with_db(tmp_path, "delete-spares-completed.db")(_body)
|
||||
|
||||
|
||||
def test_delete_future_uncompleted_spares_past(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
await instances.upsert(definition.id, child.id, "2026-09-10", NOW)
|
||||
await instances.upsert(definition.id, child.id, "2026-09-11", NOW)
|
||||
deleted = await instances.delete_future_uncompleted(
|
||||
definition.id, "2026-09-11"
|
||||
)
|
||||
assert deleted == 1
|
||||
assert await instances.get(definition.id, "2026-09-10") is not None
|
||||
return deleted
|
||||
|
||||
_with_db(tmp_path, "delete-spares-past.db")(_body)
|
||||
|
||||
|
||||
def test_delete_future_uncompleted_other_definitions_untouched(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
other = await definitions.create(
|
||||
"Make bed", child.id, (await rules.create("daily", "2026-09-01")).id,
|
||||
NOW,
|
||||
)
|
||||
await instances.upsert(definition.id, child.id, "2026-09-20", NOW)
|
||||
await instances.upsert(other.id, child.id, "2026-09-20", NOW)
|
||||
deleted = await instances.delete_future_uncompleted(
|
||||
definition.id, "2026-09-20"
|
||||
)
|
||||
assert deleted == 1
|
||||
assert await instances.get(other.id, "2026-09-20") is not None
|
||||
return deleted
|
||||
|
||||
_with_db(tmp_path, "delete-scoped.db")(_body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# completion_events: append
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_append_completion_round_trips(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
instance = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-15", NOW
|
||||
)
|
||||
event = await events.append(
|
||||
instance.id, child.id, "completed", "user", NOW, True,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
assert isinstance(event, CompletionEventRecord)
|
||||
assert event.event_type == "completed"
|
||||
assert event.actor_source == "user"
|
||||
assert event.actor_user_id == "user-1"
|
||||
assert event.was_on_time is True
|
||||
return event
|
||||
|
||||
_with_db(tmp_path, "append-completion.db")(_body)
|
||||
|
||||
|
||||
def test_append_panel_event_round_trips(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
instance = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-15", NOW
|
||||
)
|
||||
event = await events.append(
|
||||
instance.id, child.id, "completed", "panel", NOW, False,
|
||||
actor_user_id=None,
|
||||
)
|
||||
assert event.actor_source == "panel"
|
||||
assert event.actor_user_id is None
|
||||
assert event.was_on_time is False
|
||||
return event
|
||||
|
||||
_with_db(tmp_path, "append-panel.db")(_body)
|
||||
|
||||
|
||||
def test_append_uncompleted_with_optional_on_time(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
instance = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-15", NOW
|
||||
)
|
||||
reversal = await events.append(
|
||||
instance.id, child.id, "uncompleted", "user", NOW, None,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
assert reversal.event_type == "uncompleted"
|
||||
assert reversal.was_on_time is None
|
||||
carried = await events.append(
|
||||
instance.id, child.id, "uncompleted", "user", NOW, True,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
assert carried.was_on_time is True
|
||||
return (reversal, carried)
|
||||
|
||||
_with_db(tmp_path, "append-uncompleted.db")(_body)
|
||||
|
||||
|
||||
def test_append_validation_rejects_bad_shapes(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
instance = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-15", NOW
|
||||
)
|
||||
with pytest.raises(ValueError, match="event_type"):
|
||||
await events.append(
|
||||
instance.id, child.id, "missed", "user", NOW, True,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
with pytest.raises(ValueError, match="actor_source"):
|
||||
await events.append(
|
||||
instance.id, child.id, "completed", "kiosk", NOW, True,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
with pytest.raises(ValueError, match="requires actor_user_id"):
|
||||
await events.append(
|
||||
instance.id, child.id, "completed", "user", NOW, True,
|
||||
actor_user_id=None,
|
||||
)
|
||||
with pytest.raises(ValueError, match="must not carry"):
|
||||
await events.append(
|
||||
instance.id, child.id, "completed", "panel", NOW, True,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
with pytest.raises(ValueError, match="requires was_on_time"):
|
||||
await events.append(
|
||||
instance.id, child.id, "completed", "panel", NOW, None
|
||||
)
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "append-validation.db")(_body)
|
||||
|
||||
|
||||
def test_append_rejects_unknown_instance_or_child(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
with pytest.raises(ValueError, match="instance 999"):
|
||||
await events.append(
|
||||
999, child.id, "completed", "user", NOW, True,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
instance = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-15", NOW
|
||||
)
|
||||
with pytest.raises(ValueError, match="belongs to child"):
|
||||
await events.append(
|
||||
instance.id, 999, "completed", "user", NOW, True,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "append-unknown.db")(_body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# completion_events: reads and append-only semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_completed_uncompleted_recompleted_three_ordered_events(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
instance = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-15", NOW
|
||||
)
|
||||
await events.append(
|
||||
instance.id, child.id, "completed", "panel", NOW, True
|
||||
)
|
||||
await events.append(
|
||||
instance.id, child.id, "uncompleted", "user", NOW, True,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
await events.append(
|
||||
instance.id, child.id, "completed", "panel", NOW, False
|
||||
)
|
||||
history = await events.list_by_instance(instance.id)
|
||||
assert [e.event_type for e in history] == [
|
||||
"completed",
|
||||
"uncompleted",
|
||||
"completed",
|
||||
]
|
||||
assert [e.id for e in history] == sorted(e.id for e in history)
|
||||
latest = await events.get_latest_for_instance(instance.id)
|
||||
assert latest.event_type == "completed"
|
||||
assert latest.was_on_time is False
|
||||
return history
|
||||
|
||||
_with_db(tmp_path, "three-events.db")(_body)
|
||||
|
||||
|
||||
def test_get_latest_for_instance_none_when_untouched(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
instance = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-15", NOW
|
||||
)
|
||||
return await events.get_latest_for_instance(instance.id)
|
||||
|
||||
assert _with_db(tmp_path, "latest-none.db")(_body) is None
|
||||
|
||||
|
||||
def test_get_latest_returns_uncompleted_when_reversed(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
instance = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-15", NOW
|
||||
)
|
||||
await events.append(
|
||||
instance.id, child.id, "completed", "panel", NOW, True
|
||||
)
|
||||
await events.append(
|
||||
instance.id, child.id, "uncompleted", "user", NOW, True,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
latest = await events.get_latest_for_instance(instance.id)
|
||||
assert latest.event_type == "uncompleted"
|
||||
return latest
|
||||
|
||||
_with_db(tmp_path, "latest-reversed.db")(_body)
|
||||
|
||||
|
||||
def test_list_by_child_and_date_range_scopes_by_due_date(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
early = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-10", NOW
|
||||
)
|
||||
inside = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-12", NOW
|
||||
)
|
||||
late = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-20", NOW
|
||||
)
|
||||
await events.append(
|
||||
early.id, child.id, "completed", "panel", NOW, True
|
||||
)
|
||||
await events.append(
|
||||
inside.id, child.id, "completed", "panel", NOW, False
|
||||
)
|
||||
await events.append(
|
||||
inside.id, child.id, "uncompleted", "user", NOW, True,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
await events.append(
|
||||
late.id, child.id, "completed", "panel", NOW, True
|
||||
)
|
||||
found = await events.list_by_child_and_date_range(
|
||||
child.id, "2026-09-11", "2026-09-15"
|
||||
)
|
||||
assert [e.id for e in found] == [2, 3] # inside's two events
|
||||
return found
|
||||
|
||||
_with_db(tmp_path, "events-by-range.db")(_body)
|
||||
|
||||
|
||||
def test_list_events_occurred_at_scope_variant(tmp_path) -> None:
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
late_due = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-20", NOW
|
||||
)
|
||||
# Completed long after its due date: due-date scope misses it,
|
||||
# occurred-at scope catches it.
|
||||
await events.append(
|
||||
late_due.id, child.id, "completed", "panel", NOW, False
|
||||
)
|
||||
by_due = await events.list_by_child_and_date_range(
|
||||
child.id, "2026-09-01", "2026-09-15"
|
||||
)
|
||||
assert by_due == []
|
||||
by_occurrence = await events.list_by_child_and_date_range(
|
||||
child.id, "2026-09-14", "2026-09-14", due_date_scope=False
|
||||
)
|
||||
assert len(by_occurrence) == 1
|
||||
assert by_occurrence[0].instance_id == late_due.id
|
||||
return by_occurrence
|
||||
|
||||
_with_db(tmp_path, "events-occurred-scope.db")(_body)
|
||||
|
||||
|
||||
def test_completion_events_dao_has_no_update_or_delete(tmp_path) -> None:
|
||||
"""Done-condition: NO update or delete method exists at all."""
|
||||
async def _body(database, children, rules, definitions, instances,
|
||||
events, child, definition):
|
||||
import inspect
|
||||
|
||||
methods = {
|
||||
name
|
||||
for name, _ in inspect.getmembers(
|
||||
CompletionEventsDao, inspect.isfunction
|
||||
)
|
||||
if not name.startswith("__")
|
||||
}
|
||||
forbidden = {
|
||||
name for name in methods
|
||||
if name.startswith(("update", "delete", "set", "clear",
|
||||
"remove", "modify", "edit", "upsert"))
|
||||
}
|
||||
assert forbidden == set(), (
|
||||
f"append-only DAO exposed mutation methods: {forbidden}"
|
||||
)
|
||||
assert methods == {
|
||||
"append",
|
||||
"list_by_instance",
|
||||
"list_by_child_and_date_range",
|
||||
"get_latest_for_instance",
|
||||
}
|
||||
return methods
|
||||
|
||||
_with_db(tmp_path, "events-no-mutation.db")(_body)
|
||||
|
||||
|
||||
def test_no_mutation_sql_for_completion_events_anywhere() -> None:
|
||||
"""Package-wide guardrail: UPDATE/DELETE ... completion_events must
|
||||
not appear in ANY module or test — the append-only property is
|
||||
enforced here, not just in the DAO class docstring.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
package = Path(
|
||||
__import__(
|
||||
"custom_components.nestquest", fromlist=["__file__"]
|
||||
).__file__
|
||||
).parent
|
||||
repo_root = package.parent.parent
|
||||
mutation_pattern = re.compile(
|
||||
r"(UPDATE|DELETE\s+FROM)\s+[`'\"]*(\[)?completion_events\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
offenders: list[str] = []
|
||||
for root in (package, repo_root / "tests"):
|
||||
for py in sorted(root.rglob("*.py")):
|
||||
if mutation_pattern.search(py.read_text()):
|
||||
offenders.append(py.relative_to(repo_root).as_posix())
|
||||
assert offenders == [], (
|
||||
f"completion_events mutation SQL found in: {offenders}"
|
||||
)
|
||||
|
||||
|
||||
def test_append_concurrent_events_serialize(tmp_path) -> None:
|
||||
"""Concurrent appends for the same instance all land, in order."""
|
||||
async def _main():
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
await database.open(tmp_path / "append-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
prepared = await _prepare_in(database)
|
||||
_, children, _, _, instances, events, child, definition = (
|
||||
prepared
|
||||
)
|
||||
instance = await instances.upsert(
|
||||
definition.id, child.id, "2026-09-15", NOW
|
||||
)
|
||||
tasks = [
|
||||
asyncio.ensure_future(
|
||||
events.append(
|
||||
instance.id, child.id, "completed", "user",
|
||||
f"2026-09-15T08:0{index}:00+00:00", True,
|
||||
actor_user_id=f"user-{index}",
|
||||
)
|
||||
)
|
||||
for index in range(5)
|
||||
]
|
||||
await asyncio.sleep(0)
|
||||
appended = await asyncio.gather(*tasks)
|
||||
history = await events.list_by_instance(instance.id)
|
||||
assert [e.id for e in history] == [
|
||||
e.id for e in sorted(appended, key=lambda x: x.id)
|
||||
]
|
||||
assert len(history) == 5
|
||||
return history
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
async def _prepare_in(database):
|
||||
children = ChildrenDao(database)
|
||||
rules = ScheduleRulesDao(database)
|
||||
definitions = TaskDefinitionsDao(database)
|
||||
instances = TaskInstancesDao(database)
|
||||
events = CompletionEventsDao(database)
|
||||
child = await children.create("Ada", NOW)
|
||||
rule = await rules.create("daily", "2026-09-01")
|
||||
definition = await definitions.create(
|
||||
"Brush teeth", child.id, rule.id, NOW
|
||||
)
|
||||
return (database, children, rules, definitions, instances,
|
||||
events, child, definition)
|
||||
|
||||
history = _run(_main())
|
||||
assert history[-1].occurred_at == "2026-09-15T08:04:00+00:00"
|
||||
@@ -789,15 +789,15 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None:
|
||||
|
||||
allowed = {
|
||||
"custom_components/nestquest/dao_rules.py", # this DAO
|
||||
"custom_components/nestquest/dao_instances.py", # validates the
|
||||
# definition exists + fetches its assignee before generating an
|
||||
# instance (the instance must snapshot the definition's child)
|
||||
"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_rules.py", # this file, scanned separately
|
||||
# dao_children.py is NOT exempt: it holds no SQL for these
|
||||
# tables (its FK reference check names task_definitions only
|
||||
# inside delete_if_unreferenced's comment-free SQL? No — that
|
||||
# lives HERE in dao_rules). Any future leak there must fail.
|
||||
"tests/test_dao_instances.py", # instances guard, own scope
|
||||
}
|
||||
sql_pattern = re.compile(
|
||||
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?"
|
||||
|
||||
Reference in New Issue
Block a user