Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3e7c0d3d6 | ||
|
|
5b5aa7c789 | ||
|
|
5db49b3d29 | ||
|
|
0281b51676 | ||
|
|
c5cebc9f49 | ||
|
|
12bb4d58b4 | ||
|
|
29c058a045 |
@@ -1,13 +1,17 @@
|
||||
"""The NestQuest integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .db import NestQuestDatabase
|
||||
@@ -16,6 +20,191 @@ from .store import async_get_db_path
|
||||
|
||||
_LOGGER = LOGGER
|
||||
|
||||
#: SQLite reports "file is not a database" for corruption in every
|
||||
#: sqlite3 version this integration targets; the version-specific
|
||||
#: "encrypted or malformed" spelling (3.41+) maps to the same failure.
|
||||
_CORRUPTION_MARKERS = (
|
||||
"file is not a database",
|
||||
"database disk image is malformed",
|
||||
"encrypted or malformed",
|
||||
"is not a database",
|
||||
)
|
||||
|
||||
#: Primary sqlite error codes that mean "the file is corrupt" (SQLite
|
||||
#: result codes, https://www.sqlite.org/c3ref/c_abort.html). Extended
|
||||
#: codes carry the primary code in their low byte, so the match is on
|
||||
#: errcode & 0xff.
|
||||
_CORRUPT_PRIMARY_CODES = frozenset({11, 26}) # SQLITE_CORRUPT, SQLITE_NOTADB
|
||||
|
||||
|
||||
def _is_corruption_error(error: BaseException) -> bool:
|
||||
"""Return True when ``error`` indicates a corrupt database file.
|
||||
|
||||
Classification order: sqlite_errorcode (Python 3.11+) is primary
|
||||
and locale-independent — SQLITE_CORRUPT and SQLITE_NOTADB, matched
|
||||
on the primary byte so extended codes (SQLITE_CORRUPT_VTAB etc.)
|
||||
classify correctly too. Message matching is the fallback for
|
||||
older Pythons: the corruption spellings are the documented English
|
||||
texts; a non-English locale could evade them, but the code check
|
||||
already covers every locale on supported Pythons.
|
||||
"""
|
||||
if not isinstance(error, sqlite3.DatabaseError):
|
||||
return False
|
||||
errorcode = getattr(error, "sqlite_errorcode", None)
|
||||
if errorcode is not None and (errorcode & 0xFF) in _CORRUPT_PRIMARY_CODES:
|
||||
return True
|
||||
message = str(error).lower()
|
||||
return any(marker in message for marker in _CORRUPTION_MARKERS)
|
||||
|
||||
|
||||
async def _preflight_existing_file(
|
||||
hass: HomeAssistant, db_path: Path
|
||||
) -> None:
|
||||
"""Detect corruption in an EXISTING file before it is opened read-write.
|
||||
|
||||
Opening read-write persists WAL mode — for a rollback-journal file
|
||||
that rewrites the header byte 18/19 and can create -wal/-shm
|
||||
sidecars — which would modify a corrupt file before detection and
|
||||
break the leave-untouched guarantee. This preflight therefore
|
||||
validates the WHOLE file with a READ-ONLY connection (mode=ro +
|
||||
immutable=1 URI, PRAGMA integrity_check, run on the executor like
|
||||
every other DB call): corruption anywhere fails there, before
|
||||
anything is written. ``immutable=1`` is SQLite's documented
|
||||
guarantee that the connection performs NO writes and creates NO
|
||||
sidecars — even a WAL-header file gets its -wal/-shm reads served
|
||||
without creating the shared-memory file. Missing/empty files skip
|
||||
the preflight (they are created fresh by the real open). All
|
||||
filesystem stat/URI work runs inside the executor job with the
|
||||
probe so the event loop never touches disk.
|
||||
"""
|
||||
def _probe() -> None:
|
||||
# The file-state check and URI construction live inside the
|
||||
# executor job: exists/stat/resolve are disk I/O.
|
||||
if not db_path.exists() or db_path.stat().st_size == 0:
|
||||
return
|
||||
# Properly escape the path: '#'/'?'/'%' in a directory or file
|
||||
# name would otherwise be parsed as URI fragments/parameters/
|
||||
# escapes. immutable=1: SQLite guarantees no writes and no
|
||||
# sidecar creation from this connection.
|
||||
uri = db_path.resolve().as_uri() + "?mode=ro&immutable=1"
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
try:
|
||||
row = conn.execute("PRAGMA integrity_check").fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
verdict = row[0] if row else "missing"
|
||||
if verdict != "ok":
|
||||
# A non-ok integrity verdict IS corruption: raise with the
|
||||
# verdict text (which matches the corruption markers) and
|
||||
# the NOTADB code so classification is code-based.
|
||||
raise sqlite3.DatabaseError(
|
||||
f"integrity_check verdict {verdict!r} on read-only "
|
||||
f"probe: file is not a database"
|
||||
)
|
||||
|
||||
try:
|
||||
# Every DB-touching call goes through the executor (the mock
|
||||
# harness's async_add_executor_job is awaitable too).
|
||||
await hass.async_add_executor_job(_probe)
|
||||
except sqlite3.DatabaseError as err:
|
||||
# Non-ok integrity verdicts are corruption, full stop: there is
|
||||
# no benign reading of a failed whole-file integrity check, so
|
||||
# ANY DatabaseError out of the probe that carries a corruption
|
||||
# marker or code propagates as corruption. Only exotic probe
|
||||
# failures that classify as non-corruption (e.g. a transient
|
||||
# 'database is locked' on the read) are downgraded to a debug
|
||||
# log, letting the real open path surface them.
|
||||
if _is_corruption_error(err):
|
||||
raise
|
||||
raise sqlite3.DatabaseError(
|
||||
f"read-only corruption probe of {db_path} failed: {err}"
|
||||
) from err
|
||||
|
||||
async def _async_open_database(
|
||||
hass: HomeAssistant, db_path: Path
|
||||
) -> NestQuestDatabase:
|
||||
"""Open the NestQuest database, mapping failure modes for setup.
|
||||
|
||||
Three startup cases (Feature 02 done-condition):
|
||||
|
||||
- file missing: SQLite creates it fresh on open — no special path,
|
||||
migrations stamp version 1 on the next step.
|
||||
- file present and valid: opens normally.
|
||||
- file present but corrupt: log an ERROR with the path, close
|
||||
anything opened, and raise ConfigEntryNotReady so HA retries —
|
||||
the file is left byte-for-byte untouched so the owner can
|
||||
restore a backup. No code path here deletes or overwrites an
|
||||
existing database file.
|
||||
|
||||
Corruption can surface at EITHER stage: a truncated header fails
|
||||
in the read-only preflight (before the file is ever opened
|
||||
read-write), while damaged later pages fail during migrations —
|
||||
the preflight guarantees no WAL sidecar or header write happens
|
||||
on a corrupt file before it is detected. Both are classified the
|
||||
same way.
|
||||
"""
|
||||
try:
|
||||
await _preflight_existing_file(hass, db_path)
|
||||
database = await NestQuestDatabase(hass).open(db_path)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except sqlite3.DatabaseError as err:
|
||||
if _is_corruption_error(err):
|
||||
_LOGGER.error(
|
||||
"NestQuest database at %s is corrupt: %s. Refusing to "
|
||||
"start with a corrupt database; the file was left "
|
||||
"untouched so a backup can be restored. Home Assistant "
|
||||
"will retry setup.",
|
||||
db_path,
|
||||
err,
|
||||
)
|
||||
raise ConfigEntryNotReady(
|
||||
f"NestQuest database corrupt: {db_path}"
|
||||
) from err
|
||||
# open() failed for a non-corruption reason (locked file,
|
||||
# permission problem): retryable, but nothing was opened.
|
||||
_LOGGER.error(
|
||||
"NestQuest database at %s failed to open: %s. Home "
|
||||
"Assistant will retry setup.",
|
||||
db_path,
|
||||
err,
|
||||
)
|
||||
raise ConfigEntryNotReady(
|
||||
f"NestQuest database unavailable: {db_path}"
|
||||
) from err
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
except (sqlite3.DatabaseError, RuntimeError) as err:
|
||||
await database.close()
|
||||
if _is_corruption_error(err):
|
||||
_LOGGER.error(
|
||||
"NestQuest database at %s is corrupt: %s. Refusing to "
|
||||
"start with a corrupt database; the file was left "
|
||||
"untouched so a backup can be restored. Home Assistant "
|
||||
"will retry setup.",
|
||||
db_path,
|
||||
err,
|
||||
)
|
||||
raise ConfigEntryNotReady(
|
||||
f"NestQuest database corrupt: {db_path}"
|
||||
) from err
|
||||
# A non-corruption failure (e.g. a locked file, a future schema
|
||||
# version) is also a retryable setup problem, but without the
|
||||
# corruption framing.
|
||||
_LOGGER.error(
|
||||
"NestQuest database at %s failed to open: %s. Home "
|
||||
"Assistant will retry setup.",
|
||||
db_path,
|
||||
err,
|
||||
)
|
||||
raise ConfigEntryNotReady(
|
||||
f"NestQuest database unavailable: {db_path}"
|
||||
) from err
|
||||
except asyncio.CancelledError:
|
||||
await database.close()
|
||||
raise
|
||||
return database
|
||||
|
||||
|
||||
@dataclass
|
||||
class NestQuestRuntimeData:
|
||||
@@ -38,8 +227,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
existing = hass.data[DOMAIN].get(entry.entry_id)
|
||||
if existing is not None:
|
||||
db = getattr(existing, "database", None)
|
||||
if db is None:
|
||||
db = await NestQuestDatabase(hass).open(await async_get_db_path(hass))
|
||||
if db is None or not db.connected:
|
||||
# Reopen through the SAME safety path as first setup: a
|
||||
# record without a live connection (a previous setup's
|
||||
# close-on-failure, or a hand-replaced file) must get the
|
||||
# missing/valid/corrupt classification too, never a raw
|
||||
# sqlite3 error and never skipped migrations.
|
||||
db = await _async_open_database(
|
||||
hass, await async_get_db_path(hass)
|
||||
)
|
||||
existing.database = db
|
||||
entry.runtime_data = existing
|
||||
return True
|
||||
@@ -50,9 +246,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Reload the entry when its options change."""
|
||||
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
|
||||
|
||||
database = await NestQuestDatabase(hass).open(await async_get_db_path(hass))
|
||||
database = await _async_open_database(hass, await async_get_db_path(hass))
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
remove_update_listener = entry.add_update_listener(_async_update_listener)
|
||||
except BaseException:
|
||||
await database.close()
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
"""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
|
||||
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .dao_children import _connection_lock
|
||||
from .dao_rules import _validate_date
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
#: Timestamp policy for completion_events.occurred_at: strict UTC
|
||||
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
|
||||
#: One accepted shape bounds lexicographic range queries correctly, so
|
||||
#: the occurred-at scope does not guess at timestamp spellings.
|
||||
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||
|
||||
|
||||
def _validate_utc_timestamp(value: str, field: str) -> None:
|
||||
"""Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp.
|
||||
|
||||
Same strictness policy as _validate_date: parse with the exact
|
||||
format, then round-trip the parsed value against the input so
|
||||
non-padded or re-ordered shapes are rejected. The round-trip
|
||||
strips the UTC offset (isoformat() omits it when the offset is
|
||||
zero), so the suffix is appended before comparing.
|
||||
"""
|
||||
try:
|
||||
parsed = datetime.datetime.strptime(
|
||||
value, _UTC_TIMESTAMP_FORMAT
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(
|
||||
f"{field} must be a UTC ISO-8601 timestamp "
|
||||
f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}"
|
||||
) from None
|
||||
if parsed.isoformat() + "+00:00" != value:
|
||||
raise ValueError(
|
||||
f"{field} must be a strict UTC timestamp "
|
||||
f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def _next_day_start(date_str: str) -> str:
|
||||
"""Return the UTC timestamp one second past ``date_str``'s last second.
|
||||
|
||||
Gives range queries an exact half-open upper bound without
|
||||
fractional-second guessing.
|
||||
"""
|
||||
day = datetime.date.fromisoformat(date_str)
|
||||
next_day = day + datetime.timedelta(days=1)
|
||||
return f"{next_day.isoformat()}T00:00:00+00:00"
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
Guardrails enforced here:
|
||||
|
||||
- ``due_date`` must be TODAY or later: instances are never
|
||||
generated in the past (Feature 07). The caller's
|
||||
materialization horizon is future-dated by definition.
|
||||
- An existing instance WITH a completion event is IMMUTABLE:
|
||||
the conflict path must not rewrite its snapshot columns
|
||||
(Feature 07/08). The upsert refuses with ValueError in that
|
||||
case — silently no-oping would mask a materialization bug.
|
||||
|
||||
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")
|
||||
# Fail fast on obviously-past dates BEFORE queuing on the
|
||||
# connection lock (a nice early error); the AUTHORITATIVE
|
||||
# check runs after the lock is acquired, because a caller can
|
||||
# wait on the lock across midnight — a date that was "today"
|
||||
# at call time may be "yesterday" by the time the INSERT runs.
|
||||
today = datetime.date.today().isoformat()
|
||||
if due_date < today:
|
||||
raise ValueError(
|
||||
f"instances are never generated in the past: due_date "
|
||||
f"{due_date!r} is before today {today!r}"
|
||||
)
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
# Re-read "today" under the lock: this is the date the
|
||||
# insert actually executes on, so the no-past rule
|
||||
# holds across midnight rollovers.
|
||||
today = datetime.date.today().isoformat()
|
||||
if due_date < today:
|
||||
raise ValueError(
|
||||
f"instances are never generated in the past: "
|
||||
f"due_date {due_date!r} is before today "
|
||||
f"{today!r}"
|
||||
)
|
||||
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")
|
||||
existing = await self._database.fetch_one(
|
||||
"SELECT 1 FROM completion_events "
|
||||
"WHERE instance_id = (SELECT id FROM task_instances "
|
||||
"WHERE definition_id = ? AND due_date = ?)",
|
||||
(definition_id, due_date),
|
||||
)
|
||||
if existing is not None:
|
||||
raise ValueError(
|
||||
f"instance for (definition {definition_id}, "
|
||||
f"due_date {due_date!r}) already has a completion "
|
||||
"event and is immutable; it cannot be regenerated"
|
||||
)
|
||||
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 open instances at/after cutoff.
|
||||
|
||||
Implements regeneration after a definition edit or reassignment
|
||||
(Features 06/07): only instances at or after the cutoff with NO
|
||||
completion event are removed, inside one transaction — an
|
||||
instance that gains a completion event mid-flight cannot be
|
||||
deleted. The cutoff cannot be backdated below today: "future"
|
||||
is measured against TODAY (materialization regenerates the
|
||||
rolling horizon from now), so a caller cannot use this to
|
||||
rewrite past open instances either — the day-rollover missed
|
||||
sweep (Feature 11) handles past-due instances instead. Any
|
||||
instance with a completion event is never touched, regardless
|
||||
of its date. Returns the number deleted.
|
||||
"""
|
||||
_validate_date(cutoff_date, "cutoff_date")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
# "today" is read under the lock: the DELETE actually
|
||||
# executes on this date, so a caller queued across
|
||||
# midnight cannot delete instances that became past
|
||||
# while it waited (backdating is clamped to the
|
||||
# execution date, not the call date).
|
||||
today = datetime.date.today().isoformat()
|
||||
effective_cutoff = max(cutoff_date, today)
|
||||
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, effective_cutoff),
|
||||
)
|
||||
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 | None,
|
||||
*,
|
||||
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.
|
||||
``occurred_at`` must be the module's one strict UTC shape
|
||||
(YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range queries
|
||||
stay exact. ``was_on_time`` is required (True/False) for
|
||||
completions; for un-completions it may be None or the reversed
|
||||
completion's flag (Feature 08 still deciding). 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 was_on_time is not None and type(was_on_time) is not bool:
|
||||
raise ValueError(
|
||||
f"was_on_time must be True, False or None, got "
|
||||
f"{was_on_time!r}"
|
||||
)
|
||||
if event_type == EVENT_COMPLETED and was_on_time is None:
|
||||
raise ValueError(
|
||||
"a completed event requires was_on_time (True/False)"
|
||||
)
|
||||
_validate_utc_timestamp(occurred_at, "occurred_at")
|
||||
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:
|
||||
# Occurred-at scope: occurred_at is validated to the one
|
||||
# accepted UTC shape at append time, so half-open day
|
||||
# bounds in that same shape are exact (no spelling drift).
|
||||
occurred_start = f"{range_start}T00:00:00+00:00"
|
||||
occurred_end = f"{range_end}T23:59:59+00:00"
|
||||
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,
|
||||
_next_day_start(range_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
|
||||
@@ -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
|
||||
@@ -41,6 +41,7 @@ _HA_MODULES = (
|
||||
"homeassistant.core",
|
||||
"homeassistant.config_entries",
|
||||
"homeassistant.data_entry_flow",
|
||||
"homeassistant.exceptions",
|
||||
)
|
||||
|
||||
def _find_spec(name: str):
|
||||
@@ -77,6 +78,7 @@ for _parent, _child in (
|
||||
("homeassistant", "config_entries"),
|
||||
("homeassistant", "data_entry_flow"),
|
||||
("homeassistant", "core"),
|
||||
("homeassistant", "exceptions"),
|
||||
):
|
||||
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
|
||||
|
||||
@@ -158,6 +160,7 @@ def _callback_decorator(fn):
|
||||
_config_entries_mock = _ha_mock("homeassistant.config_entries")
|
||||
_core_mock = _ha_mock("homeassistant.core")
|
||||
_data_entry_flow_mock = _ha_mock("homeassistant.data_entry_flow")
|
||||
_exceptions_mock = _ha_mock("homeassistant.exceptions")
|
||||
|
||||
_config_entries_mock.ConfigFlow = _ConfigFlowBase
|
||||
_config_entries_mock.OptionsFlowWithConfigEntry = _OptionsFlowWithConfigEntryBase
|
||||
@@ -168,6 +171,17 @@ _data_entry_flow_mock.RESULT_TYPE_ABORT = "abort"
|
||||
_data_entry_flow_mock.FlowResult = dict
|
||||
|
||||
|
||||
class ConfigEntryNotReady(Exception):
|
||||
"""Stand-in mirroring homeassistant.exceptions.ConfigEntryNotReady.
|
||||
|
||||
HA retries setup when this is raised; the tests assert the corrupt
|
||||
database path raises it instead of crashing with an unrelated error.
|
||||
"""
|
||||
|
||||
|
||||
_exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers for the behavior tests.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -481,12 +481,22 @@ 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/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
|
||||
"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
|
||||
"tests/test_dao_instances.py", # instances guard, own scope
|
||||
"tests/test_startup_db.py", # row-count probe of the children
|
||||
# table only (proving a fresh setup loads); guard below covers
|
||||
# everything else
|
||||
}
|
||||
sql_pattern = re.compile(
|
||||
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+"
|
||||
@@ -499,13 +509,66 @@ def test_children_and_admin_sql_lives_only_in_dao_module() -> None:
|
||||
relative = py.relative_to(repo_root).as_posix()
|
||||
if relative in allowed:
|
||||
continue
|
||||
if sql_pattern.search(py.read_text()):
|
||||
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 sql_pattern.search(text):
|
||||
offenders.append(relative)
|
||||
assert offenders == [], (
|
||||
f"SQL touching children/admin_users leaked into: {offenders}"
|
||||
)
|
||||
|
||||
|
||||
def _strip_function_spans(text: str, names: set[str]) -> str:
|
||||
"""Return ``text`` minus the source spans of the named functions."""
|
||||
import ast
|
||||
|
||||
lines = text.splitlines(keepends=True)
|
||||
excluded: set[int] = set()
|
||||
tree = ast.parse(text)
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and node.name in names
|
||||
):
|
||||
excluded.update(
|
||||
range(node.lineno, (node.end_lineno or node.lineno) + 1)
|
||||
)
|
||||
return "".join(
|
||||
line
|
||||
for number, line in enumerate(lines, start=1)
|
||||
if number not in excluded
|
||||
)
|
||||
|
||||
|
||||
def test_startup_db_test_file_uses_dao_elsewhere() -> None:
|
||||
"""Compensating self-scan for the startup-test exemption: outside
|
||||
the single sanctioned COUNT(*) probe, test_startup_db.py must go
|
||||
through the DAO for children/admin_users data access.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
startup_test = Path(__file__).parent / "test_startup_db.py"
|
||||
stripped = _strip_function_spans(
|
||||
startup_test.read_text(),
|
||||
{"test_setup_with_valid_file_preserves_rows"},
|
||||
)
|
||||
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_startup_db.py must go through the DAO for these tables "
|
||||
"outside its sanctioned row-count probe"
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
)
|
||||
+48
-4
@@ -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+[`'\"]*(\[)?"
|
||||
@@ -818,6 +818,50 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_dao_instances_test_file_uses_dao_not_raw_rules_sql() -> None:
|
||||
"""Compensating self-scan for the test_dao_instances.py exemption:
|
||||
that file may query task_instances/completion_events raw (its own
|
||||
guard's scope) but must go through the DAO for schedule_rules and
|
||||
task_definitions, except its single sanctioned definition-create
|
||||
helper usage and guard spans.
|
||||
"""
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
instances_test = Path(__file__).parent / "test_dao_instances.py"
|
||||
text = instances_test.read_text()
|
||||
lines = text.splitlines(keepends=True)
|
||||
tree = ast.parse(text)
|
||||
guard_names = {
|
||||
"test_rules_and_definitions_sql_lives_only_in_dao_module",
|
||||
"test_no_mutation_sql_for_completion_events_anywhere",
|
||||
}
|
||||
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)
|
||||
)
|
||||
|
||||
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+[`'\"]*(\[)?(schedule_rules|task_definitions)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
assert sql_pattern.search(remaining) is None, (
|
||||
"test_dao_instances.py must go through the DAO, not raw SQL, "
|
||||
"for schedule_rules/task_definitions"
|
||||
)
|
||||
|
||||
|
||||
def test_dao_rules_test_file_uses_dao_not_raw_table_sql() -> None:
|
||||
"""This test module must exercise the DAO, not raw SQL, for these
|
||||
tables. Exceptions: the guard functions' own spans (their regexes
|
||||
|
||||
@@ -638,11 +638,14 @@ async def test_setup_entry_failure_closes_database(
|
||||
) -> None:
|
||||
"""A failing migration closes the opened connection before re-raising.
|
||||
|
||||
Spies on the real wrapper instance: records whether close() ran and
|
||||
whether the connection is left disconnected, rather than inferring
|
||||
from hass.data (a failed setup never stores a runtime record).
|
||||
A migration failure surfaces as ConfigEntryNotReady (HA retries
|
||||
setup) with the original error preserved as __cause__. Spies on
|
||||
the real wrapper instance: records whether close() ran, rather
|
||||
than inferring from hass.data (a failed setup never stores a
|
||||
runtime record).
|
||||
"""
|
||||
import pytest as pytest_module
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
import custom_components.nestquest as nestquest_module
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
@@ -667,8 +670,10 @@ async def test_setup_entry_failure_closes_database(
|
||||
monkeypatch.setattr(NestQuestDatabase, "close", _spy_close)
|
||||
|
||||
entry = make_entry()
|
||||
with pytest_module.raises(RuntimeError, match="boom"):
|
||||
with pytest_module.raises(ConfigEntryNotReady) as excinfo:
|
||||
await _setup_entry(hass, entry, hass.registry)
|
||||
assert isinstance(excinfo.value.__cause__, RuntimeError)
|
||||
assert "boom" in str(excinfo.value.__cause__)
|
||||
|
||||
assert close_calls == [True], (
|
||||
"failed setup must close the connection it opened"
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
"""Tests for startup handling of missing, valid, and corrupt databases."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import sqlite3
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from conftest import make_config_entry
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from custom_components.nestquest import (
|
||||
_async_open_database,
|
||||
_is_corruption_error,
|
||||
async_setup_entry,
|
||||
async_unload_entry,
|
||||
)
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import (
|
||||
VERSION_TABLE,
|
||||
apply_migrations,
|
||||
)
|
||||
from tests.test_lifecycle import _wire
|
||||
|
||||
|
||||
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 _open_db(path) -> NestQuestDatabase:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
_run(database.open(path))
|
||||
return database
|
||||
|
||||
|
||||
async def _avalid_database(path) -> None:
|
||||
"""Create a valid, migrated NestQuest database file (async, on the
|
||||
running loop, for use inside pytest-asyncio tests)."""
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
await database.open(path)
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
def _valid_database(path) -> None:
|
||||
"""Create a valid, migrated NestQuest database file at ``path``."""
|
||||
database = _open_db(path)
|
||||
try:
|
||||
_run(apply_migrations(database))
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def _truncated_database(path) -> None:
|
||||
"""Create a deliberately truncated (corrupt) database file.
|
||||
|
||||
A valid database is written, closed, then cut short mid-header:
|
||||
opening it reports 'file is not a database' — exactly what an
|
||||
interrupted write or failing disk leaves behind.
|
||||
"""
|
||||
_valid_database(path)
|
||||
data = path.read_bytes()
|
||||
assert len(data) > 64, "valid database must exist before truncation"
|
||||
path.write_bytes(data[: len(data) // 2])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Corruption classification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_corruption_error_matches_sqlite_spellings() -> None:
|
||||
assert _is_corruption_error(
|
||||
sqlite3.DatabaseError("file is not a database")
|
||||
)
|
||||
assert _is_corruption_error(
|
||||
sqlite3.DatabaseError("file is not a database".upper())
|
||||
)
|
||||
assert _is_corruption_error(
|
||||
sqlite3.DatabaseError("file is encrypted or malformed")
|
||||
)
|
||||
assert _is_corruption_error(
|
||||
sqlite3.OperationalError("file is not a database")
|
||||
)
|
||||
|
||||
|
||||
def test_is_corruption_error_rejects_other_failures() -> None:
|
||||
assert not _is_corruption_error(
|
||||
sqlite3.OperationalError("database is locked")
|
||||
)
|
||||
assert not _is_corruption_error(
|
||||
sqlite3.IntegrityError("NOT NULL constraint failed")
|
||||
)
|
||||
assert not _is_corruption_error(RuntimeError("boom"))
|
||||
assert not _is_corruption_error(ValueError("nope"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Startup: three cases through _async_open_database
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_missing_file_opens_fresh(tmp_path) -> None:
|
||||
db_path = tmp_path / "nestquest.db"
|
||||
assert not db_path.exists()
|
||||
database = _run(_async_open_database(_make_hass_mock(), db_path))
|
||||
try:
|
||||
# The file was created fresh and migrations stamped version 1.
|
||||
assert db_path.exists()
|
||||
row = _run(
|
||||
database.fetch_one(f"SELECT version FROM {VERSION_TABLE}")
|
||||
)
|
||||
assert row == (1,)
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_valid_file_opens_and_migrations_noop(tmp_path) -> None:
|
||||
db_path = tmp_path / "nestquest.db"
|
||||
_valid_database(db_path)
|
||||
digest_before = hashlib.sha256(db_path.read_bytes()).hexdigest()
|
||||
database = _run(_async_open_database(_make_hass_mock(), db_path))
|
||||
try:
|
||||
row = _run(
|
||||
database.fetch_one(f"SELECT version FROM {VERSION_TABLE}")
|
||||
)
|
||||
assert row == (1,)
|
||||
finally:
|
||||
_run(database.close())
|
||||
# Opening a valid file must not rewrite it (the no-op path).
|
||||
assert (
|
||||
hashlib.sha256(db_path.read_bytes()).hexdigest() == digest_before
|
||||
)
|
||||
|
||||
|
||||
def test_corrupt_file_raises_not_ready_and_leaves_file_untouched(
|
||||
tmp_path, caplog
|
||||
) -> None:
|
||||
db_path = tmp_path / "nestquest.db"
|
||||
_truncated_database(db_path)
|
||||
corrupt_bytes = db_path.read_bytes()
|
||||
digest = hashlib.sha256(corrupt_bytes).hexdigest()
|
||||
|
||||
with pytest.raises(ConfigEntryNotReady) as excinfo:
|
||||
_run(_async_open_database(_make_hass_mock(), db_path))
|
||||
|
||||
# The file is byte-identical: the owner can restore/inspect it.
|
||||
assert hashlib.sha256(db_path.read_bytes()).hexdigest() == digest
|
||||
assert len(db_path.read_bytes()) == len(corrupt_bytes)
|
||||
# The original error is preserved and the log names the path.
|
||||
assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError)
|
||||
assert any(
|
||||
"corrupt" in record.getMessage().lower()
|
||||
and str(db_path) in record.getMessage()
|
||||
for record in caplog.records
|
||||
if record.levelname == "ERROR"
|
||||
), "the error log must name the corrupt path"
|
||||
|
||||
|
||||
def test_corrupt_file_raises_not_ready_never_raw_error(tmp_path) -> None:
|
||||
"""The corrupt case must surface as ConfigEntryNotReady, not crash
|
||||
setup with a raw sqlite3.DatabaseError — and it must ALWAYS raise
|
||||
(returning normally would mean corruption was silently accepted)."""
|
||||
db_path = tmp_path / "nestquest.db"
|
||||
_truncated_database(db_path)
|
||||
with pytest.raises(ConfigEntryNotReady) as excinfo:
|
||||
_run(_async_open_database(_make_hass_mock(), db_path))
|
||||
assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError)
|
||||
|
||||
|
||||
def test_migration_stage_corruption_leaves_db_and_sidecars_untouched(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""A valid header with a corrupt LATER page must be rejected by the
|
||||
whole-file read-only preflight BEFORE the read-write open can
|
||||
switch the journal mode to WAL.
|
||||
|
||||
The fixture is deliberately a ROLLBACK-JOURNAL file (created with
|
||||
plain sqlite3, default journal_mode=delete, NOT via the wrapper):
|
||||
that is the case where the WAL transition actually rewrites the
|
||||
header — a WAL-mode fixture would mask the write under test. The
|
||||
open() spy proves the read-write open was never reached.
|
||||
"""
|
||||
db_path = tmp_path / "nestquest.db"
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=DELETE")
|
||||
conn.execute(
|
||||
"CREATE TABLE filler (id INTEGER PRIMARY KEY, "
|
||||
"payload TEXT NOT NULL)"
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO filler (payload) VALUES (?)",
|
||||
[(f"row-{index}" * 8,) for index in range(200)],
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
data = bytearray(db_path.read_bytes())
|
||||
assert conn.execute is not None
|
||||
assert len(data) > 4200, "fixture must span more than one page"
|
||||
# Corrupt a page well past the 100-byte header (offset 4096, the
|
||||
# second page region): the header itself stays valid, so the
|
||||
# read-write open succeeds and only the integrity probe/migrations
|
||||
# discover damage.
|
||||
for offset in range(4096, 4128):
|
||||
data[offset] = 0xFF
|
||||
db_path.write_bytes(bytes(data))
|
||||
corrupt_bytes = db_path.read_bytes()
|
||||
digest = hashlib.sha256(corrupt_bytes).hexdigest()
|
||||
|
||||
# Track whether the read-write open is reached at all: the whole
|
||||
# preflight must reject this corruption BEFORE NestQuestDatabase
|
||||
# .open() is ever called, which is the only way the file can stay
|
||||
# byte-identical (the open persists WAL, rewriting the header).
|
||||
open_calls: list[str] = []
|
||||
original_open = NestQuestDatabase.open
|
||||
|
||||
async def _spy_open(self, path):
|
||||
open_calls.append(str(path))
|
||||
return await original_open(self, path)
|
||||
|
||||
monkeypatch.setattr(NestQuestDatabase, "open", _spy_open)
|
||||
|
||||
with pytest.raises(ConfigEntryNotReady) as excinfo:
|
||||
_run(_async_open_database(_make_hass_mock(), db_path))
|
||||
assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError)
|
||||
|
||||
# The corruption was caught at the preflight: the read-write open
|
||||
# (and with it the WAL header rewrite) was never attempted.
|
||||
assert open_calls == [], (
|
||||
"whole-file preflight must reject corruption before the "
|
||||
"read-write open can transition the journal mode"
|
||||
)
|
||||
|
||||
# The main file is byte-identical to the corrupt state we handed in.
|
||||
assert hashlib.sha256(db_path.read_bytes()).hexdigest() == digest
|
||||
# No WAL/SHM sidecars left behind (WAL mode was never persisted,
|
||||
# or they were cleaned up on close).
|
||||
assert not (tmp_path / "nestquest.db-wal").exists()
|
||||
assert not (tmp_path / "nestquest.db-shm").exists()
|
||||
|
||||
|
||||
def test_wal_mode_corruption_leaves_file_and_sidecars_untouched(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""A corrupt WAL-HEADER file must be rejected without creating
|
||||
-shm sidecars from the read-only preflight itself.
|
||||
|
||||
The immutable=1 probe URI is what guarantees a read-only
|
||||
connection creates no sidecars even for a WAL-header database; the
|
||||
sidecar assertions after the call prove it.
|
||||
"""
|
||||
db_path = tmp_path / "nestquest.db"
|
||||
# WAL fixture: created via the wrapper (journal_mode=WAL persists).
|
||||
_valid_database(db_path)
|
||||
data = bytearray(db_path.read_bytes())
|
||||
# Corrupt the WAL header flag region: bytes 18/19 hold the
|
||||
# read/write version numbers (2 = WAL). Flip them to garbage so
|
||||
# the file claims a broken journal mode.
|
||||
assert len(data) > 64
|
||||
data[18] = 0x99
|
||||
data[19] = 0x99
|
||||
db_path.write_bytes(bytes(data))
|
||||
corrupt_bytes = db_path.read_bytes()
|
||||
digest = hashlib.sha256(corrupt_bytes).hexdigest()
|
||||
|
||||
with pytest.raises(ConfigEntryNotReady):
|
||||
_run(_async_open_database(_make_hass_mock(), db_path))
|
||||
|
||||
# Main file byte-identical; no -shm appeared from the probe.
|
||||
assert hashlib.sha256(db_path.read_bytes()).hexdigest() == digest
|
||||
assert not (tmp_path / "nestquest.db-shm").exists()
|
||||
|
||||
|
||||
def test_open_failure_closes_connection_when_opened(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""A failure DURING MIGRATIONS closes the opened connection; a
|
||||
failure inside open() itself has nothing to close (the wrapper
|
||||
never adopted a connection), so close must not be called there.
|
||||
"""
|
||||
db_path = tmp_path / "nestquest.db"
|
||||
_truncated_database(db_path)
|
||||
|
||||
close_calls: list[bool] = []
|
||||
original_close = NestQuestDatabase.close
|
||||
|
||||
async def _spy_close(self):
|
||||
close_calls.append(True)
|
||||
return await original_close(self)
|
||||
|
||||
monkeypatch.setattr(NestQuestDatabase, "close", _spy_close)
|
||||
with pytest.raises(ConfigEntryNotReady):
|
||||
_run(_async_open_database(_make_hass_mock(), db_path))
|
||||
# Corruption hit at PRAGMA time inside open(): no connection was
|
||||
# adopted, so there is nothing to close and close() must NOT have
|
||||
# been called (calling close on a never-opened wrapper would be
|
||||
# harmless but the helper deliberately skips it).
|
||||
assert close_calls == []
|
||||
|
||||
# Contrast: a failure DURING migrations (file opens fine) closes.
|
||||
close_calls.clear()
|
||||
valid_path = tmp_path / "migrations-fail.db"
|
||||
_valid_database(valid_path)
|
||||
# _valid_database's own close went through the spy; only the
|
||||
# failure path's close counts from here.
|
||||
close_calls.clear()
|
||||
from custom_components.nestquest import migrations as migrations_mod
|
||||
from custom_components.nestquest.db import NestQuestDatabase as _DB
|
||||
|
||||
async def _failing(database, migrations=None):
|
||||
raise sqlite3.DatabaseError("database disk image is malformed")
|
||||
|
||||
monkeypatch.setattr(migrations_mod, "apply_migrations", _failing)
|
||||
monkeypatch.setattr(
|
||||
"custom_components.nestquest.apply_migrations", _failing
|
||||
)
|
||||
with pytest.raises(ConfigEntryNotReady) as excinfo:
|
||||
_run(_async_open_database(_make_hass_mock(), valid_path))
|
||||
# Exactly one close: the migrations-failure cleanup path. (The
|
||||
# spy also sees the wrapper's own idempotent close if it were
|
||||
# double-called — it must not be.)
|
||||
assert close_calls == [True], (
|
||||
"an open connection must be closed before the error propagates"
|
||||
)
|
||||
assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Startup through async_setup_entry (the real wiring)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_setup(hass, registry):
|
||||
def _setup(entry):
|
||||
return async_setup_entry(hass, entry)
|
||||
|
||||
return _setup
|
||||
|
||||
|
||||
async def _setup_entry(hass, entry, registry) -> object:
|
||||
entry = _wire(entry, registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
return entry
|
||||
|
||||
|
||||
async def test_setup_with_corrupt_file_raises_not_ready(
|
||||
hass, make_entry, monkeypatch, tmp_path, caplog
|
||||
) -> None:
|
||||
from custom_components.nestquest.store import async_get_db_path
|
||||
|
||||
db_path = await async_get_db_path(hass)
|
||||
await _avalid_database(db_path)
|
||||
data = db_path.read_bytes()
|
||||
assert len(data) > 64
|
||||
db_path.write_bytes(data[: len(data) // 2])
|
||||
corrupt_bytes = db_path.read_bytes()
|
||||
|
||||
entry = make_entry()
|
||||
with pytest.raises(ConfigEntryNotReady):
|
||||
await _setup_entry(hass, entry, hass.registry)
|
||||
# File untouched, no runtime data stored.
|
||||
assert db_path.read_bytes() == corrupt_bytes
|
||||
assert hass.data["nestquest"].get(entry.entry_id) is None
|
||||
|
||||
|
||||
async def test_setup_with_missing_file_creates_and_loads(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry = await _setup_entry(hass, make_entry(), hass.registry)
|
||||
database = entry.runtime_data.database
|
||||
try:
|
||||
row = await database.fetch_one(
|
||||
f"SELECT version FROM {VERSION_TABLE}"
|
||||
)
|
||||
assert row == (1,)
|
||||
finally:
|
||||
await database.close()
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
|
||||
|
||||
async def test_setup_with_valid_file_preserves_rows(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry_a = await _setup_entry(hass, make_entry(), hass.registry)
|
||||
database_a = entry_a.runtime_data.database
|
||||
await database_a.execute(
|
||||
"INSERT INTO children (display_name, created_at) VALUES (?, ?)",
|
||||
("Ada", "2026-09-14T12:00:00+00:00"),
|
||||
)
|
||||
# Unload pops the runtime record AND closes the connection; a
|
||||
# fresh setup for the same entry_id then reopens the file.
|
||||
assert await async_unload_entry(hass, entry_a) is True
|
||||
|
||||
entry_b = await _setup_entry(hass, make_entry(), hass.registry)
|
||||
database_b = entry_b.runtime_data.database
|
||||
assert database_b is not database_a
|
||||
try:
|
||||
row = await database_b.fetch_one("SELECT COUNT(*) FROM children")
|
||||
assert row == (1,)
|
||||
finally:
|
||||
await database_b.close()
|
||||
assert await async_unload_entry(hass, entry_b) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No code path deletes or overwrites an existing database file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_package_never_unlinks_or_truncates_db_files() -> None:
|
||||
"""Guardrail: no code path may delete or overwrite an existing
|
||||
database file. Scans the package for unlink/rmtree/write on the
|
||||
DB filename constant or *.db patterns.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
package = Path(
|
||||
__import__(
|
||||
"custom_components.nestquest", fromlist=["__file__"]
|
||||
).__file__
|
||||
).parent
|
||||
pattern = re.compile(
|
||||
r"(unlink|rmtree|write_bytes|write_text|open\s*\(\s*[^)]*['\"]w)"
|
||||
r"[^\n]*(nestquest\.db|\.db['\"])|"
|
||||
r"(nestquest\.db|\.db['\"])[^\n]*"
|
||||
r"(unlink|rmtree|write_bytes|write_text)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
offenders: list[str] = []
|
||||
for py in sorted(package.glob("*.py")):
|
||||
if pattern.search(py.read_text()):
|
||||
offenders.append(py.name)
|
||||
assert offenders == [], (
|
||||
f"code paths that could delete/overwrite the database: {offenders}"
|
||||
)
|
||||
+40
-2
@@ -101,12 +101,20 @@ def test_executor_helper_creates_directory(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_no_sqlite3_import_outside_db_module() -> None:
|
||||
"""sqlite3 is banned package-wide except db.py, the connection wrapper."""
|
||||
"""sqlite3 is banned package-wide except two justified modules.
|
||||
|
||||
db.py is the connection wrapper (all statement execution lives
|
||||
there). __init__.py imports sqlite3 ONLY for its exception types
|
||||
to classify corruption on startup (done-condition: corrupt file ->
|
||||
ConfigEntryNotReady); it never opens a connection or executes
|
||||
SQL — that is asserted separately below.
|
||||
"""
|
||||
pkg_dir = Path(store.__file__).parent
|
||||
py_files = list(pkg_dir.glob("*.py"))
|
||||
assert py_files
|
||||
allowed = {"db.py", "__init__.py"}
|
||||
for py_file in py_files:
|
||||
if py_file.name == "db.py":
|
||||
if py_file.name in allowed:
|
||||
continue
|
||||
tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
|
||||
for node in ast.walk(tree):
|
||||
@@ -121,6 +129,36 @@ def test_no_sqlite3_import_outside_db_module() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_init_module_never_opens_or_executes_sqlite() -> None:
|
||||
"""The __init__.py sqlite3 usage is limited to (a) exception typing
|
||||
for corruption classification and (b) the ONE read-only preflight
|
||||
connection (mode=ro URI, SELECT-only) that detects corruption in an
|
||||
existing file before the real read-write open. No read-write
|
||||
connect, no cursor writes, no non-SELECT statements may appear.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
init_path = (
|
||||
Path(store.__file__).parent / "__init__.py"
|
||||
).read_text(encoding="utf-8")
|
||||
# The preflight connect must be the URI read-only form, and only
|
||||
# SELECT statements and the integrity_check PRAGMA may run there.
|
||||
assert "sqlite3.connect(uri, uri=True)" in init_path
|
||||
assert "?mode=ro" in init_path
|
||||
assert 'PRAGMA integrity_check' in init_path
|
||||
forbidden = re.compile(
|
||||
r"sqlite3\s*\.\s*connect(?!\(\s*uri\s*,)"
|
||||
r"|\bconn(ection)?\s*\.\s*execute\s*\(\s*[\"'](?!SELECT\s|PRAGMA\s+integrity_check)"
|
||||
r"|\bexecutemany\b|\bexecutescript\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
assert forbidden.search(init_path) is None, (
|
||||
"__init__.py may use sqlite3 only for exception typing and the "
|
||||
"read-only corruption preflight (SELECT + integrity_check)"
|
||||
)
|
||||
|
||||
|
||||
def test_no_file_creation_calls_in_store() -> None:
|
||||
source = Path(store.__file__).read_text(encoding="utf-8")
|
||||
tree = ast.parse(source, filename=str(store.__file__))
|
||||
|
||||
Reference in New Issue
Block a user