Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfe1609df2 | ||
|
|
5c74034f81 | ||
|
|
1d247fbca1 | ||
|
|
d5d972b872 | ||
|
|
09363a57cf | ||
|
|
5578507ce8 | ||
|
|
712527d5ba | ||
|
|
61b77cc2db | ||
|
|
a2ea42673c | ||
|
|
82d1be97fa | ||
|
|
2263481f1e | ||
|
|
522875642d | ||
|
|
9eea769c7e | ||
|
|
b955ab487b | ||
|
|
fe90064b65 | ||
|
|
25f1479444 |
@@ -0,0 +1,246 @@
|
|||||||
|
"""Business layer for the children registry (Feature 03).
|
||||||
|
|
||||||
|
Sits between the Feature 09 service gate and the typed
|
||||||
|
:class:`~.dao_children.ChildrenDao`: callers get validation, name
|
||||||
|
policies and the duplicate-name warning here, and never talk to the
|
||||||
|
DAO or SQL directly. Children are never hard-deleted — deactivation
|
||||||
|
is a separate explicit operation (:func:`set_child_active`), so
|
||||||
|
completion history keeps its references (feature guardrail).
|
||||||
|
|
||||||
|
Name policy: the display name is required and trimmed; an empty or
|
||||||
|
whitespace-only name is rejected with ValueError. A duplicate display
|
||||||
|
name is ALLOWED (two children may legitimately share a nickname) but
|
||||||
|
logged as a warning so an accidental double-add is visible in the HA
|
||||||
|
log. The duplicate check is case-insensitive and whitespace-normalised
|
||||||
|
on both sides, because "ada" and "Ada " are the same name to a parent.
|
||||||
|
|
||||||
|
Colour and avatar are optional free-form strings, trimmed when present;
|
||||||
|
``sort_order`` is an int with the DAO/schema default of 0. Clearing an
|
||||||
|
optional field back to NULL is deliberately rejected: the DAO's update
|
||||||
|
writes only provided fields (it cannot express NULL), so the business
|
||||||
|
layer refuses an explicit ``None`` instead of silently ignoring it.
|
||||||
|
Timestamps follow the module timestamp policy: the business layer
|
||||||
|
stamps UTC ISO-8601 at second precision so every row it creates is
|
||||||
|
uniform.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .dao_children import ChildRecord, ChildrenDao, _connection_lock
|
||||||
|
from .db import NestQuestDatabase
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
|
||||||
|
#: in :func:`edit_child`.
|
||||||
|
_UNSET = object()
|
||||||
|
|
||||||
|
|
||||||
|
def _now_stamp() -> str:
|
||||||
|
"""Return the module's strict UTC second-precision stamp."""
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||||
|
timespec="seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_text(
|
||||||
|
value: object, field: str, *, required: bool = False
|
||||||
|
) -> str | None:
|
||||||
|
"""Validate a free-text field and return it trimmed.
|
||||||
|
|
||||||
|
``None`` raises when ``required`` and otherwise returns None. A
|
||||||
|
non-string value is rejected; an empty result is rejected for
|
||||||
|
required fields (whitespace-only names are empty to a parent).
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
if required:
|
||||||
|
raise ValueError(f"{field} is required")
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"{field} must be a string, got {value!r}")
|
||||||
|
trimmed = value.strip()
|
||||||
|
if required and not trimmed:
|
||||||
|
raise ValueError(f"{field} is required")
|
||||||
|
return trimmed
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_sort_order(value: object) -> int:
|
||||||
|
"""Reject non-int sort orders (bools included) before the DAO."""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ValueError(f"sort_order must be an integer, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def _warn_on_duplicate_name(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
display_name: str,
|
||||||
|
*,
|
||||||
|
exclude_child_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Log a warning when ``display_name`` duplicates an existing child.
|
||||||
|
|
||||||
|
Case-insensitive and whitespace-normalised on both sides. The
|
||||||
|
duplicate is still allowed — this only makes it visible; the log
|
||||||
|
line names both rows so an accidental double-add can be found.
|
||||||
|
``exclude_child_id`` removes the child being edited from the scan,
|
||||||
|
so an edit only warns when the result collides with a DIFFERENT
|
||||||
|
child's name — including case-only edits, where the self-comparison
|
||||||
|
would otherwise hide a real duplicate.
|
||||||
|
"""
|
||||||
|
lowered = display_name.casefold()
|
||||||
|
for child in await ChildrenDao(database).list_all():
|
||||||
|
if exclude_child_id is not None and child.id == exclude_child_id:
|
||||||
|
continue
|
||||||
|
if child.display_name.strip().casefold() == lowered:
|
||||||
|
LOGGER.warning(
|
||||||
|
"Child display name %r duplicates existing child %d "
|
||||||
|
"(%r); allowed, but check this was intentional",
|
||||||
|
display_name,
|
||||||
|
child.id,
|
||||||
|
child.display_name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
async def create_child(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
display_name: str,
|
||||||
|
*,
|
||||||
|
colour: str | None = None,
|
||||||
|
avatar_ref: str | None = None,
|
||||||
|
sort_order: int = 0,
|
||||||
|
) -> ChildRecord:
|
||||||
|
"""Create a child profile and return the stored record.
|
||||||
|
|
||||||
|
The display name is required, trimmed, and rejected when empty.
|
||||||
|
A duplicate display name (case-insensitive) is allowed but logged
|
||||||
|
as a warning. The scan and the INSERT run inside the
|
||||||
|
connection-scoped lock shared with the DAOs, so a concurrent
|
||||||
|
create of the same normalised name queues behind the first insert
|
||||||
|
and sees it — the duplicate still warns; it can never slip through
|
||||||
|
unlogged.
|
||||||
|
"""
|
||||||
|
name = _validate_text(display_name, "display_name", required=True)
|
||||||
|
assert name is not None
|
||||||
|
colour_value = _validate_text(colour, "colour")
|
||||||
|
avatar_value = _validate_text(avatar_ref, "avatar_ref")
|
||||||
|
order = _validate_sort_order(sort_order)
|
||||||
|
async with _connection_lock(database):
|
||||||
|
await _warn_on_duplicate_name(database, name)
|
||||||
|
created = await ChildrenDao(database).create(
|
||||||
|
name,
|
||||||
|
_now_stamp(),
|
||||||
|
colour=colour_value,
|
||||||
|
avatar_ref=avatar_value,
|
||||||
|
sort_order=order,
|
||||||
|
)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
async def edit_child(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
child_id: int,
|
||||||
|
*,
|
||||||
|
display_name: str | object = _UNSET,
|
||||||
|
colour: str | None | object = _UNSET,
|
||||||
|
avatar_ref: str | None | object = _UNSET,
|
||||||
|
sort_order: int | object = _UNSET,
|
||||||
|
) -> ChildRecord:
|
||||||
|
"""Edit a child profile and return the updated record.
|
||||||
|
|
||||||
|
Arguments default to the module sentinel ``_UNSET`` meaning "leave
|
||||||
|
this field alone". An explicitly passed ``None`` for an optional
|
||||||
|
field raises: the DAO's update writes only provided fields and
|
||||||
|
cannot express NULL, so a silent no-op would mask the caller's
|
||||||
|
intent (see the module docstring). A provided display name is
|
||||||
|
validated like create's; the duplicate scan excludes only the child
|
||||||
|
being edited, so a case-only edit colliding with another child's
|
||||||
|
normalised name still warns. Raises ValueError when the child does
|
||||||
|
not exist.
|
||||||
|
|
||||||
|
Atomicity: the existence check, the duplicate scan, the UPDATE and
|
||||||
|
the readback all run inside the connection-scoped lock shared with
|
||||||
|
the DAOs, so another edit cannot slip between this call's UPDATE
|
||||||
|
and its readback — the returned record is exactly the one this
|
||||||
|
edit produced, not a later writer's.
|
||||||
|
"""
|
||||||
|
async with _connection_lock(database):
|
||||||
|
dao = ChildrenDao(database)
|
||||||
|
existing = await dao.get(child_id)
|
||||||
|
if existing is None:
|
||||||
|
raise ValueError(f"child {child_id} does not exist")
|
||||||
|
updates: dict[str, object] = {}
|
||||||
|
if display_name is not _UNSET:
|
||||||
|
name = _validate_text(
|
||||||
|
display_name, "display_name", required=True
|
||||||
|
)
|
||||||
|
assert name is not None
|
||||||
|
await _warn_on_duplicate_name(
|
||||||
|
database, name, exclude_child_id=child_id
|
||||||
|
)
|
||||||
|
updates["display_name"] = name
|
||||||
|
if colour is not _UNSET:
|
||||||
|
value = _validate_text(colour, "colour")
|
||||||
|
if value is None:
|
||||||
|
raise ValueError(
|
||||||
|
"clearing colour is not supported; pass a replacement "
|
||||||
|
"value or omit the field"
|
||||||
|
)
|
||||||
|
updates["colour"] = value
|
||||||
|
if avatar_ref is not _UNSET:
|
||||||
|
value = _validate_text(avatar_ref, "avatar_ref")
|
||||||
|
if value is None:
|
||||||
|
raise ValueError(
|
||||||
|
"clearing avatar_ref is not supported; pass a "
|
||||||
|
"replacement value or omit the field"
|
||||||
|
)
|
||||||
|
updates["avatar_ref"] = value
|
||||||
|
if sort_order is not _UNSET:
|
||||||
|
updates["sort_order"] = _validate_sort_order(sort_order)
|
||||||
|
if updates:
|
||||||
|
await dao.update(child_id, **updates)
|
||||||
|
edited = await dao.get(child_id)
|
||||||
|
assert edited is not None
|
||||||
|
return edited
|
||||||
|
|
||||||
|
|
||||||
|
async def set_child_active(
|
||||||
|
database: NestQuestDatabase, child_id: int, is_active: bool
|
||||||
|
) -> ChildRecord:
|
||||||
|
"""Deactivate or reactivate a child; returns the updated record.
|
||||||
|
|
||||||
|
Deactivation hides the child from the panel without deleting their
|
||||||
|
history (feature guardrail); this is the only removal path.
|
||||||
|
``is_active`` must be a real bool: the DAO's ``int()`` would
|
||||||
|
otherwise silently coerce strings and numerics ("1", 0) into a
|
||||||
|
state the caller never asked for. Raises ValueError when the
|
||||||
|
child does not exist.
|
||||||
|
|
||||||
|
Atomicity: existence check, UPDATE and readback run inside the
|
||||||
|
connection-scoped lock, so a concurrent opposite transition cannot
|
||||||
|
make this call report the other caller's value.
|
||||||
|
"""
|
||||||
|
async with _connection_lock(database):
|
||||||
|
dao = ChildrenDao(database)
|
||||||
|
if await dao.get(child_id) is None:
|
||||||
|
raise ValueError(f"child {child_id} does not exist")
|
||||||
|
if not isinstance(is_active, bool):
|
||||||
|
raise ValueError(
|
||||||
|
f"is_active must be a real bool, got {is_active!r}"
|
||||||
|
)
|
||||||
|
await dao.set_active(child_id, is_active)
|
||||||
|
updated = await dao.get(child_id)
|
||||||
|
assert updated is not None
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
async def list_children(
|
||||||
|
database: NestQuestDatabase, *, active_only: bool = False
|
||||||
|
) -> list[ChildRecord]:
|
||||||
|
"""Return children in display order (sort_order, then id)."""
|
||||||
|
dao = ChildrenDao(database)
|
||||||
|
if active_only:
|
||||||
|
return await dao.list_active()
|
||||||
|
return await dao.list_all()
|
||||||
@@ -15,13 +15,13 @@ event. A package-wide guard test (bottom of the test module) keeps
|
|||||||
mutation SQL for this table out of every other module.
|
mutation SQL for this table out of every other module.
|
||||||
|
|
||||||
Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on
|
Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on
|
||||||
(definition_id, due_date) — re-running materialization for a date
|
(definition_id, child_id, due_date, window) — re-running
|
||||||
whose instance already exists updates nothing that matters
|
materialization for a tuple whose instance already exists refreshes
|
||||||
(child_id/due_time are refreshed only in the degenerate re-generation
|
only the generation-time snapshot columns and never duplicates a row.
|
||||||
case where they changed) and never duplicates a row. The schema's
|
The schema's UNIQUE(definition_id, child_id, due_date, window) is the
|
||||||
UNIQUE(definition_id, due_date) is the last line of defense. The
|
last line of defense. The upsert refuses a child that is not an
|
||||||
upsert refuses a child that is not an assignee of the definition
|
assignee of the definition and a window outside ``const.QUEST_WINDOWS``
|
||||||
(D-008 multi-assignee model).
|
(D-008 multi-assignee, multi-window model).
|
||||||
|
|
||||||
``delete_future_uncompleted`` implements the Feature 06/07 rule that
|
``delete_future_uncompleted`` implements the Feature 06/07 rule that
|
||||||
reassignment and schedule edits regenerate future instances: it
|
reassignment and schedule edits regenerate future instances: it
|
||||||
@@ -37,7 +37,7 @@ import datetime
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from .dao_children import _connection_lock
|
from .dao_children import _connection_lock
|
||||||
from .dao_rules import _validate_date
|
from .dao_rules import _validate_date, _validate_window
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase
|
||||||
|
|
||||||
#: Timestamp policy for completion_events.occurred_at: strict UTC
|
#: Timestamp policy for completion_events.occurred_at: strict UTC
|
||||||
@@ -90,6 +90,7 @@ class QuestInstanceRecord:
|
|||||||
id: int
|
id: int
|
||||||
definition_id: int
|
definition_id: int
|
||||||
child_id: int
|
child_id: int
|
||||||
|
window: str
|
||||||
due_date: str
|
due_date: str
|
||||||
due_time: str | None
|
due_time: str | None
|
||||||
generated_at: str
|
generated_at: str
|
||||||
@@ -105,16 +106,17 @@ class CompletionEventRecord:
|
|||||||
event_type: str
|
event_type: str
|
||||||
actor_source: str
|
actor_source: str
|
||||||
actor_user_id: str | None
|
actor_user_id: str | None
|
||||||
|
actor_child_id: int | None
|
||||||
occurred_at: str
|
occurred_at: str
|
||||||
was_on_time: bool | None
|
was_on_time: bool | None
|
||||||
|
|
||||||
|
|
||||||
_INSTANCE_COLUMNS = (
|
_INSTANCE_COLUMNS = (
|
||||||
"id, definition_id, child_id, due_date, due_time, generated_at"
|
"id, definition_id, child_id, window, due_date, due_time, generated_at"
|
||||||
)
|
)
|
||||||
_EVENT_COLUMNS = (
|
_EVENT_COLUMNS = (
|
||||||
"id, instance_id, child_id, event_type, actor_source, actor_user_id, "
|
"id, instance_id, child_id, event_type, actor_source, actor_user_id, "
|
||||||
"occurred_at, was_on_time"
|
"actor_child_id, occurred_at, was_on_time"
|
||||||
)
|
)
|
||||||
|
|
||||||
#: The only event types the append path accepts; the schema CHECK is
|
#: The only event types the append path accepts; the schema CHECK is
|
||||||
@@ -128,9 +130,10 @@ def _instance_from_row(row: tuple) -> QuestInstanceRecord:
|
|||||||
id=row[0],
|
id=row[0],
|
||||||
definition_id=row[1],
|
definition_id=row[1],
|
||||||
child_id=row[2],
|
child_id=row[2],
|
||||||
due_date=row[3],
|
window=row[3],
|
||||||
due_time=row[4],
|
due_date=row[4],
|
||||||
generated_at=row[5],
|
due_time=row[5],
|
||||||
|
generated_at=row[6],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -142,8 +145,9 @@ def _event_from_row(row: tuple) -> CompletionEventRecord:
|
|||||||
event_type=row[3],
|
event_type=row[3],
|
||||||
actor_source=row[4],
|
actor_source=row[4],
|
||||||
actor_user_id=row[5],
|
actor_user_id=row[5],
|
||||||
occurred_at=row[6],
|
actor_child_id=row[6],
|
||||||
was_on_time=None if row[7] is None else bool(row[7]),
|
occurred_at=row[7],
|
||||||
|
was_on_time=None if row[8] is None else bool(row[8]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -160,31 +164,40 @@ class QuestInstancesDao:
|
|||||||
due_date: str,
|
due_date: str,
|
||||||
generated_at: str,
|
generated_at: str,
|
||||||
*,
|
*,
|
||||||
|
window: str,
|
||||||
due_time: str | None = None,
|
due_time: str | None = None,
|
||||||
) -> QuestInstanceRecord:
|
) -> QuestInstanceRecord:
|
||||||
"""Create the dated instance for (definition_id, due_date) idempotently.
|
"""Create the dated instance for the widened key idempotently.
|
||||||
|
|
||||||
Running materialization twice for the same definition/date
|
The instance key is (definition_id, child_id, due_date, window)
|
||||||
yields ONE row: the ON CONFLICT path refreshes the
|
(D-008): running materialization twice for the same tuple
|
||||||
generation-time snapshot columns (child_id, due_time) and
|
yields ONE row — the ON CONFLICT path refreshes the
|
||||||
regenerates the stamp, but can never create a duplicate — the
|
generation-time snapshot columns (due_time) and regenerates the
|
||||||
schema UNIQUE(definition_id, due_date) backs this up.
|
stamp, but can never create a duplicate. The schema's
|
||||||
|
UNIQUE(definition_id, child_id, due_date, window) backs this
|
||||||
|
up, so the twice-daily case (two windows, one child, one date)
|
||||||
|
yields two rows and the shared case (three assignees, one
|
||||||
|
window) yields three.
|
||||||
|
|
||||||
Guardrails enforced here:
|
Guardrails enforced here:
|
||||||
|
|
||||||
- ``due_date`` must be TODAY or later: instances are never
|
- ``due_date`` must be TODAY or later: instances are never
|
||||||
generated in the past (Feature 07). The caller's
|
generated in the past (Feature 07). The caller's
|
||||||
materialization horizon is future-dated by definition.
|
materialization horizon is future-dated by definition.
|
||||||
|
- ``window`` must be one of ``const.QUEST_WINDOWS``.
|
||||||
- An existing instance WITH a completion event is IMMUTABLE:
|
- An existing instance WITH a completion event is IMMUTABLE:
|
||||||
the conflict path must not rewrite its snapshot columns
|
the conflict path must not rewrite its snapshot columns
|
||||||
(Feature 07/08). The upsert refuses with ValueError in that
|
(Feature 07/08). The upsert refuses with ValueError in that
|
||||||
case — silently no-oping would mask a materialization bug.
|
case — silently no-oping would mask a materialization bug.
|
||||||
|
- The child must be an assignee of the definition (D-008
|
||||||
|
multi-assignee model).
|
||||||
|
|
||||||
The definition must exist (validated atomically under the
|
The definition must exist (validated atomically under the
|
||||||
connection lock so a concurrent definition deactivation or
|
connection lock so a concurrent definition deactivation or
|
||||||
child deletion cannot slip between check and insert).
|
child deletion cannot slip between check and insert).
|
||||||
"""
|
"""
|
||||||
_validate_date(due_date, "due_date")
|
_validate_date(due_date, "due_date")
|
||||||
|
_validate_window(window)
|
||||||
# Fail fast on obviously-past dates BEFORE queuing on the
|
# Fail fast on obviously-past dates BEFORE queuing on the
|
||||||
# connection lock (a nice early error); the AUTHORITATIVE
|
# connection lock (a nice early error); the AUTHORITATIVE
|
||||||
# check runs after the lock is acquired, because a caller can
|
# check runs after the lock is acquired, because a caller can
|
||||||
@@ -227,51 +240,56 @@ class QuestInstancesDao:
|
|||||||
f"assigned to child {child_id}; instance must "
|
f"assigned to child {child_id}; instance must "
|
||||||
"carry an assigned child"
|
"carry an assigned child"
|
||||||
)
|
)
|
||||||
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(
|
existing = await self._database.fetch_one(
|
||||||
"SELECT 1 FROM completion_events "
|
"SELECT 1 FROM completion_events "
|
||||||
"WHERE instance_id = (SELECT id FROM quest_instances "
|
"WHERE instance_id = (SELECT id FROM quest_instances "
|
||||||
"WHERE definition_id = ? AND due_date = ?)",
|
"WHERE definition_id = ? AND child_id = ? "
|
||||||
(definition_id, due_date),
|
"AND due_date = ? AND window = ?)",
|
||||||
|
(definition_id, child_id, due_date, window),
|
||||||
)
|
)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"instance for (definition {definition_id}, "
|
f"instance for (definition {definition_id}, "
|
||||||
f"due_date {due_date!r}) already has a completion "
|
f"child {child_id}, due_date {due_date!r}, "
|
||||||
|
f"window {window!r}) already has a completion "
|
||||||
"event and is immutable; it cannot be regenerated"
|
"event and is immutable; it cannot be regenerated"
|
||||||
)
|
)
|
||||||
await self._database.execute(
|
await self._database.execute(
|
||||||
"INSERT INTO quest_instances (definition_id, child_id, "
|
"INSERT INTO quest_instances (definition_id, child_id, "
|
||||||
"due_date, due_time, generated_at) "
|
"window, due_date, due_time, generated_at) "
|
||||||
"VALUES (?, ?, ?, ?, ?) "
|
"VALUES (?, ?, ?, ?, ?, ?) "
|
||||||
"ON CONFLICT (definition_id, due_date) DO UPDATE SET "
|
"ON CONFLICT (definition_id, child_id, due_date, "
|
||||||
"child_id = excluded.child_id, "
|
"window) DO UPDATE SET "
|
||||||
"due_time = excluded.due_time, "
|
"due_time = excluded.due_time, "
|
||||||
"generated_at = excluded.generated_at",
|
"generated_at = excluded.generated_at",
|
||||||
(
|
(
|
||||||
definition_id,
|
definition_id,
|
||||||
child_id,
|
child_id,
|
||||||
|
window,
|
||||||
due_date,
|
due_date,
|
||||||
due_time,
|
due_time,
|
||||||
generated_at,
|
generated_at,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
instance = await self.get(definition_id, due_date)
|
instance = await self.get(
|
||||||
|
definition_id, child_id, due_date, window
|
||||||
|
)
|
||||||
assert instance is not None
|
assert instance is not None
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
async def get(
|
async def get(
|
||||||
self, definition_id: int, due_date: str
|
self,
|
||||||
|
definition_id: int,
|
||||||
|
child_id: int,
|
||||||
|
due_date: str,
|
||||||
|
window: str,
|
||||||
) -> QuestInstanceRecord | None:
|
) -> QuestInstanceRecord | None:
|
||||||
"""Return the instance for (definition_id, due_date), or None."""
|
"""Return the instance for the widened key tuple, or None."""
|
||||||
row = await self._database.fetch_one(
|
row = await self._database.fetch_one(
|
||||||
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
|
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
|
||||||
"WHERE definition_id = ? AND due_date = ?",
|
"WHERE definition_id = ? AND child_id = ? AND due_date = ? "
|
||||||
(definition_id, due_date),
|
"AND window = ?",
|
||||||
|
(definition_id, child_id, due_date, window),
|
||||||
)
|
)
|
||||||
return _instance_from_row(row) if row is not None else None
|
return _instance_from_row(row) if row is not None else None
|
||||||
|
|
||||||
@@ -380,19 +398,21 @@ class CompletionEventsDao:
|
|||||||
was_on_time: bool | None,
|
was_on_time: bool | None,
|
||||||
*,
|
*,
|
||||||
actor_user_id: str | None = None,
|
actor_user_id: str | None = None,
|
||||||
|
actor_child_id: int | None = None,
|
||||||
) -> CompletionEventRecord:
|
) -> CompletionEventRecord:
|
||||||
"""Append one completion or un-completion event.
|
"""Append one completion or un-completion event.
|
||||||
|
|
||||||
``actor_source`` is 'user' (with ``actor_user_id`` set) or
|
``actor_source`` is 'user' (an admin action: ``actor_user_id``
|
||||||
'panel' (no user id — the tapped child is ``child_id``); the
|
set, ``actor_child_id`` NULL) or 'panel' (a panel tap: no user
|
||||||
actor-pair CHECK enforces this shape at the schema level.
|
id, and ``actor_child_id`` carrying the tapped child profile,
|
||||||
``occurred_at`` must be the module's one strict UTC shape
|
D-008); the actor-pair CHECKs enforce this shape at the schema
|
||||||
(YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range queries
|
level. ``occurred_at`` must be the module's one strict UTC
|
||||||
stay exact. ``was_on_time`` is required (True/False) for
|
shape (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range
|
||||||
completions; for un-completions it may be None or the reversed
|
queries stay exact. ``was_on_time`` is required (True/False)
|
||||||
completion's flag (Feature 08 still deciding). The instance
|
for completions; for un-completions it may be None or the
|
||||||
and child must exist — validated atomically under the
|
reversed completion's flag (Feature 08 still deciding). The
|
||||||
connection lock.
|
instance, child and — for panel events — the tapped child must
|
||||||
|
exist — validated atomically under the connection lock.
|
||||||
"""
|
"""
|
||||||
if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
|
if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -412,6 +432,15 @@ class CompletionEventsDao:
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
"actor_source 'panel' must not carry actor_user_id"
|
"actor_source 'panel' must not carry actor_user_id"
|
||||||
)
|
)
|
||||||
|
if actor_source == "panel" and actor_child_id is None:
|
||||||
|
raise ValueError(
|
||||||
|
"actor_source 'panel' requires actor_child_id "
|
||||||
|
"(the tapped child profile)"
|
||||||
|
)
|
||||||
|
if actor_source == "user" and actor_child_id is not None:
|
||||||
|
raise ValueError(
|
||||||
|
"actor_source 'user' must not carry actor_child_id"
|
||||||
|
)
|
||||||
if was_on_time is not None and type(was_on_time) is not bool:
|
if was_on_time is not None and type(was_on_time) is not bool:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"was_on_time must be True, False or None, got "
|
f"was_on_time must be True, False or None, got "
|
||||||
@@ -442,16 +471,28 @@ class CompletionEventsDao:
|
|||||||
)
|
)
|
||||||
if child is None:
|
if child is None:
|
||||||
raise ValueError(f"child {child_id} does not exist")
|
raise ValueError(f"child {child_id} does not exist")
|
||||||
|
if actor_child_id is not None:
|
||||||
|
tapped = await self._database.fetch_one(
|
||||||
|
"SELECT 1 FROM children WHERE id = ?",
|
||||||
|
(actor_child_id,),
|
||||||
|
)
|
||||||
|
if tapped is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"actor_child_id {actor_child_id} does not "
|
||||||
|
"reference an existing child"
|
||||||
|
)
|
||||||
result = await self._database.execute(
|
result = await self._database.execute(
|
||||||
"INSERT INTO completion_events (instance_id, child_id, "
|
"INSERT INTO completion_events (instance_id, child_id, "
|
||||||
"event_type, actor_source, actor_user_id, occurred_at, "
|
"event_type, actor_source, actor_user_id, "
|
||||||
"was_on_time) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
"actor_child_id, occurred_at, was_on_time) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
(
|
(
|
||||||
instance_id,
|
instance_id,
|
||||||
child_id,
|
child_id,
|
||||||
event_type,
|
event_type,
|
||||||
actor_source,
|
actor_source,
|
||||||
actor_user_id,
|
actor_user_id,
|
||||||
|
actor_child_id,
|
||||||
occurred_at,
|
occurred_at,
|
||||||
None if was_on_time is None else int(was_on_time),
|
None if was_on_time is None else int(was_on_time),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -24,9 +24,13 @@ names before anything else assumes the new model. Migration 3 brings
|
|||||||
pre-D-008 databases to the multi-assignee model: it creates
|
pre-D-008 databases to the multi-assignee model: it creates
|
||||||
``quest_definition_assignees`` and, when ``quest_definitions`` still
|
``quest_definition_assignees`` and, when ``quest_definitions`` still
|
||||||
carries a single ``child_id`` column, rebuilds the table without it,
|
carries a single ``child_id`` column, rebuilds the table without it,
|
||||||
copying each definition's assignee across. Each callable migration
|
copying each definition's assignee across. Migration 4 adds the
|
||||||
runs inside its own transaction together with its version stamp, so a
|
windows table, migration 5 rebuilds ``quest_instances`` onto the
|
||||||
crash mid-step rolls the statements and the stamp back together.
|
widened (definition_id, child_id, due_date, window) key, and migration
|
||||||
|
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
|
||||||
|
column. Each callable migration runs inside its own transaction
|
||||||
|
together with its version stamp, so a crash mid-step rolls the
|
||||||
|
statements and the stamp back together.
|
||||||
|
|
||||||
Migration entries are either an ordered sequence of SQL statements or
|
Migration entries are either an ordered sequence of SQL statements or
|
||||||
an async callable taking ``(database, target_version)``. Callables
|
an async callable taking ``(database, target_version)``. Callables
|
||||||
@@ -70,7 +74,9 @@ from typing import Union
|
|||||||
|
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase
|
||||||
from .schema import (
|
from .schema import (
|
||||||
|
COMPLETION_EVENTS_TABLE_SQL,
|
||||||
QUEST_DEFINITIONS_TABLE_SQL,
|
QUEST_DEFINITIONS_TABLE_SQL,
|
||||||
|
QUEST_INSTANCES_TABLE_SQL,
|
||||||
SCHEMA_V1_STATEMENTS,
|
SCHEMA_V1_STATEMENTS,
|
||||||
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
|
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
|
||||||
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
||||||
@@ -147,6 +153,14 @@ async def _table_has_column(
|
|||||||
return any(row[1] == column for row in rows)
|
return any(row[1] == column for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
#: Window assigned to instances migrated from the pre-window instance
|
||||||
|
#: shape (D-008). Pre-window rows were generated for whole-day
|
||||||
|
#: obligations; the morning window is the documented default landing
|
||||||
|
#: spot, and future dates are regenerated with real window declarations
|
||||||
|
#: by materialization (delete_future_uncompleted + re-run).
|
||||||
|
LEGACY_MIGRATED_WINDOW = "morning"
|
||||||
|
|
||||||
|
|
||||||
async def _rename_legacy_task_tables(
|
async def _rename_legacy_task_tables(
|
||||||
database: NestQuestDatabase, target_version: int
|
database: NestQuestDatabase, target_version: int
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -250,6 +264,138 @@ async def _add_definition_assignees(
|
|||||||
await database.execute("PRAGMA foreign_keys = ON")
|
await database.execute("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
|
|
||||||
|
async def _widen_instance_key(
|
||||||
|
database: NestQuestDatabase, target_version: int
|
||||||
|
) -> None:
|
||||||
|
"""Bring ``quest_instances`` to the D-008 instance key.
|
||||||
|
|
||||||
|
Databases whose instance table still carries the pre-window shape
|
||||||
|
(no ``window`` column, UNIQUE on (definition_id, due_date)) are
|
||||||
|
rebuilt via SQLite's canonical procedure into the widened shape —
|
||||||
|
UNIQUE (definition_id, child_id, due_date, window) — with every
|
||||||
|
migrated row landing in :data:`LEGACY_MIGRATED_WINDOW` so existing
|
||||||
|
ids (and the completion events referencing them) survive intact.
|
||||||
|
The old rows were unique per (definition, due_date), so pinning one
|
||||||
|
window cannot collide with the widened key. Fresh databases whose
|
||||||
|
table already matches the canonical shape only stamp.
|
||||||
|
|
||||||
|
The rebuild runs with ``foreign_keys = OFF`` (dropping the old
|
||||||
|
table under enforcement would fire the implicit DELETE and violate
|
||||||
|
the ``completion_events`` foreign key) and verifies
|
||||||
|
``PRAGMA foreign_key_check`` inside the transaction before the
|
||||||
|
stamp, so a corrupt copy aborts the whole migration.
|
||||||
|
"""
|
||||||
|
legacy_shape = not await _table_has_column(
|
||||||
|
database, "quest_instances", "window"
|
||||||
|
)
|
||||||
|
await database.execute("PRAGMA foreign_keys = OFF")
|
||||||
|
try:
|
||||||
|
async with database.transaction():
|
||||||
|
if legacy_shape:
|
||||||
|
await database.execute(
|
||||||
|
"CREATE TABLE quest_instances_rebuilt "
|
||||||
|
f"{QUEST_INSTANCES_TABLE_SQL}"
|
||||||
|
)
|
||||||
|
await database.execute(
|
||||||
|
"INSERT INTO quest_instances_rebuilt "
|
||||||
|
"(id, definition_id, child_id, window, due_date, "
|
||||||
|
"due_time, generated_at) "
|
||||||
|
"SELECT id, definition_id, child_id, ?, due_date, "
|
||||||
|
"due_time, generated_at "
|
||||||
|
"FROM quest_instances ORDER BY id",
|
||||||
|
(LEGACY_MIGRATED_WINDOW,),
|
||||||
|
)
|
||||||
|
await database.execute("DROP TABLE quest_instances")
|
||||||
|
await database.execute(
|
||||||
|
"ALTER TABLE quest_instances_rebuilt "
|
||||||
|
"RENAME TO quest_instances"
|
||||||
|
)
|
||||||
|
violations = await database.fetch_all(
|
||||||
|
"PRAGMA foreign_key_check"
|
||||||
|
)
|
||||||
|
if violations:
|
||||||
|
raise RuntimeError(
|
||||||
|
"instance rebuild produced foreign-key "
|
||||||
|
f"violations: {violations!r}"
|
||||||
|
)
|
||||||
|
LOGGER.info(
|
||||||
|
"Widened quest_instances key; migrated rows pinned "
|
||||||
|
"to the %r window",
|
||||||
|
LEGACY_MIGRATED_WINDOW,
|
||||||
|
)
|
||||||
|
await stamp_schema_version(database, target_version)
|
||||||
|
finally:
|
||||||
|
await database.execute("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
|
|
||||||
|
async def _add_actor_child_id(
|
||||||
|
database: NestQuestDatabase, target_version: int
|
||||||
|
) -> None:
|
||||||
|
"""Bring ``completion_events`` to the D-008 actor-child shape.
|
||||||
|
|
||||||
|
The ``actor_child_id`` column records which panel profile was
|
||||||
|
tapped; it cannot be added with ``ALTER TABLE ADD COLUMN`` alone
|
||||||
|
because the canonical shape also carries an actor-pair coherence
|
||||||
|
CHECK (panel events require it, user events forbid it), and CHECK
|
||||||
|
constraints cannot be added to an existing table. Databases with
|
||||||
|
the legacy shape are therefore rebuilt via SQLite's canonical
|
||||||
|
procedure, backfilling each legacy panel row's ``actor_child_id``
|
||||||
|
to ``child_id`` — pre-D-008 panel completions were always recorded
|
||||||
|
against the tapped child themselves — while admin rows stay NULL.
|
||||||
|
Row ids are preserved so ``quest_instances`` references and the
|
||||||
|
append-only history stay intact. Fresh databases already match
|
||||||
|
the canonical shape and only stamp.
|
||||||
|
|
||||||
|
The rebuild runs with ``foreign_keys = OFF`` and verifies
|
||||||
|
``PRAGMA foreign_key_check`` inside the transaction before the
|
||||||
|
stamp. The copy is an INSERT..SELECT, not an UPDATE: the
|
||||||
|
completion_events table keeps its append-only guarantee.
|
||||||
|
"""
|
||||||
|
legacy_shape = not await _table_has_column(
|
||||||
|
database, "completion_events", "actor_child_id"
|
||||||
|
)
|
||||||
|
await database.execute("PRAGMA foreign_keys = OFF")
|
||||||
|
try:
|
||||||
|
async with database.transaction():
|
||||||
|
if legacy_shape:
|
||||||
|
await database.execute(
|
||||||
|
"CREATE TABLE completion_events_rebuilt "
|
||||||
|
f"{COMPLETION_EVENTS_TABLE_SQL}"
|
||||||
|
)
|
||||||
|
await database.execute(
|
||||||
|
"INSERT INTO completion_events_rebuilt "
|
||||||
|
"(id, instance_id, child_id, event_type, actor_source, "
|
||||||
|
"actor_user_id, actor_child_id, occurred_at, "
|
||||||
|
"was_on_time) "
|
||||||
|
"SELECT id, instance_id, child_id, event_type, "
|
||||||
|
"actor_source, actor_user_id, "
|
||||||
|
"CASE WHEN actor_source = 'panel' THEN child_id "
|
||||||
|
"ELSE NULL END, "
|
||||||
|
"occurred_at, was_on_time "
|
||||||
|
"FROM completion_events ORDER BY id"
|
||||||
|
)
|
||||||
|
await database.execute("DROP TABLE completion_events")
|
||||||
|
await database.execute(
|
||||||
|
"ALTER TABLE completion_events_rebuilt "
|
||||||
|
"RENAME TO completion_events"
|
||||||
|
)
|
||||||
|
violations = await database.fetch_all(
|
||||||
|
"PRAGMA foreign_key_check"
|
||||||
|
)
|
||||||
|
if violations:
|
||||||
|
raise RuntimeError(
|
||||||
|
"completion_events rebuild produced foreign-key "
|
||||||
|
f"violations: {violations!r}"
|
||||||
|
)
|
||||||
|
LOGGER.info(
|
||||||
|
"Added actor_child_id to completion_events; legacy "
|
||||||
|
"panel rows backfilled to their own child_id"
|
||||||
|
)
|
||||||
|
await stamp_schema_version(database, target_version)
|
||||||
|
finally:
|
||||||
|
await database.execute("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
|
|
||||||
#: The ordered migration list. Append-only: never edit an applied
|
#: The ordered migration list. Append-only: never edit an applied
|
||||||
#: entry, add the next one instead. (Migration 1's content was
|
#: entry, add the next one instead. (Migration 1's content was
|
||||||
#: rewritten pre-release per D-007 — no version 1 database shipped.)
|
#: rewritten pre-release per D-007 — no version 1 database shipped.)
|
||||||
@@ -261,6 +407,13 @@ MIGRATIONS: Sequence[MigrationStep] = [
|
|||||||
# CREATE IF NOT EXISTS suffices for both fresh databases (which
|
# CREATE IF NOT EXISTS suffices for both fresh databases (which
|
||||||
# already made it in migration 1) and pre-window databases.
|
# already made it in migration 1) and pre-window databases.
|
||||||
list(SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL),
|
list(SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL),
|
||||||
|
# Migration 5 (D-008): rebuild quest_instances onto the widened
|
||||||
|
# (definition_id, child_id, due_date, window) key.
|
||||||
|
_widen_instance_key,
|
||||||
|
# Migration 6 (D-008): rebuild completion_events with the
|
||||||
|
# actor_child_id column and its actor-pair coherence CHECK,
|
||||||
|
# backfilling legacy panel rows to their own child_id.
|
||||||
|
_add_actor_child_id,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -109,25 +109,31 @@ status column. An instance's current state derives from the latest row
|
|||||||
in ``completion_events`` (Feature 08), so materialization (Feature 07)
|
in ``completion_events`` (Feature 08), so materialization (Feature 07)
|
||||||
can insert instances without knowing anything about completion and the
|
can insert instances without knowing anything about completion and the
|
||||||
append-only log stays the single source of truth. ``definition_id``,
|
append-only log stays the single source of truth. ``definition_id``,
|
||||||
``due_date`` and ``child_id`` snapshot the materialization-time facts
|
``child_id``, ``due_date`` and ``window`` snapshot the
|
||||||
("why this instance exists"): reassignment and rule edits change future
|
materialization-time facts ("why this instance exists"): assignment
|
||||||
instances only and never rewrite these rows. ``due_time`` snapshots
|
edits and rule edits change future instances only and never rewrite
|
||||||
the definition's optional due time at generation for the same reason —
|
these rows. ``window`` records which declared day window produced the
|
||||||
the instance keeps its generation-time value even if the definition is
|
instance (D-008), CHECK-constrained to the same three spellings
|
||||||
edited later. ``generated_at`` is a caller-set UTC ISO-8601 timestamp
|
``const`` owns. ``due_time`` snapshots the window's optional due time
|
||||||
per the module timestamp policy (no DB default, so a generation batch
|
at generation for the same reason — the instance keeps its
|
||||||
stamps one coherent time).
|
generation-time value even if the definition is edited later.
|
||||||
|
``generated_at`` is a caller-set UTC ISO-8601 timestamp per the module
|
||||||
|
timestamp policy (no DB default, so a generation batch stamps one
|
||||||
|
coherent time).
|
||||||
|
|
||||||
Uniqueness: the (definition_id, due_date) pair is declared as a
|
Uniqueness: the (definition_id, child_id, due_date, window) tuple is
|
||||||
table-level ``UNIQUE`` constraint rather than a separate ``CREATE
|
declared as a table-level ``UNIQUE`` constraint rather than a separate
|
||||||
UNIQUE INDEX`` statement. SQLite materializes it as an implicit unique
|
``CREATE UNIQUE INDEX`` statement. SQLite materializes it as an
|
||||||
index (``sqlite_autoindex_quest_instances_1``) — the required "unique
|
implicit unique index (``sqlite_autoindex_quest_instances_1``) — the
|
||||||
index prevents duplicate instances" — and the constraint form cannot be
|
required "unique index prevents duplicate instances" — and the
|
||||||
silently dropped the way a standalone index can, so the duplicate door
|
constraint form cannot be silently dropped the way a standalone index
|
||||||
stays shut. Idempotent materialization keys on exactly this pair. No
|
can, so the duplicate door stays shut. Idempotent materialization keys
|
||||||
secondary indexes beyond it are declared yet: history and board queries
|
on exactly this tuple: the twice-daily case (one definition, two
|
||||||
(Features 08/10/13) should justify their indexes as a later migration
|
windows, one child, one date) yields two rows and the shared case (one
|
||||||
once their query shapes exist.
|
definition, three assignees, one window) yields three. No secondary
|
||||||
|
indexes beyond it are declared yet: history and board queries (Features
|
||||||
|
08/10/13) should justify their indexes as a later migration once their
|
||||||
|
query shapes exist.
|
||||||
|
|
||||||
No ``ON DELETE`` clauses are declared on any foreign key in this
|
No ``ON DELETE`` clauses are declared on any foreign key in this
|
||||||
module: children and definitions are deactivated, never deleted, so a
|
module: children and definitions are deactivated, never deleted, so a
|
||||||
@@ -153,11 +159,12 @@ self-describing without joins.
|
|||||||
Actor policy: ``actor_source`` distinguishes an authenticated HA user
|
Actor policy: ``actor_source`` distinguishes an authenticated HA user
|
||||||
(``'user'``, with ``actor_user_id`` holding the HA user id) from a
|
(``'user'``, with ``actor_user_id`` holding the HA user id) from a
|
||||||
panel tap with no user context (``'panel'``, with ``actor_user_id``
|
panel tap with no user context (``'panel'``, with ``actor_user_id``
|
||||||
NULL — the tapped child profile is already ``child_id``). A coherence
|
NULL — the tapped child profile is ``actor_child_id``, D-008). Two
|
||||||
CHECK makes the pair shape non-negotiable: ``'user'`` requires
|
coherence CHECKs make both shapes non-negotiable: ``'user'`` requires
|
||||||
``actor_user_id``, ``'panel'`` forbids it. Other actor sources are
|
``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` requires
|
||||||
rejected; if an unattended system actor is ever needed, that is a
|
``actor_child_id`` (the tapped profile) and forbids ``actor_user_id``.
|
||||||
schema migration, not a silent widening.
|
Other actor sources are rejected; if an unattended system actor is
|
||||||
|
ever needed, that is a schema migration, not a silent widening.
|
||||||
|
|
||||||
On-time policy: ``was_on_time`` is required (integer 0 or 1) for
|
On-time policy: ``was_on_time`` is required (integer 0 or 1) for
|
||||||
``completed`` events — at completion time the due date is known and the
|
``completed`` events — at completion time the due date is known and the
|
||||||
@@ -316,43 +323,64 @@ SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [
|
|||||||
""",
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
#: Column body of ``quest_instances``, shared between the v1 DDL
|
||||||
|
#: below and the instance-key rebuild migration, which must recreate
|
||||||
|
#: the table in exactly this shape (D-008: the instance key is
|
||||||
|
#: (definition_id, child_id, due_date, window) — one instance per
|
||||||
|
#: assigned child per declared window per firing date).
|
||||||
|
QUEST_INSTANCES_TABLE_SQL = """(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
|
||||||
|
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||||
|
window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')),
|
||||||
|
due_date TEXT NOT NULL,
|
||||||
|
due_time TEXT,
|
||||||
|
generated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (definition_id, child_id, due_date, window)
|
||||||
|
)"""
|
||||||
|
|
||||||
SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [
|
SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [
|
||||||
"""
|
f"""
|
||||||
CREATE TABLE IF NOT EXISTS quest_instances (
|
CREATE TABLE IF NOT EXISTS quest_instances {QUEST_INSTANCES_TABLE_SQL}
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
|
|
||||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
|
||||||
due_date TEXT NOT NULL,
|
|
||||||
due_time TEXT,
|
|
||||||
generated_at TEXT NOT NULL,
|
|
||||||
UNIQUE (definition_id, due_date)
|
|
||||||
)
|
|
||||||
""",
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
|
#: Column body of ``completion_events``, shared between the v1 DDL
|
||||||
"""
|
#: below and the actor-child rebuild migration, which must recreate the
|
||||||
CREATE TABLE IF NOT EXISTS completion_events (
|
#: table in exactly this shape (D-008: ``actor_child_id`` records which
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
#: panel profile was tapped — set for panel events, NULL for admin
|
||||||
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
|
#: ones; legacy panel rows backfill to ``child_id``, the child they
|
||||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
#: always were).
|
||||||
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
|
COMPLETION_EVENTS_TABLE_SQL = """(
|
||||||
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
actor_user_id TEXT,
|
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
|
||||||
occurred_at TEXT NOT NULL,
|
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||||
was_on_time INTEGER CHECK (
|
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
|
||||||
was_on_time IS NULL
|
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
|
||||||
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
|
actor_user_id TEXT,
|
||||||
),
|
actor_child_id INTEGER REFERENCES children(id),
|
||||||
CHECK (
|
occurred_at TEXT NOT NULL,
|
||||||
(actor_source = 'user' AND actor_user_id IS NOT NULL)
|
was_on_time INTEGER CHECK (
|
||||||
OR (actor_source = 'panel' AND actor_user_id IS NULL)
|
was_on_time IS NULL
|
||||||
),
|
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
|
||||||
CHECK (
|
),
|
||||||
(event_type = 'completed' AND was_on_time IS NOT NULL)
|
CHECK (
|
||||||
OR (event_type = 'uncompleted')
|
(actor_source = 'user' AND actor_user_id IS NOT NULL)
|
||||||
)
|
OR (actor_source = 'panel' AND actor_user_id IS NULL)
|
||||||
|
),
|
||||||
|
CHECK (
|
||||||
|
(actor_source = 'user' AND actor_child_id IS NULL)
|
||||||
|
OR (actor_source = 'panel' AND actor_child_id IS NOT NULL)
|
||||||
|
),
|
||||||
|
CHECK (
|
||||||
|
(event_type = 'completed' AND was_on_time IS NOT NULL)
|
||||||
|
OR (event_type = 'uncompleted')
|
||||||
)
|
)
|
||||||
|
)"""
|
||||||
|
|
||||||
|
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS completion_events {COMPLETION_EVENTS_TABLE_SQL}
|
||||||
""",
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,706 @@
|
|||||||
|
"""Tests for children.py: the Feature 03 child-profile business layer."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from custom_components.nestquest.children import (
|
||||||
|
create_child,
|
||||||
|
edit_child,
|
||||||
|
list_children,
|
||||||
|
set_child_active,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.dao_children import ChildRecord, ChildrenDao
|
||||||
|
from custom_components.nestquest.db import NestQuestDatabase
|
||||||
|
from custom_components.nestquest.migrations import apply_migrations
|
||||||
|
|
||||||
|
|
||||||
|
def _run(coro):
|
||||||
|
return asyncio.new_event_loop().run_until_complete(coro)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_hass_mock():
|
||||||
|
hass = MagicMock()
|
||||||
|
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
|
||||||
|
return hass
|
||||||
|
|
||||||
|
|
||||||
|
def _with_db(tmp_path, name):
|
||||||
|
def _run_test(body):
|
||||||
|
async def _main():
|
||||||
|
database = NestQuestDatabase(_make_hass_mock())
|
||||||
|
await database.open(tmp_path / name)
|
||||||
|
try:
|
||||||
|
await apply_migrations(database)
|
||||||
|
return await body(database)
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
return _run(_main())
|
||||||
|
|
||||||
|
return _run_test
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# create_child: required, trimmed display name
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_child_returns_typed_record(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(
|
||||||
|
database,
|
||||||
|
" Declan ",
|
||||||
|
colour="#2E7D32",
|
||||||
|
avatar_ref="avatars/declan.png",
|
||||||
|
sort_order=2,
|
||||||
|
)
|
||||||
|
assert isinstance(child, ChildRecord)
|
||||||
|
# The name is trimmed before storage.
|
||||||
|
assert child.display_name == "Declan"
|
||||||
|
assert child.colour == "#2E7D32"
|
||||||
|
assert child.avatar_ref == "avatars/declan.png"
|
||||||
|
assert child.sort_order == 2
|
||||||
|
assert child.is_active is True
|
||||||
|
# The business layer stamps created_at in strict UTC shape.
|
||||||
|
assert re.fullmatch(
|
||||||
|
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00", child.created_at
|
||||||
|
)
|
||||||
|
return child
|
||||||
|
|
||||||
|
_with_db(tmp_path, "create-typed.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_child_defaults(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Chloe")
|
||||||
|
assert child.display_name == "Chloe"
|
||||||
|
assert child.colour is None
|
||||||
|
assert child.avatar_ref is None
|
||||||
|
assert child.sort_order == 0
|
||||||
|
return child
|
||||||
|
|
||||||
|
_with_db(tmp_path, "create-defaults.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["", " ", "\t\n", None])
|
||||||
|
def test_create_child_missing_name_rejected(tmp_path, bad) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
with pytest.raises(ValueError, match="display_name is required"):
|
||||||
|
await create_child(database, bad)
|
||||||
|
assert await list_children(database) == []
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "create-empty-name.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [42, ["Ada"], 3.5])
|
||||||
|
def test_create_child_non_string_name_rejected(tmp_path, bad) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
with pytest.raises(ValueError, match="must be a string"):
|
||||||
|
await create_child(database, bad)
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "create-bad-name-type.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_child_optional_text_fields_validated(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
with pytest.raises(ValueError, match="colour must be a string"):
|
||||||
|
await create_child(database, "Ada", colour=7)
|
||||||
|
with pytest.raises(ValueError, match="avatar_ref must be a string"):
|
||||||
|
await create_child(database, "Ada", avatar_ref=1.5)
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "create-bad-optional.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["2", 1.5, True, None])
|
||||||
|
def test_create_child_sort_order_must_be_int(tmp_path, bad) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
with pytest.raises(ValueError, match="sort_order must be an integer"):
|
||||||
|
await create_child(database, "Ada", sort_order=bad)
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "create-bad-order.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# create_child: duplicate display name allowed but warned
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_child_duplicate_name_allowed_and_warned(
|
||||||
|
tmp_path, caplog
|
||||||
|
) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
first = await create_child(database, "Ada")
|
||||||
|
with caplog.at_level(
|
||||||
|
logging.WARNING, logger="custom_components.nestquest.children"
|
||||||
|
):
|
||||||
|
second = await create_child(database, " ADA ")
|
||||||
|
# Whitespace/case-normalised duplicate: allowed, both rows exist.
|
||||||
|
assert first.display_name == "Ada"
|
||||||
|
assert second.display_name == "ADA"
|
||||||
|
names = [c.display_name for c in await list_children(database)]
|
||||||
|
assert names == ["Ada", "ADA"]
|
||||||
|
warnings = [
|
||||||
|
r for r in caplog.records if "duplicates existing child" in r.getMessage()
|
||||||
|
]
|
||||||
|
assert len(warnings) == 1, (
|
||||||
|
"exactly one duplicate warning for the second create"
|
||||||
|
)
|
||||||
|
assert "Ada" in warnings[0].getMessage()
|
||||||
|
return second
|
||||||
|
|
||||||
|
_with_db(tmp_path, "create-duplicate.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_child_distinct_name_logs_no_warning(
|
||||||
|
tmp_path, caplog
|
||||||
|
) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
await create_child(database, "Ada")
|
||||||
|
caplog.clear()
|
||||||
|
await create_child(database, "Jordyn")
|
||||||
|
warnings = [
|
||||||
|
r for r in caplog.records if "duplicates existing child" in r.getMessage()
|
||||||
|
]
|
||||||
|
assert warnings == []
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "create-no-duplicate.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# edit_child: returns the updated record
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_updates_fields_and_returns_record(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Ada", colour="#111111")
|
||||||
|
edited = await edit_child(
|
||||||
|
database,
|
||||||
|
child.id,
|
||||||
|
display_name=" Ada L. ",
|
||||||
|
colour="#222222",
|
||||||
|
avatar_ref="avatars/ada.png",
|
||||||
|
sort_order=3,
|
||||||
|
)
|
||||||
|
assert isinstance(edited, ChildRecord)
|
||||||
|
assert edited.id == child.id
|
||||||
|
assert edited.display_name == "Ada L."
|
||||||
|
assert edited.colour == "#222222"
|
||||||
|
assert edited.avatar_ref == "avatars/ada.png"
|
||||||
|
assert edited.sort_order == 3
|
||||||
|
# The stored row reflects every edit.
|
||||||
|
stored = await list_children(database)
|
||||||
|
assert len(stored) == 1
|
||||||
|
assert stored[0] == edited
|
||||||
|
return edited
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-round-trip.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_omitted_fields_unchanged(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(
|
||||||
|
database, "Ada", colour="#111111", sort_order=5
|
||||||
|
)
|
||||||
|
edited = await edit_child(database, child.id, sort_order=9)
|
||||||
|
# Only sort_order moved; colour and name untouched.
|
||||||
|
assert edited.sort_order == 9
|
||||||
|
assert edited.colour == "#111111"
|
||||||
|
assert edited.display_name == "Ada"
|
||||||
|
return edited
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-partial.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_no_fields_returns_current_record(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Ada", colour="#111111")
|
||||||
|
edited = await edit_child(database, child.id)
|
||||||
|
assert edited == child
|
||||||
|
return edited
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-noop.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_missing_raises(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
with pytest.raises(ValueError, match="does not exist"):
|
||||||
|
await edit_child(database, 999, display_name="X")
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-missing.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_name_validation(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Ada")
|
||||||
|
for bad in ("", " ", None, 42):
|
||||||
|
with pytest.raises(ValueError, match="display_name"):
|
||||||
|
await edit_child(database, child.id, display_name=bad)
|
||||||
|
# The failed edits left the record untouched.
|
||||||
|
assert (await list_children(database))[0].display_name == "Ada"
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-name-validation.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_sort_order_must_be_int(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Ada")
|
||||||
|
with pytest.raises(ValueError, match="sort_order must be an integer"):
|
||||||
|
await edit_child(database, child.id, sort_order="3")
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-bad-order.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_explicit_none_clear_rejected(tmp_path) -> None:
|
||||||
|
"""Clearing is refused loudly, not silently ignored: the DAO cannot
|
||||||
|
express NULL writes, so an explicit None must never masquerade as a
|
||||||
|
no-op."""
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Ada", colour="#111111")
|
||||||
|
with pytest.raises(ValueError, match="clearing colour"):
|
||||||
|
await edit_child(database, child.id, colour=None)
|
||||||
|
with pytest.raises(ValueError, match="clearing avatar_ref"):
|
||||||
|
await edit_child(database, child.id, avatar_ref=None)
|
||||||
|
# The refused clears left the colour intact.
|
||||||
|
assert (await list_children(database))[0].colour == "#111111"
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-clear-unsupported.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_rename_to_duplicate_warns_but_different_name_not(
|
||||||
|
tmp_path, caplog
|
||||||
|
) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
ada = await create_child(database, "Ada")
|
||||||
|
bo = await create_child(database, "Bo")
|
||||||
|
caplog.clear()
|
||||||
|
# Renaming Bo onto Ada's name warns (different child's name).
|
||||||
|
edited = await edit_child(database, bo.id, display_name="ada")
|
||||||
|
assert edited.display_name == "ada"
|
||||||
|
warnings = [
|
||||||
|
r for r in caplog.records if "duplicates existing child" in r.getMessage()
|
||||||
|
]
|
||||||
|
assert len(warnings) == 1
|
||||||
|
assert "Ada" in warnings[0].getMessage()
|
||||||
|
return edited
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-rename-duplicate.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_rename_to_own_name_casing_alone_warns_not(
|
||||||
|
tmp_path, caplog
|
||||||
|
) -> None:
|
||||||
|
"""Renaming a child to their own name (casing aside) is not a
|
||||||
|
duplicate when NO OTHER child carries the normalised name."""
|
||||||
|
async def _body(database):
|
||||||
|
ada = await create_child(database, "Ada")
|
||||||
|
caplog.clear()
|
||||||
|
edited = await edit_child(database, ada.id, display_name="ADA")
|
||||||
|
assert edited.display_name == "ADA"
|
||||||
|
assert not any(
|
||||||
|
"duplicates existing child" in r.getMessage()
|
||||||
|
for r in caplog.records
|
||||||
|
)
|
||||||
|
return edited
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-rename-self.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_child_concurrent_same_name_still_warns(tmp_path) -> None:
|
||||||
|
"""Racing creates of the same normalised name cannot slip through
|
||||||
|
unlogged: the scan and INSERT are serialized on the connection
|
||||||
|
lock, so the second create sees the first's committed row and
|
||||||
|
warns. Deterministic gate on task A's INSERT while task B queues
|
||||||
|
behind the lock. Without the lock, B's scan would run before A's
|
||||||
|
INSERT commits and no warning would fire."""
|
||||||
|
async def _main():
|
||||||
|
armed = {"active": False, "gated": False}
|
||||||
|
gate_open = asyncio.Event()
|
||||||
|
a_started = asyncio.Event()
|
||||||
|
hass = _gated_hass(armed, gate_open, a_started)
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
await database.open(tmp_path / "create-race.db")
|
||||||
|
try:
|
||||||
|
await apply_migrations(database)
|
||||||
|
logger = logging.getLogger(
|
||||||
|
"custom_components.nestquest.children"
|
||||||
|
)
|
||||||
|
seen: list[logging.LogRecord] = []
|
||||||
|
|
||||||
|
class _Capture(logging.Handler):
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
seen.append(record)
|
||||||
|
|
||||||
|
handler = _Capture()
|
||||||
|
logger.addHandler(handler)
|
||||||
|
try:
|
||||||
|
armed["active"] = True
|
||||||
|
task_a = asyncio.ensure_future(
|
||||||
|
create_child(database, "Ada")
|
||||||
|
)
|
||||||
|
await a_started.wait()
|
||||||
|
armed["active"] = False
|
||||||
|
task_b = asyncio.ensure_future(
|
||||||
|
create_child(database, " ada ")
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
gate_open.set()
|
||||||
|
first, second = await asyncio.gather(task_a, task_b)
|
||||||
|
finally:
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
|
||||||
|
# Both creates land; the duplicate is allowed.
|
||||||
|
assert first.display_name == "Ada"
|
||||||
|
assert second.display_name == "ada"
|
||||||
|
names = [c.display_name for c in await list_children(database)]
|
||||||
|
assert names == ["Ada", "ada"]
|
||||||
|
# Exactly one warning: B's scan saw A's committed row.
|
||||||
|
warnings = [
|
||||||
|
r for r in seen if "duplicates existing child" in r.getMessage()
|
||||||
|
]
|
||||||
|
assert len(warnings) == 1, (
|
||||||
|
"the queued duplicate create must warn exactly once"
|
||||||
|
)
|
||||||
|
assert "Ada" in warnings[0].getMessage()
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
_run(_main())
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_case_only_edit_colliding_with_other_child_warns(
|
||||||
|
tmp_path, caplog
|
||||||
|
) -> None:
|
||||||
|
"""A case-only edit must not hide a real duplicate: with two Ada
|
||||||
|
profiles already present, editing either one's name to another
|
||||||
|
casing still warns (the scan excludes only the edited child)."""
|
||||||
|
async def _body(database):
|
||||||
|
first = await create_child(database, "Ada")
|
||||||
|
await create_child(database, "ada")
|
||||||
|
caplog.clear()
|
||||||
|
edited = await edit_child(database, first.id, display_name="ADA")
|
||||||
|
assert edited.display_name == "ADA"
|
||||||
|
warnings = [
|
||||||
|
r for r in caplog.records if "duplicates existing child" in r.getMessage()
|
||||||
|
]
|
||||||
|
assert len(warnings) == 1
|
||||||
|
# Editing the name to a value that STAYS duplicated (no change,
|
||||||
|
# other child still carries it) warns too: the scan is on the
|
||||||
|
# resulting state, not on whether the word moved.
|
||||||
|
caplog.clear()
|
||||||
|
await edit_child(database, first.id, display_name=" ada ")
|
||||||
|
warnings = [
|
||||||
|
r for r in caplog.records if "duplicates existing child" in r.getMessage()
|
||||||
|
]
|
||||||
|
assert len(warnings) == 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-case-only-duplicate.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# set_child_active / list_children
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_child_active_round_trip(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Ada")
|
||||||
|
deactivated = await set_child_active(database, child.id, False)
|
||||||
|
assert deactivated.is_active is False
|
||||||
|
assert [c.id for c in await list_children(database)] == [child.id]
|
||||||
|
assert await list_children(database, active_only=True) == []
|
||||||
|
reactivated = await set_child_active(database, child.id, True)
|
||||||
|
assert reactivated.is_active is True
|
||||||
|
assert len(await list_children(database, active_only=True)) == 1
|
||||||
|
return reactivated
|
||||||
|
|
||||||
|
_with_db(tmp_path, "set-active.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_child_active_missing_raises(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
with pytest.raises(ValueError, match="does not exist"):
|
||||||
|
await set_child_active(database, 999, False)
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "set-active-missing.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["1", "0", 1, 0, 1.0, None, "true"])
|
||||||
|
def test_set_child_active_rejects_non_bool(tmp_path, bad) -> None:
|
||||||
|
"""The DAO's int() would silently coerce "1"/0/1.0 into a state the
|
||||||
|
caller never asked for; the business layer refuses non-bools."""
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Ada")
|
||||||
|
with pytest.raises(ValueError, match="is_active must be a real bool"):
|
||||||
|
await set_child_active(database, child.id, bad)
|
||||||
|
# The refused transition left the child untouched.
|
||||||
|
assert (await list_children(database))[0].is_active is True
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "set-active-non-bool.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Concurrency: edit/set_active are atomic under the connection lock
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _gated_hass(armed: dict, gate_open: asyncio.Event, a_started: asyncio.Event):
|
||||||
|
"""A hass mock whose executor gates the FIRST `_execute` job after
|
||||||
|
arming — the mutate statement of whichever business call starts
|
||||||
|
first — proving the caller holds the connection lock at that
|
||||||
|
moment and the second caller must queue behind it."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
hass = MagicMock()
|
||||||
|
|
||||||
|
async def _gated_executor(fn, *args):
|
||||||
|
if armed["active"]:
|
||||||
|
if getattr(fn, "__name__", "") == "_execute" and not armed["gated"]:
|
||||||
|
armed["gated"] = True
|
||||||
|
a_started.set()
|
||||||
|
await gate_open.wait()
|
||||||
|
return fn(*args)
|
||||||
|
|
||||||
|
hass.async_add_executor_job = _gated_executor
|
||||||
|
return hass
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_concurrent_opposite_edits_return_own_values(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
"""Two racing edits cannot cross-contaminate each other's readback.
|
||||||
|
|
||||||
|
Deterministic gate: task A is paused at its UPDATE (it provably
|
||||||
|
holds the connection lock), task B is then created and must queue;
|
||||||
|
A's readback runs before B's edit, so A returns A's value and B
|
||||||
|
returns B's. Without the lock, A's readback would see B's write.
|
||||||
|
"""
|
||||||
|
async def _main():
|
||||||
|
armed = {"active": False, "gated": False}
|
||||||
|
gate_open = asyncio.Event()
|
||||||
|
a_started = asyncio.Event()
|
||||||
|
hass = _gated_hass(armed, gate_open, a_started)
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
await database.open(tmp_path / "edit-race.db")
|
||||||
|
try:
|
||||||
|
await apply_migrations(database)
|
||||||
|
child = await create_child(
|
||||||
|
database, "Ada", colour="#111111", sort_order=0
|
||||||
|
)
|
||||||
|
|
||||||
|
armed["active"] = True
|
||||||
|
task_a = asyncio.ensure_future(
|
||||||
|
edit_child(database, child.id, colour="#AAAAAA", sort_order=1)
|
||||||
|
)
|
||||||
|
await a_started.wait()
|
||||||
|
armed["active"] = False
|
||||||
|
task_b = asyncio.ensure_future(
|
||||||
|
edit_child(
|
||||||
|
database, child.id, colour="#BBBBBB", sort_order=2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
gate_open.set()
|
||||||
|
record_a, record_b = await asyncio.gather(task_a, task_b)
|
||||||
|
|
||||||
|
# Each caller gets back exactly what it wrote.
|
||||||
|
assert record_a.colour == "#AAAAAA"
|
||||||
|
assert record_a.sort_order == 1
|
||||||
|
assert record_b.colour == "#BBBBBB"
|
||||||
|
assert record_b.sort_order == 2
|
||||||
|
# B landed last: the stored row is B's (read via the DAO —
|
||||||
|
# the children leak guard forbids raw probes here).
|
||||||
|
stored = await ChildrenDao(database).get(child.id)
|
||||||
|
assert stored is not None
|
||||||
|
assert (stored.colour, stored.sort_order) == ("#BBBBBB", 2)
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
_run(_main())
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_child_active_concurrent_opposite_transitions_return_own(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
"""Racing activate/deactivate calls each report their own request.
|
||||||
|
|
||||||
|
Same deterministic gate on task A's UPDATE; task B queues behind
|
||||||
|
the connection lock and lands the opposite transition after A.
|
||||||
|
"""
|
||||||
|
async def _main():
|
||||||
|
armed = {"active": False, "gated": False}
|
||||||
|
gate_open = asyncio.Event()
|
||||||
|
a_started = asyncio.Event()
|
||||||
|
hass = _gated_hass(armed, gate_open, a_started)
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
await database.open(tmp_path / "active-race.db")
|
||||||
|
try:
|
||||||
|
await apply_migrations(database)
|
||||||
|
child = await create_child(database, "Ada")
|
||||||
|
|
||||||
|
armed["active"] = True
|
||||||
|
task_a = asyncio.ensure_future(
|
||||||
|
set_child_active(database, child.id, False)
|
||||||
|
)
|
||||||
|
await a_started.wait()
|
||||||
|
armed["active"] = False
|
||||||
|
task_b = asyncio.ensure_future(
|
||||||
|
set_child_active(database, child.id, True)
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
gate_open.set()
|
||||||
|
record_a, record_b = await asyncio.gather(task_a, task_b)
|
||||||
|
|
||||||
|
assert record_a.is_active is False
|
||||||
|
assert record_b.is_active is True
|
||||||
|
stored = await ChildrenDao(database).get(child.id)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.is_active is True
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
_run(_main())
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_children_orders_by_sort_order_then_id(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
second = await create_child(database, "Bo", sort_order=1)
|
||||||
|
first = await create_child(database, "Ada", sort_order=0)
|
||||||
|
third = await create_child(database, "Cleo", sort_order=1)
|
||||||
|
listed = await list_children(database)
|
||||||
|
# sort_order 0 first, then the two sort_order-1 rows by id.
|
||||||
|
assert [c.id for c in listed] == [first.id, second.id, third.id]
|
||||||
|
return listed
|
||||||
|
|
||||||
|
_with_db(tmp_path, "list-order.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Deactivation keeps every referencing row intact (Feature 03)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_deactivating_child_preserves_definitions_instances_events(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from custom_components.nestquest.dao_children import ChildrenDao
|
||||||
|
from custom_components.nestquest.dao_instances import (
|
||||||
|
CompletionEventsDao,
|
||||||
|
QuestInstancesDao,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.dao_rules import (
|
||||||
|
QuestDefinitionsDao,
|
||||||
|
ScheduleRulesDao,
|
||||||
|
)
|
||||||
|
|
||||||
|
children = ChildrenDao(database)
|
||||||
|
rules = ScheduleRulesDao(database)
|
||||||
|
definitions = QuestDefinitionsDao(database)
|
||||||
|
instances = QuestInstancesDao(database)
|
||||||
|
events = CompletionEventsDao(database)
|
||||||
|
|
||||||
|
child = await create_child(database, "Ada")
|
||||||
|
rule = await rules.create("daily", "2026-01-01")
|
||||||
|
definition = await definitions.create(
|
||||||
|
"Brush teeth",
|
||||||
|
rule.id,
|
||||||
|
"2026-09-15T12:00:00+00:00",
|
||||||
|
assignee_child_ids=[child.id],
|
||||||
|
)
|
||||||
|
due = (date.today() + timedelta(days=1)).isoformat()
|
||||||
|
stamp = f"{due}T08:00:00+00:00"
|
||||||
|
instance = await instances.upsert(
|
||||||
|
definition.id, child.id, due, stamp, window="morning"
|
||||||
|
)
|
||||||
|
event = await events.append(
|
||||||
|
instance.id,
|
||||||
|
child.id,
|
||||||
|
"completed",
|
||||||
|
"panel",
|
||||||
|
f"{due}T09:00:00+00:00",
|
||||||
|
True,
|
||||||
|
actor_child_id=child.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deactivate: the referencing rows must not move.
|
||||||
|
deactivated = await set_child_active(database, child.id, False)
|
||||||
|
assert deactivated.is_active is False
|
||||||
|
assert await children.get(child.id) == deactivated
|
||||||
|
assert (await definitions.get(definition.id)) == definition
|
||||||
|
assert (await instances.get_by_id(instance.id)) == instance
|
||||||
|
assert await events.list_by_instance(instance.id) == [event]
|
||||||
|
# Excluded from the active list, still present in the full list.
|
||||||
|
assert await list_children(database, active_only=True) == []
|
||||||
|
assert [c.id for c in await list_children(database)] == [child.id]
|
||||||
|
|
||||||
|
# Reactivate: fully restored.
|
||||||
|
reactivated = await set_child_active(database, child.id, True)
|
||||||
|
assert reactivated.is_active is True
|
||||||
|
assert [c.id for c in await list_children(database, active_only=True)] == [
|
||||||
|
child.id
|
||||||
|
]
|
||||||
|
assert (await instances.get_by_id(instance.id)) == instance
|
||||||
|
return reactivated
|
||||||
|
|
||||||
|
_with_db(tmp_path, "deactivate-data-intact.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_code_path_deletes_a_child_row() -> None:
|
||||||
|
"""Structural guardrail: children are deactivated, never deleted.
|
||||||
|
|
||||||
|
The DAO exposes no delete method at all, and no module in the
|
||||||
|
package (nor any test outside the sanctioned DDL/migration layers,
|
||||||
|
which create the tables) carries a statement that removes child
|
||||||
|
rows — the leak guard in test_dao_children enforces the file
|
||||||
|
allowlist, this asserts the no-delete property itself across every
|
||||||
|
file that may legitimately name the table.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
package = Path(
|
||||||
|
__import__(
|
||||||
|
"custom_components.nestquest", fromlist=["__file__"]
|
||||||
|
).__file__
|
||||||
|
).parent
|
||||||
|
|
||||||
|
delete_pattern = re.compile(
|
||||||
|
r"DELETE\s+(FROM\s+)?((main|temp)\s*\.\s*)?[`'\"]*(\[)?children\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
for py in sorted(package.rglob("*.py")):
|
||||||
|
assert delete_pattern.search(py.read_text()) is None, (
|
||||||
|
f"a code path in {py.name} deletes child rows"
|
||||||
|
)
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from custom_components.nestquest.dao_children import ChildrenDao
|
||||||
|
|
||||||
|
methods = {
|
||||||
|
name
|
||||||
|
for name, _ in inspect.getmembers(ChildrenDao, inspect.isfunction)
|
||||||
|
}
|
||||||
|
assert not any(
|
||||||
|
name.startswith("delete") or name == "remove" for name in methods
|
||||||
|
), f"ChildrenDao exposed a delete-shaped method: {methods}"
|
||||||
+181
-91
@@ -119,12 +119,10 @@ def test_upsert_creates_then_is_idempotent(tmp_path) -> None:
|
|||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
first = await instances.upsert(
|
first = await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp()
|
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
assert isinstance(first, QuestInstanceRecord)
|
assert isinstance(first, QuestInstanceRecord)
|
||||||
second = await instances.upsert(
|
second = await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp()
|
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
assert second.id == first.id
|
assert second.id == first.id
|
||||||
rows = await database.fetch_one(
|
rows = await database.fetch_one(
|
||||||
"SELECT COUNT(*) FROM quest_instances"
|
"SELECT COUNT(*) FROM quest_instances"
|
||||||
@@ -138,27 +136,116 @@ def test_upsert_creates_then_is_idempotent(tmp_path) -> None:
|
|||||||
def test_upsert_different_dates_create_distinct_rows(tmp_path) -> None:
|
def test_upsert_different_dates_create_distinct_rows(tmp_path) -> None:
|
||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
a = await instances.upsert(definition.id, child.id, D1, _now_stamp())
|
a = await instances.upsert(definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
b = await instances.upsert(definition.id, child.id, D2, _now_stamp())
|
b = await instances.upsert(definition.id, child.id, D2, _now_stamp(), window="morning")
|
||||||
assert a.id != b.id
|
assert a.id != b.id
|
||||||
return (a, b)
|
return (a, b)
|
||||||
|
|
||||||
_with_db(tmp_path, "upsert-multi-date.db")(_body)
|
_with_db(tmp_path, "upsert-multi-date.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_twice_daily_fanout_two_windows_two_rows(tmp_path) -> None:
|
||||||
|
"""D-008 twice-daily case: one definition, one child, one date,
|
||||||
|
two windows — two rows, each idempotent on its own tuple."""
|
||||||
|
async def _body(database, children, rules, definitions, instances,
|
||||||
|
events, child, definition):
|
||||||
|
morning = await instances.upsert(
|
||||||
|
definition.id, child.id, D1, _now_stamp(), window="morning"
|
||||||
|
)
|
||||||
|
evening = await instances.upsert(
|
||||||
|
definition.id, child.id, D1, _now_stamp(), window="evening"
|
||||||
|
)
|
||||||
|
assert morning.id != evening.id
|
||||||
|
rows = await database.fetch_all(
|
||||||
|
"SELECT window, child_id, due_date FROM quest_instances "
|
||||||
|
"WHERE definition_id = ? AND due_date = ?",
|
||||||
|
(definition.id, D1),
|
||||||
|
)
|
||||||
|
assert sorted(rows) == [
|
||||||
|
("evening", child.id, D1),
|
||||||
|
("morning", child.id, D1),
|
||||||
|
]
|
||||||
|
# Re-running both is still idempotent.
|
||||||
|
await instances.upsert(
|
||||||
|
definition.id, child.id, D1, _now_stamp(), window="morning"
|
||||||
|
)
|
||||||
|
await instances.upsert(
|
||||||
|
definition.id, child.id, D1, _now_stamp(), window="evening"
|
||||||
|
)
|
||||||
|
count = await database.fetch_one(
|
||||||
|
"SELECT COUNT(*) FROM quest_instances"
|
||||||
|
)
|
||||||
|
assert count == (2,)
|
||||||
|
return (morning, evening)
|
||||||
|
|
||||||
|
_with_db(tmp_path, "upsert-twice-daily.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_shared_definition_three_assignees_three_rows(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
"""D-008 shared case: one definition, three assignees, one window
|
||||||
|
— three rows, one per child."""
|
||||||
|
async def _body(database, children, rules, definitions, instances,
|
||||||
|
events, child, definition):
|
||||||
|
second = await children.create("Bo", _now_stamp())
|
||||||
|
third = await children.create("Cleo", _now_stamp())
|
||||||
|
await definitions.add_assignee(definition.id, second.id)
|
||||||
|
await definitions.add_assignee(definition.id, third.id)
|
||||||
|
created = [
|
||||||
|
await instances.upsert(
|
||||||
|
definition.id, assignee.id, D1, _now_stamp(),
|
||||||
|
window="morning",
|
||||||
|
)
|
||||||
|
for assignee in (child, second, third)
|
||||||
|
]
|
||||||
|
assert len({row.id for row in created}) == 3
|
||||||
|
count = await database.fetch_one(
|
||||||
|
"SELECT COUNT(*) FROM quest_instances "
|
||||||
|
"WHERE definition_id = ? AND due_date = ? AND window = ?",
|
||||||
|
(definition.id, D1, "morning"),
|
||||||
|
)
|
||||||
|
assert count == (3,)
|
||||||
|
# Re-running the fan-out changes nothing.
|
||||||
|
for assignee in (child, second, third):
|
||||||
|
await instances.upsert(
|
||||||
|
definition.id, assignee.id, D1, _now_stamp(),
|
||||||
|
window="morning",
|
||||||
|
)
|
||||||
|
count = await database.fetch_one(
|
||||||
|
"SELECT COUNT(*) FROM quest_instances"
|
||||||
|
)
|
||||||
|
assert count == (3,)
|
||||||
|
return created
|
||||||
|
|
||||||
|
_with_db(tmp_path, "upsert-shared-fanout.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_window_outside_const_list_raises(tmp_path) -> None:
|
||||||
|
"""The DAO rejects a window spelling the schema CHECK would reject
|
||||||
|
anyway, with the typed ValueError instead of raw IntegrityError."""
|
||||||
|
async def _body(database, children, rules, definitions, instances,
|
||||||
|
events, child, definition):
|
||||||
|
with pytest.raises(ValueError, match="window must be one of"):
|
||||||
|
await instances.upsert(
|
||||||
|
definition.id, child.id, D1, _now_stamp(), window="noon"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "upsert-bad-window.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
def test_upsert_refreshes_snapshot_columns_on_conflict(tmp_path) -> None:
|
def test_upsert_refreshes_snapshot_columns_on_conflict(tmp_path) -> None:
|
||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
first = await instances.upsert(
|
first = await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp(), due_time="08:00"
|
definition.id, child.id, D1, _now_stamp(), due_time="08:00", window="morning")
|
||||||
)
|
|
||||||
second = await instances.upsert(
|
second = await instances.upsert(
|
||||||
definition.id,
|
definition.id,
|
||||||
child.id,
|
child.id,
|
||||||
D1,
|
D1,
|
||||||
f"{D1}T13:00:00+00:00",
|
f"{D1}T13:00:00+00:00",
|
||||||
due_time="08:30",
|
due_time="08:30", window="morning")
|
||||||
)
|
|
||||||
assert second.id == first.id
|
assert second.id == first.id
|
||||||
assert second.due_time == "08:30"
|
assert second.due_time == "08:30"
|
||||||
assert second.generated_at == f"{D1}T13:00:00+00:00"
|
assert second.generated_at == f"{D1}T13:00:00+00:00"
|
||||||
@@ -172,7 +259,7 @@ def test_upsert_rejects_wrong_child_for_definition(tmp_path) -> None:
|
|||||||
events, child, definition):
|
events, child, definition):
|
||||||
other = await children.create("Bo", _now_stamp())
|
other = await children.create("Bo", _now_stamp())
|
||||||
with pytest.raises(ValueError, match="assigned to"):
|
with pytest.raises(ValueError, match="assigned to"):
|
||||||
await instances.upsert(definition.id, other.id, D1, _now_stamp())
|
await instances.upsert(definition.id, other.id, D1, _now_stamp(), window="morning")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
_with_db(tmp_path, "upsert-wrong-child.db")(_body)
|
_with_db(tmp_path, "upsert-wrong-child.db")(_body)
|
||||||
@@ -182,7 +269,7 @@ def test_upsert_rejects_unknown_definition_and_child(tmp_path) -> None:
|
|||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
with pytest.raises(ValueError, match="definition 999"):
|
with pytest.raises(ValueError, match="definition 999"):
|
||||||
await instances.upsert(999, child.id, D1, _now_stamp())
|
await instances.upsert(999, child.id, D1, _now_stamp(), window="morning")
|
||||||
# Unknown child: the definition-assignee check fires first and
|
# Unknown child: the definition-assignee check fires first and
|
||||||
# is the one that matters (an instance must carry an assigned
|
# is the one that matters (an instance must carry an assigned
|
||||||
# child). Under D-008 the assignee check is a membership test
|
# child). Under D-008 the assignee check is a membership test
|
||||||
@@ -193,7 +280,7 @@ def test_upsert_rejects_unknown_definition_and_child(tmp_path) -> None:
|
|||||||
"X", rule.id, _now_stamp(), assignee_child_ids=[child.id]
|
"X", rule.id, _now_stamp(), assignee_child_ids=[child.id]
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="not assigned to child 999"):
|
with pytest.raises(ValueError, match="not assigned to child 999"):
|
||||||
await instances.upsert(other_definition.id, 999, D1, _now_stamp())
|
await instances.upsert(other_definition.id, 999, D1, _now_stamp(), window="morning")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
_with_db(tmp_path, "upsert-unknown.db")(_body)
|
_with_db(tmp_path, "upsert-unknown.db")(_body)
|
||||||
@@ -204,7 +291,7 @@ def test_upsert_malformed_due_date_raises(tmp_path) -> None:
|
|||||||
events, child, definition):
|
events, child, definition):
|
||||||
for bad in ("not-a-date", "2026-9-15", ""):
|
for bad in ("not-a-date", "2026-9-15", ""):
|
||||||
with pytest.raises(ValueError, match="YYYY-MM-DD"):
|
with pytest.raises(ValueError, match="YYYY-MM-DD"):
|
||||||
await instances.upsert(definition.id, child.id, bad, _now_stamp())
|
await instances.upsert(definition.id, child.id, bad, _now_stamp(), window="morning")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
_with_db(tmp_path, "upsert-bad-date.db")(_body)
|
_with_db(tmp_path, "upsert-bad-date.db")(_body)
|
||||||
@@ -216,11 +303,11 @@ def test_upsert_rejects_past_due_dates(tmp_path) -> None:
|
|||||||
events, child, definition):
|
events, child, definition):
|
||||||
yesterday = _future_day(-1)
|
yesterday = _future_day(-1)
|
||||||
with pytest.raises(ValueError, match="never generated in the past"):
|
with pytest.raises(ValueError, match="never generated in the past"):
|
||||||
await instances.upsert(definition.id, child.id, yesterday, _now_stamp())
|
await instances.upsert(definition.id, child.id, yesterday, _now_stamp(), window="morning")
|
||||||
# Today itself is allowed (the rollover job generates today's
|
# Today itself is allowed (the rollover job generates today's
|
||||||
# instances before the day is over).
|
# instances before the day is over).
|
||||||
today = datetime.date.today().isoformat()
|
today = datetime.date.today().isoformat()
|
||||||
record = await instances.upsert(definition.id, child.id, today, _now_stamp())
|
record = await instances.upsert(definition.id, child.id, today, _now_stamp(), window="morning")
|
||||||
assert record.due_date == today
|
assert record.due_date == today
|
||||||
return record
|
return record
|
||||||
|
|
||||||
@@ -296,7 +383,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
|
|||||||
await asyncio.wait_for(holder_inside.wait(), timeout=5)
|
await asyncio.wait_for(holder_inside.wait(), timeout=5)
|
||||||
|
|
||||||
task = asyncio.ensure_future(
|
task = asyncio.ensure_future(
|
||||||
instances.upsert(definition.id, child.id, today, _now_stamp())
|
instances.upsert(definition.id, child.id, today, _now_stamp(), window="morning")
|
||||||
)
|
)
|
||||||
# A is now queued on the connection lock (it cannot pass
|
# A is now queued on the connection lock (it cannot pass
|
||||||
# its in-lock check while B holds the lock). Shift the
|
# its in-lock check while B holds the lock). Shift the
|
||||||
@@ -349,17 +436,15 @@ def test_upsert_refuses_immutable_completed_instance(tmp_path) -> None:
|
|||||||
events, child, definition):
|
events, child, definition):
|
||||||
generated_at = _now_stamp()
|
generated_at = _now_stamp()
|
||||||
instance = await instances.upsert(
|
instance = await instances.upsert(
|
||||||
definition.id, child.id, D1, generated_at, due_time="08:00"
|
definition.id, child.id, D1, generated_at, due_time="08:00", window="morning")
|
||||||
)
|
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "completed", "user", _now_stamp(), True,
|
instance.id, child.id, "completed", "user", _now_stamp(), True,
|
||||||
actor_user_id="user-1",
|
actor_user_id="user-1",
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="immutable"):
|
with pytest.raises(ValueError, match="immutable"):
|
||||||
await instances.upsert(
|
await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp(), due_time="09:00"
|
definition.id, child.id, D1, _now_stamp(), due_time="09:00", window="morning")
|
||||||
)
|
fetched = await instances.get(definition.id, child.id, D1, "morning")
|
||||||
fetched = await instances.get(definition.id, D1)
|
|
||||||
assert fetched.due_time == "08:00", (
|
assert fetched.due_time == "08:00", (
|
||||||
"failed regeneration must not touch the completed instance"
|
"failed regeneration must not touch the completed instance"
|
||||||
)
|
)
|
||||||
@@ -378,12 +463,11 @@ def test_get_by_definition_and_date_round_trip(tmp_path) -> None:
|
|||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
created = await instances.upsert(
|
created = await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp(), due_time="17:30"
|
definition.id, child.id, D1, _now_stamp(), due_time="17:30", window="morning")
|
||||||
)
|
fetched = await instances.get(definition.id, child.id, D1, "morning")
|
||||||
fetched = await instances.get(definition.id, D1)
|
|
||||||
assert fetched == created
|
assert fetched == created
|
||||||
assert fetched.due_time == "17:30"
|
assert fetched.due_time == "17:30"
|
||||||
assert await instances.get(definition.id, D2) is None
|
assert await instances.get(definition.id, child.id, D2, "morning") is None
|
||||||
return fetched
|
return fetched
|
||||||
|
|
||||||
_with_db(tmp_path, "get-round-trip.db")(_body)
|
_with_db(tmp_path, "get-round-trip.db")(_body)
|
||||||
@@ -392,7 +476,7 @@ def test_get_by_definition_and_date_round_trip(tmp_path) -> None:
|
|||||||
def test_get_by_id_round_trip(tmp_path) -> None:
|
def test_get_by_id_round_trip(tmp_path) -> None:
|
||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
created = await instances.upsert(definition.id, child.id, D1, _now_stamp())
|
created = await instances.upsert(definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
fetched = await instances.get_by_id(created.id)
|
fetched = await instances.get_by_id(created.id)
|
||||||
assert fetched == created
|
assert fetched == created
|
||||||
assert await instances.get_by_id(999) is None
|
assert await instances.get_by_id(999) is None
|
||||||
@@ -409,11 +493,10 @@ def test_list_by_child_and_date_filters(tmp_path) -> None:
|
|||||||
other_definition = await definitions.create(
|
other_definition = await definitions.create(
|
||||||
"Make bed", rule.id, _now_stamp(), assignee_child_ids=[other.id]
|
"Make bed", rule.id, _now_stamp(), assignee_child_ids=[other.id]
|
||||||
)
|
)
|
||||||
await instances.upsert(definition.id, child.id, D1, _now_stamp())
|
await instances.upsert(definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
await instances.upsert(definition.id, child.id, D2, _now_stamp())
|
await instances.upsert(definition.id, child.id, D2, _now_stamp(), window="morning")
|
||||||
await instances.upsert(
|
await instances.upsert(
|
||||||
other_definition.id, other.id, D1, _now_stamp()
|
other_definition.id, other.id, D1, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
mine = await instances.list_by_child_and_date(child.id, D1)
|
mine = await instances.list_by_child_and_date(child.id, D1)
|
||||||
assert len(mine) == 1
|
assert len(mine) == 1
|
||||||
assert mine[0].child_id == child.id
|
assert mine[0].child_id == child.id
|
||||||
@@ -427,7 +510,7 @@ def test_list_by_date_range_filters_and_orders(tmp_path) -> None:
|
|||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
for day in (D1, D3, D5, D20):
|
for day in (D1, D3, D5, D20):
|
||||||
await instances.upsert(definition.id, child.id, day, _now_stamp())
|
await instances.upsert(definition.id, child.id, day, _now_stamp(), window="morning")
|
||||||
found = await instances.list_by_date_range(
|
found = await instances.list_by_date_range(
|
||||||
child.id, D2, D6
|
child.id, D2, D6
|
||||||
)
|
)
|
||||||
@@ -461,18 +544,17 @@ def test_list_by_date_range_rejects_inverted_range(tmp_path) -> None:
|
|||||||
def test_delete_future_uncompleted_removes_only_eligible(tmp_path) -> None:
|
def test_delete_future_uncompleted_removes_only_eligible(tmp_path) -> None:
|
||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
await instances.upsert(definition.id, child.id, D1, _now_stamp())
|
await instances.upsert(definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
future_open = await instances.upsert(
|
future_open = await instances.upsert(
|
||||||
definition.id, child.id, D5, _now_stamp()
|
definition.id, child.id, D5, _now_stamp(), window="morning")
|
||||||
)
|
await instances.upsert(definition.id, child.id, D6, _now_stamp(), window="morning")
|
||||||
await instances.upsert(definition.id, child.id, D6, _now_stamp())
|
|
||||||
deleted = await instances.delete_future_uncompleted(
|
deleted = await instances.delete_future_uncompleted(
|
||||||
definition.id, D5
|
definition.id, D5
|
||||||
)
|
)
|
||||||
assert deleted == 2
|
assert deleted == 2
|
||||||
assert await instances.get(definition.id, D1) is not None
|
assert await instances.get(definition.id, child.id, D1, "morning") is not None
|
||||||
assert await instances.get(definition.id, D5) is None
|
assert await instances.get(definition.id, child.id, D5, "morning") is None
|
||||||
assert await instances.get(definition.id, D6) is None
|
assert await instances.get(definition.id, child.id, D6, "morning") is None
|
||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
_with_db(tmp_path, "delete-future.db")(_body)
|
_with_db(tmp_path, "delete-future.db")(_body)
|
||||||
@@ -482,11 +564,9 @@ def test_delete_future_uncompleted_spares_completed(tmp_path) -> None:
|
|||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
completed = await instances.upsert(
|
completed = await instances.upsert(
|
||||||
definition.id, child.id, D5, _now_stamp()
|
definition.id, child.id, D5, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
open_one = await instances.upsert(
|
open_one = await instances.upsert(
|
||||||
definition.id, child.id, D6, _now_stamp()
|
definition.id, child.id, D6, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
await events.append(
|
await events.append(
|
||||||
completed.id, child.id, "completed", "user", _now_stamp(), True,
|
completed.id, child.id, "completed", "user", _now_stamp(), True,
|
||||||
actor_user_id="user-1",
|
actor_user_id="user-1",
|
||||||
@@ -521,12 +601,12 @@ def test_delete_future_uncompleted_cannot_backdate_cutoff(
|
|||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
today = datetime.date.today().isoformat()
|
today = datetime.date.today().isoformat()
|
||||||
await instances.upsert(definition.id, child.id, today, _now_stamp())
|
await instances.upsert(definition.id, child.id, today, _now_stamp(), window="morning")
|
||||||
deleted = await instances.delete_future_uncompleted(
|
deleted = await instances.delete_future_uncompleted(
|
||||||
definition.id, "2020-01-01"
|
definition.id, "2020-01-01"
|
||||||
)
|
)
|
||||||
assert deleted == 1
|
assert deleted == 1
|
||||||
assert await instances.get(definition.id, today) is None
|
assert await instances.get(definition.id, child.id, today, "morning") is None
|
||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
_with_db(tmp_path, "delete-no-backdate.db")(_body)
|
_with_db(tmp_path, "delete-no-backdate.db")(_body)
|
||||||
@@ -541,13 +621,13 @@ def test_delete_future_uncompleted_other_definitions_untouched(
|
|||||||
"Make bed", (await rules.create("daily", D1)).id,
|
"Make bed", (await rules.create("daily", D1)).id,
|
||||||
_now_stamp(), assignee_child_ids=[child.id],
|
_now_stamp(), assignee_child_ids=[child.id],
|
||||||
)
|
)
|
||||||
await instances.upsert(definition.id, child.id, D20, _now_stamp())
|
await instances.upsert(definition.id, child.id, D20, _now_stamp(), window="morning")
|
||||||
await instances.upsert(other.id, child.id, D20, _now_stamp())
|
await instances.upsert(other.id, child.id, D20, _now_stamp(), window="morning")
|
||||||
deleted = await instances.delete_future_uncompleted(
|
deleted = await instances.delete_future_uncompleted(
|
||||||
definition.id, D20
|
definition.id, D20
|
||||||
)
|
)
|
||||||
assert deleted == 1
|
assert deleted == 1
|
||||||
assert await instances.get(other.id, D20) is not None
|
assert await instances.get(other.id, child.id, D20, "morning") is not None
|
||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
_with_db(tmp_path, "delete-scoped.db")(_body)
|
_with_db(tmp_path, "delete-scoped.db")(_body)
|
||||||
@@ -562,8 +642,7 @@ def test_append_completion_round_trips(tmp_path) -> None:
|
|||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
instance = await instances.upsert(
|
instance = await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp()
|
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
event = await events.append(
|
event = await events.append(
|
||||||
instance.id, child.id, "completed", "user", _now_stamp(), True,
|
instance.id, child.id, "completed", "user", _now_stamp(), True,
|
||||||
actor_user_id="user-1",
|
actor_user_id="user-1",
|
||||||
@@ -582,10 +661,10 @@ def test_append_panel_event_round_trips(tmp_path) -> None:
|
|||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
instance = await instances.upsert(
|
instance = await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp()
|
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
event = await events.append(
|
event = await events.append(
|
||||||
instance.id, child.id, "completed", "panel", _now_stamp(), False,
|
instance.id, child.id, "completed", "panel", _now_stamp(), False,
|
||||||
|
actor_child_id=child.id,
|
||||||
actor_user_id=None,
|
actor_user_id=None,
|
||||||
)
|
)
|
||||||
assert event.actor_source == "panel"
|
assert event.actor_source == "panel"
|
||||||
@@ -600,8 +679,7 @@ def test_append_uncompleted_with_optional_on_time(tmp_path) -> None:
|
|||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
instance = await instances.upsert(
|
instance = await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp()
|
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
reversal = await events.append(
|
reversal = await events.append(
|
||||||
instance.id, child.id, "uncompleted", "user", _now_stamp(), None,
|
instance.id, child.id, "uncompleted", "user", _now_stamp(), None,
|
||||||
actor_user_id="user-1",
|
actor_user_id="user-1",
|
||||||
@@ -622,8 +700,7 @@ def test_append_validation_rejects_bad_shapes(tmp_path) -> None:
|
|||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
instance = await instances.upsert(
|
instance = await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp()
|
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
with pytest.raises(ValueError, match="event_type"):
|
with pytest.raises(ValueError, match="event_type"):
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "missed", "user", _now_stamp(), True,
|
instance.id, child.id, "missed", "user", _now_stamp(), True,
|
||||||
@@ -642,17 +719,31 @@ def test_append_validation_rejects_bad_shapes(tmp_path) -> None:
|
|||||||
with pytest.raises(ValueError, match="must not carry"):
|
with pytest.raises(ValueError, match="must not carry"):
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "completed", "panel", _now_stamp(), True,
|
instance.id, child.id, "completed", "panel", _now_stamp(), True,
|
||||||
|
actor_child_id=child.id,
|
||||||
actor_user_id="user-1",
|
actor_user_id="user-1",
|
||||||
)
|
)
|
||||||
|
with pytest.raises(ValueError, match="requires actor_child_id"):
|
||||||
|
await events.append(
|
||||||
|
instance.id, child.id, "completed", "panel",
|
||||||
|
_now_stamp(), True,
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="must not carry actor_child_id"):
|
||||||
|
await events.append(
|
||||||
|
instance.id, child.id, "completed", "user", _now_stamp(), True,
|
||||||
|
actor_user_id="user-1",
|
||||||
|
actor_child_id=child.id,
|
||||||
|
)
|
||||||
with pytest.raises(ValueError, match="requires was_on_time"):
|
with pytest.raises(ValueError, match="requires was_on_time"):
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "completed", "panel", _now_stamp(), None
|
instance.id, child.id, "completed", "panel", _now_stamp(), None,
|
||||||
|
actor_child_id=child.id
|
||||||
)
|
)
|
||||||
# was_on_time must be a real bool: int-convertible junk that
|
# was_on_time must be a real bool: int-convertible junk that
|
||||||
# would silently coerce (0.5 -> False) is rejected outright.
|
# would silently coerce (0.5 -> False) is rejected outright.
|
||||||
with pytest.raises(ValueError, match="must be True, False or None"):
|
with pytest.raises(ValueError, match="must be True, False or None"):
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "completed", "panel", _now_stamp(), 0.5
|
instance.id, child.id, "completed", "panel",
|
||||||
|
_now_stamp(), 0.5, actor_child_id=child.id,
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="must be True, False or None"):
|
with pytest.raises(ValueError, match="must be True, False or None"):
|
||||||
await events.append(
|
await events.append(
|
||||||
@@ -670,8 +761,7 @@ def test_append_rejects_non_utc_timestamps(tmp_path) -> None:
|
|||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
instance = await instances.upsert(
|
instance = await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp()
|
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
for bad in (
|
for bad in (
|
||||||
"2026-09-14 12:00:00+00:00", # space separator
|
"2026-09-14 12:00:00+00:00", # space separator
|
||||||
"2026-09-14T12:00:00", # no offset
|
"2026-09-14T12:00:00", # no offset
|
||||||
@@ -683,7 +773,7 @@ def test_append_rejects_non_utc_timestamps(tmp_path) -> None:
|
|||||||
with pytest.raises(ValueError, match="UTC"):
|
with pytest.raises(ValueError, match="UTC"):
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "completed", "panel", bad,
|
instance.id, child.id, "completed", "panel", bad,
|
||||||
True,
|
True, actor_child_id=child.id,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -695,8 +785,7 @@ def test_append_requires_was_on_time_strict_bool(tmp_path) -> None:
|
|||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
instance = await instances.upsert(
|
instance = await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp()
|
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
for bad in (0.5, 1.5, 2, "true", 1):
|
for bad in (0.5, 1.5, 2, "true", 1):
|
||||||
with pytest.raises(ValueError, match="must be True, False"):
|
with pytest.raises(ValueError, match="must be True, False"):
|
||||||
await events.append(
|
await events.append(
|
||||||
@@ -717,8 +806,7 @@ def test_append_rejects_unknown_instance_or_child(tmp_path) -> None:
|
|||||||
actor_user_id="user-1",
|
actor_user_id="user-1",
|
||||||
)
|
)
|
||||||
instance = await instances.upsert(
|
instance = await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp()
|
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
with pytest.raises(ValueError, match="belongs to child"):
|
with pytest.raises(ValueError, match="belongs to child"):
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, 999, "completed", "user", _now_stamp(), True,
|
instance.id, 999, "completed", "user", _now_stamp(), True,
|
||||||
@@ -740,17 +828,18 @@ def test_completed_uncompleted_recompleted_three_ordered_events(
|
|||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
instance = await instances.upsert(
|
instance = await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp()
|
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "completed", "panel", _now_stamp(), True
|
instance.id, child.id, "completed", "panel", _now_stamp(), True,
|
||||||
|
actor_child_id=child.id
|
||||||
)
|
)
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "uncompleted", "user", _now_stamp(), True,
|
instance.id, child.id, "uncompleted", "user", _now_stamp(), True,
|
||||||
actor_user_id="user-1",
|
actor_user_id="user-1",
|
||||||
)
|
)
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "completed", "panel", _now_stamp(), False
|
instance.id, child.id, "completed", "panel", _now_stamp(), False,
|
||||||
|
actor_child_id=child.id
|
||||||
)
|
)
|
||||||
history = await events.list_by_instance(instance.id)
|
history = await events.list_by_instance(instance.id)
|
||||||
assert [e.event_type for e in history] == [
|
assert [e.event_type for e in history] == [
|
||||||
@@ -771,8 +860,7 @@ def test_get_latest_for_instance_none_when_untouched(tmp_path) -> None:
|
|||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
instance = await instances.upsert(
|
instance = await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp()
|
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
return await events.get_latest_for_instance(instance.id)
|
return await events.get_latest_for_instance(instance.id)
|
||||||
|
|
||||||
assert _with_db(tmp_path, "latest-none.db")(_body) is None
|
assert _with_db(tmp_path, "latest-none.db")(_body) is None
|
||||||
@@ -782,10 +870,10 @@ def test_get_latest_returns_uncompleted_when_reversed(tmp_path) -> None:
|
|||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
instance = await instances.upsert(
|
instance = await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp()
|
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "completed", "panel", _now_stamp(), True
|
instance.id, child.id, "completed", "panel", _now_stamp(), True,
|
||||||
|
actor_child_id=child.id
|
||||||
)
|
)
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "uncompleted", "user", _now_stamp(), True,
|
instance.id, child.id, "uncompleted", "user", _now_stamp(), True,
|
||||||
@@ -802,26 +890,26 @@ def test_list_by_child_and_date_range_scopes_by_due_date(tmp_path) -> None:
|
|||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
early = await instances.upsert(
|
early = await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp()
|
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
inside = await instances.upsert(
|
inside = await instances.upsert(
|
||||||
definition.id, child.id, D2, _now_stamp()
|
definition.id, child.id, D2, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
late = await instances.upsert(
|
late = await instances.upsert(
|
||||||
definition.id, child.id, D20, _now_stamp()
|
definition.id, child.id, D20, _now_stamp(), window="morning")
|
||||||
|
await events.append(
|
||||||
|
early.id, child.id, "completed", "panel", _now_stamp(), True,
|
||||||
|
actor_child_id=child.id
|
||||||
)
|
)
|
||||||
await events.append(
|
await events.append(
|
||||||
early.id, child.id, "completed", "panel", _now_stamp(), True
|
inside.id, child.id, "completed", "panel", _now_stamp(), False,
|
||||||
)
|
actor_child_id=child.id
|
||||||
await events.append(
|
|
||||||
inside.id, child.id, "completed", "panel", _now_stamp(), False
|
|
||||||
)
|
)
|
||||||
await events.append(
|
await events.append(
|
||||||
inside.id, child.id, "uncompleted", "user", _now_stamp(), True,
|
inside.id, child.id, "uncompleted", "user", _now_stamp(), True,
|
||||||
actor_user_id="user-1",
|
actor_user_id="user-1",
|
||||||
)
|
)
|
||||||
await events.append(
|
await events.append(
|
||||||
late.id, child.id, "completed", "panel", _now_stamp(), True
|
late.id, child.id, "completed", "panel", _now_stamp(), True,
|
||||||
|
actor_child_id=child.id
|
||||||
)
|
)
|
||||||
found = await events.list_by_child_and_date_range(
|
found = await events.list_by_child_and_date_range(
|
||||||
child.id, D2, D2
|
child.id, D2, D2
|
||||||
@@ -847,8 +935,7 @@ def test_list_events_occurred_at_scope_variant(tmp_path) -> None:
|
|||||||
async def _body(database, children, rules, definitions, instances,
|
async def _body(database, children, rules, definitions, instances,
|
||||||
events, child, definition):
|
events, child, definition):
|
||||||
late_due = await instances.upsert(
|
late_due = await instances.upsert(
|
||||||
definition.id, child.id, D20, _now_stamp()
|
definition.id, child.id, D20, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
# The event's occurred_at is today at 08:00 UTC (_now_stamp);
|
# The event's occurred_at is today at 08:00 UTC (_now_stamp);
|
||||||
# the instance is due D20. The due-date scope queried on D20
|
# the instance is due D20. The due-date scope queried on D20
|
||||||
# still finds the event (scoping follows the instance's due
|
# still finds the event (scoping follows the instance's due
|
||||||
@@ -856,7 +943,7 @@ def test_list_events_occurred_at_scope_variant(tmp_path) -> None:
|
|||||||
# queried on today's date finds it too.
|
# queried on today's date finds it too.
|
||||||
event = await events.append(
|
event = await events.append(
|
||||||
late_due.id, child.id, "completed", "panel",
|
late_due.id, child.id, "completed", "panel",
|
||||||
_now_stamp(), False,
|
_now_stamp(), False, actor_child_id=child.id,
|
||||||
)
|
)
|
||||||
by_due = await events.list_by_child_and_date_range(
|
by_due = await events.list_by_child_and_date_range(
|
||||||
child.id, D20, D20
|
child.id, D20, D20
|
||||||
@@ -944,7 +1031,12 @@ def test_no_mutation_sql_for_completion_events_anywhere() -> None:
|
|||||||
# UPDATE/DELETE/non-DAO-INSERT paths hit the pattern.
|
# UPDATE/DELETE/non-DAO-INSERT paths hit the pattern.
|
||||||
insert_exempt = {
|
insert_exempt = {
|
||||||
"custom_components/nestquest/dao_instances.py",
|
"custom_components/nestquest/dao_instances.py",
|
||||||
|
"custom_components/nestquest/migrations.py", # the rebuild
|
||||||
|
# migrations re-create the table via INSERT..SELECT (append-
|
||||||
|
# order preserved); UPDATE/DELETE stays forbidden everywhere
|
||||||
"tests/test_schema.py",
|
"tests/test_schema.py",
|
||||||
|
"tests/test_migrations.py", # seeds frozen legacy shapes to
|
||||||
|
# exercise the rebuild migrations
|
||||||
"tests/test_dao_instances.py", # this guard file; scanned by
|
"tests/test_dao_instances.py", # this guard file; scanned by
|
||||||
# its own guard's regex spans below instead
|
# its own guard's regex spans below instead
|
||||||
}
|
}
|
||||||
@@ -1028,8 +1120,7 @@ def test_completion_events_dao_instance_has_no_mutation_capability(
|
|||||||
|
|
||||||
database.execute = _recording
|
database.execute = _recording
|
||||||
instance = await instances.upsert(
|
instance = await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp()
|
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "completed", "user", _now_stamp(), True,
|
instance.id, child.id, "completed", "user", _now_stamp(), True,
|
||||||
actor_user_id="user-1",
|
actor_user_id="user-1",
|
||||||
@@ -1059,8 +1150,7 @@ def test_append_concurrent_events_serialize(tmp_path) -> None:
|
|||||||
prepared
|
prepared
|
||||||
)
|
)
|
||||||
instance = await instances.upsert(
|
instance = await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp()
|
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
)
|
|
||||||
tasks = [
|
tasks = [
|
||||||
asyncio.ensure_future(
|
asyncio.ensure_future(
|
||||||
events.append(
|
events.append(
|
||||||
|
|||||||
+18
-11
@@ -112,7 +112,8 @@ class World:
|
|||||||
self.child.id, D1, D2, True, note="test"
|
self.child.id, D1, D2, True, note="test"
|
||||||
)
|
)
|
||||||
self.instance = await self.instances.upsert(
|
self.instance = await self.instances.upsert(
|
||||||
self.definition.id, self.child.id, D1, _stamp()
|
self.definition.id, self.child.id, D1, _stamp(),
|
||||||
|
window="morning",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -291,12 +292,15 @@ def test_matrix_presence_overrides_crud_and_constraints(tmp_path) -> None:
|
|||||||
def test_matrix_quest_instances_crud_and_constraints(tmp_path) -> None:
|
def test_matrix_quest_instances_crud_and_constraints(tmp_path) -> None:
|
||||||
async def _body(w: World):
|
async def _body(w: World):
|
||||||
# insert (seeded) + read
|
# insert (seeded) + read
|
||||||
fetched = await w.instances.get(w.definition.id, D1)
|
fetched = await w.instances.get(
|
||||||
|
w.definition.id, w.child.id, D1, "morning"
|
||||||
|
)
|
||||||
assert fetched == w.instance
|
assert fetched == w.instance
|
||||||
# update path: conflict refresh on the OPEN instance — same row
|
# update path: conflict refresh on the OPEN instance — same row
|
||||||
# id, snapshot columns (due_time) actually rewritten.
|
# id, snapshot columns (due_time) actually rewritten.
|
||||||
refreshed = await w.instances.upsert(
|
refreshed = await w.instances.upsert(
|
||||||
w.definition.id, w.child.id, D1, _stamp(), due_time="17:00"
|
w.definition.id, w.child.id, D1, _stamp(),
|
||||||
|
window="morning", due_time="17:00",
|
||||||
)
|
)
|
||||||
assert refreshed.id == fetched.id, (
|
assert refreshed.id == fetched.id, (
|
||||||
"the conflict path must refresh the existing row in place"
|
"the conflict path must refresh the existing row in place"
|
||||||
@@ -316,15 +320,15 @@ def test_matrix_quest_instances_constraints(tmp_path) -> None:
|
|||||||
# proving the schema backs the DAO's idempotency.
|
# proving the schema backs the DAO's idempotency.
|
||||||
await w.database.execute(
|
await w.database.execute(
|
||||||
"INSERT INTO quest_instances (definition_id, child_id, "
|
"INSERT INTO quest_instances (definition_id, child_id, "
|
||||||
"due_date, due_time, generated_at) "
|
"window, due_date, due_time, generated_at) "
|
||||||
"VALUES (?, ?, ?, NULL, ?)",
|
"VALUES (?, ?, 'morning', ?, NULL, ?)",
|
||||||
(w.definition.id, w.child.id, D20, _stamp()),
|
(w.definition.id, w.child.id, D20, _stamp()),
|
||||||
)
|
)
|
||||||
with pytest.raises(sqlite3.IntegrityError):
|
with pytest.raises(sqlite3.IntegrityError):
|
||||||
await w.database.execute(
|
await w.database.execute(
|
||||||
"INSERT INTO quest_instances (definition_id, child_id, "
|
"INSERT INTO quest_instances (definition_id, child_id, "
|
||||||
"due_date, due_time, generated_at) "
|
"window, due_date, due_time, generated_at) "
|
||||||
"VALUES (?, ?, ?, NULL, ?)",
|
"VALUES (?, ?, 'morning', ?, NULL, ?)",
|
||||||
(w.definition.id, w.child.id, D20, _stamp()),
|
(w.definition.id, w.child.id, D20, _stamp()),
|
||||||
)
|
)
|
||||||
# delete future uncompleted removes D20's open instance
|
# delete future uncompleted removes D20's open instance
|
||||||
@@ -340,10 +344,11 @@ def test_matrix_quest_instances_constraints(tmp_path) -> None:
|
|||||||
def test_matrix_quest_instances_upsert_idempotency(tmp_path) -> None:
|
def test_matrix_quest_instances_upsert_idempotency(tmp_path) -> None:
|
||||||
async def _body(w: World):
|
async def _body(w: World):
|
||||||
first = await w.instances.upsert(
|
first = await w.instances.upsert(
|
||||||
w.definition.id, w.child.id, D2, _stamp()
|
w.definition.id, w.child.id, D2, _stamp(), window="morning"
|
||||||
)
|
)
|
||||||
second = await w.instances.upsert(
|
second = await w.instances.upsert(
|
||||||
w.definition.id, w.child.id, D2, _stamp(), due_time="17:00"
|
w.definition.id, w.child.id, D2, _stamp(),
|
||||||
|
window="morning", due_time="17:00",
|
||||||
)
|
)
|
||||||
assert second.id == first.id
|
assert second.id == first.id
|
||||||
count = await w.database.fetch_one(
|
count = await w.database.fetch_one(
|
||||||
@@ -367,14 +372,16 @@ def test_matrix_completion_events_append_read_and_no_mutation(
|
|||||||
async def _body(w: World):
|
async def _body(w: World):
|
||||||
# insert: append a completion then a reversal, then re-complete
|
# insert: append a completion then a reversal, then re-complete
|
||||||
await w.events.append(
|
await w.events.append(
|
||||||
w.instance.id, w.child.id, "completed", "panel", _stamp(), True
|
w.instance.id, w.child.id, "completed", "panel", _stamp(), True,
|
||||||
|
actor_child_id=w.child.id,
|
||||||
)
|
)
|
||||||
await w.events.append(
|
await w.events.append(
|
||||||
w.instance.id, w.child.id, "uncompleted", "user", _stamp(), None,
|
w.instance.id, w.child.id, "uncompleted", "user", _stamp(), None,
|
||||||
actor_user_id="user-1",
|
actor_user_id="user-1",
|
||||||
)
|
)
|
||||||
await w.events.append(
|
await w.events.append(
|
||||||
w.instance.id, w.child.id, "completed", "panel", _stamp(), False
|
w.instance.id, w.child.id, "completed", "panel", _stamp(), False,
|
||||||
|
actor_child_id=w.child.id,
|
||||||
)
|
)
|
||||||
# read: full ordered history + latest
|
# read: full ordered history + latest
|
||||||
history = await w.events.list_by_instance(w.instance.id)
|
history = await w.events.list_by_instance(w.instance.id)
|
||||||
|
|||||||
@@ -927,7 +927,7 @@ def test_definition_assignment_leaves_history_rows_alone(
|
|||||||
# this table, but its row shape proves assignment isolation).
|
# this table, but its row shape proves assignment isolation).
|
||||||
await database.execute(
|
await database.execute(
|
||||||
"INSERT INTO quest_instances (definition_id, child_id, "
|
"INSERT INTO quest_instances (definition_id, child_id, "
|
||||||
"due_date, generated_at) VALUES (?, ?, ?, ?)",
|
"window, due_date, generated_at) VALUES (?, ?, 'morning', ?, ?)",
|
||||||
(definition.id, child.id, "2026-09-15", NOW),
|
(definition.id, child.id, "2026-09-15", NOW),
|
||||||
)
|
)
|
||||||
await definitions.add_assignee(definition.id, other.id)
|
await definitions.add_assignee(definition.id, other.id)
|
||||||
|
|||||||
+107
-8
@@ -149,13 +149,17 @@ def test_migration_list_shape() -> None:
|
|||||||
(D-007) to create the quest_* tables directly; migration 2 exists
|
(D-007) to create the quest_* tables directly; migration 2 exists
|
||||||
for dev databases stamped version 1 by the pre-rename runner,
|
for dev databases stamped version 1 by the pre-rename runner,
|
||||||
migration 3 brings pre-D-008 definitions to the multi-assignee
|
migration 3 brings pre-D-008 definitions to the multi-assignee
|
||||||
model, and migration 4 adds the windows table.
|
model, migration 4 adds the windows table, migration 5 rebuilds
|
||||||
|
quest_instances onto the widened (definition, child, date, window)
|
||||||
|
key, and migration 6 adds actor_child_id to completion_events.
|
||||||
"""
|
"""
|
||||||
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS
|
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS
|
||||||
assert callable(MIGRATIONS[1])
|
assert callable(MIGRATIONS[1])
|
||||||
assert callable(MIGRATIONS[2])
|
assert callable(MIGRATIONS[2])
|
||||||
assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL
|
assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL
|
||||||
assert len(MIGRATIONS) == 4
|
assert callable(MIGRATIONS[4])
|
||||||
|
assert callable(MIGRATIONS[5])
|
||||||
|
assert len(MIGRATIONS) == 6
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -425,6 +429,35 @@ _V2_DEFINITIONS_DDL = [
|
|||||||
""",
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
_V2_INSTANCES_DDL = [
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS quest_instances (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
|
||||||
|
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||||
|
due_date TEXT NOT NULL,
|
||||||
|
due_time TEXT,
|
||||||
|
generated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (definition_id, due_date)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
|
||||||
|
_V2_EVENTS_DDL = [
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS completion_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
|
||||||
|
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
actor_source TEXT NOT NULL,
|
||||||
|
actor_user_id TEXT,
|
||||||
|
occurred_at TEXT NOT NULL,
|
||||||
|
was_on_time INTEGER
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _seed_v2_multi_assignee_upgrade(database) -> None:
|
def _seed_v2_multi_assignee_upgrade(database) -> None:
|
||||||
"""Create a database shaped like a post-rename, pre-D-008 dev file.
|
"""Create a database shaped like a post-rename, pre-D-008 dev file.
|
||||||
@@ -433,11 +466,21 @@ def _seed_v2_multi_assignee_upgrade(database) -> None:
|
|||||||
single child_id column and no assignees table exists — stamped at
|
single child_id column and no assignees table exists — stamped at
|
||||||
version 2, with definition and instance rows that must survive.
|
version 2, with definition and instance rows that must survive.
|
||||||
"""
|
"""
|
||||||
|
# Freeze the version-2 shape: definitions carry child_id, the
|
||||||
|
# instance table has no window column, and the assignees/windows
|
||||||
|
# tables do not exist yet — everything else matches the current DDL.
|
||||||
|
_v2_replaced = (
|
||||||
|
"quest_definitions ",
|
||||||
|
"quest_definition_assignees ",
|
||||||
|
"quest_definition_windows ",
|
||||||
|
"quest_instances ",
|
||||||
|
"completion_events ",
|
||||||
|
)
|
||||||
for sql in SCHEMA_V1_STATEMENTS:
|
for sql in SCHEMA_V1_STATEMENTS:
|
||||||
if "quest_definitions " in sql:
|
if any(name in sql for name in _v2_replaced):
|
||||||
continue # replaced by the legacy-shape DDL below
|
continue
|
||||||
_run(database.execute(sql))
|
_run(database.execute(sql))
|
||||||
for sql in _V2_DEFINITIONS_DDL:
|
for sql in _V2_DEFINITIONS_DDL + _V2_INSTANCES_DDL + _V2_EVENTS_DDL:
|
||||||
_run(database.execute(sql))
|
_run(database.execute(sql))
|
||||||
_run(database.execute(VERSION_TABLE_DDL))
|
_run(database.execute(VERSION_TABLE_DDL))
|
||||||
_run(
|
_run(
|
||||||
@@ -477,6 +520,26 @@ def _seed_v2_multi_assignee_upgrade(database) -> None:
|
|||||||
"VALUES (1, 2, '2026-09-15', '2026-09-14T00:00:00+00:00')"
|
"VALUES (1, 2, '2026-09-15', '2026-09-14T00:00:00+00:00')"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# One legacy panel event and one legacy admin event: the panel one
|
||||||
|
# must backfill actor_child_id to its own child; the admin one must
|
||||||
|
# stay NULL.
|
||||||
|
_run(
|
||||||
|
database.execute(
|
||||||
|
"INSERT INTO completion_events (instance_id, child_id, "
|
||||||
|
"event_type, actor_source, occurred_at, was_on_time) "
|
||||||
|
"VALUES (1, 2, 'completed', 'panel', "
|
||||||
|
"'2026-09-15T10:00:00+00:00', 1)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_run(
|
||||||
|
database.execute(
|
||||||
|
"INSERT INTO completion_events (instance_id, child_id, "
|
||||||
|
"event_type, actor_source, actor_user_id, occurred_at, "
|
||||||
|
"was_on_time) "
|
||||||
|
"VALUES (1, 2, 'uncompleted', 'user', 'parent-1', "
|
||||||
|
"'2026-09-15T11:00:00+00:00', 1)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
|
def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
|
||||||
@@ -506,14 +569,50 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert assignees == [(1, 2)]
|
assert assignees == [(1, 2)]
|
||||||
# The instance survived, still referencing the definition.
|
# The instance survived the rebuild, pinned to the migrated
|
||||||
|
# window, still referencing the definition.
|
||||||
instance = _run(
|
instance = _run(
|
||||||
database.fetch_one(
|
database.fetch_one(
|
||||||
"SELECT definition_id, child_id, due_date "
|
"SELECT definition_id, child_id, window, due_date "
|
||||||
"FROM quest_instances WHERE id = 1"
|
"FROM quest_instances WHERE id = 1"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert instance == (1, 2, "2026-09-15")
|
assert instance == (1, 2, "morning", "2026-09-15")
|
||||||
|
# The rebuilt instance table carries the widened unique key.
|
||||||
|
index_rows = _run(
|
||||||
|
database.fetch_all(
|
||||||
|
"PRAGMA index_list(quest_instances)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
unique_columns = []
|
||||||
|
for index_row in index_rows:
|
||||||
|
if index_row[2]: # unique flag
|
||||||
|
info = _run(
|
||||||
|
database.fetch_all(
|
||||||
|
f"PRAGMA index_info('{index_row[1]}')"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
unique_columns.append([col[2] for col in info])
|
||||||
|
assert [
|
||||||
|
"definition_id", "child_id", "due_date", "window"
|
||||||
|
] in unique_columns
|
||||||
|
# The completion events survived the actor_child_id rebuild:
|
||||||
|
# the panel row backfilled to its own child, the admin row
|
||||||
|
# stayed NULL, and ids are preserved.
|
||||||
|
events = _run(
|
||||||
|
database.fetch_all(
|
||||||
|
"SELECT id, event_type, actor_source, actor_child_id "
|
||||||
|
"FROM completion_events ORDER BY id"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert events == [
|
||||||
|
(1, "completed", "panel", 2),
|
||||||
|
(2, "uncompleted", "user", None),
|
||||||
|
]
|
||||||
|
columns = _run(
|
||||||
|
database.fetch_all("PRAGMA table_info(completion_events)")
|
||||||
|
)
|
||||||
|
assert "actor_child_id" in {c[1] for c in columns}
|
||||||
# No dangling references anywhere.
|
# No dangling references anywhere.
|
||||||
assert _run(database.fetch_all("PRAGMA foreign_key_check")) == []
|
assert _run(database.fetch_all("PRAGMA foreign_key_check")) == []
|
||||||
# The rebuilt table is live for the new model: a definition
|
# The rebuilt table is live for the new model: a definition
|
||||||
|
|||||||
+152
-12
@@ -1722,8 +1722,8 @@ def test_applying_presence_ddl_twice_keeps_rows(tmp_path) -> None:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _insert_instance(database, definition_id=1, **overrides):
|
def _insert_instance(database, definition_id=1, window="morning", **overrides):
|
||||||
"""Insert one valid task_instance, returning the instance id.
|
"""Insert one valid quest_instance, returning the instance id.
|
||||||
|
|
||||||
Pass ``_OMIT`` as a value to leave that column out of the INSERT so
|
Pass ``_OMIT`` as a value to leave that column out of the INSERT so
|
||||||
its DDL DEFAULT applies.
|
its DDL DEFAULT applies.
|
||||||
@@ -1731,6 +1731,7 @@ def _insert_instance(database, definition_id=1, **overrides):
|
|||||||
values: dict[str, object] = {
|
values: dict[str, object] = {
|
||||||
"definition_id": definition_id,
|
"definition_id": definition_id,
|
||||||
"child_id": 1,
|
"child_id": 1,
|
||||||
|
"window": window,
|
||||||
"due_date": "2026-09-14",
|
"due_date": "2026-09-14",
|
||||||
"generated_at": "2026-09-14T00:00:00+00:00",
|
"generated_at": "2026-09-14T00:00:00+00:00",
|
||||||
}
|
}
|
||||||
@@ -1829,6 +1830,7 @@ def test_quest_instances_columns_types_and_constraints(tmp_path) -> None:
|
|||||||
("id", "INTEGER", 0, None, 1),
|
("id", "INTEGER", 0, None, 1),
|
||||||
("definition_id", "INTEGER", 1, None, 0),
|
("definition_id", "INTEGER", 1, None, 0),
|
||||||
("child_id", "INTEGER", 1, None, 0),
|
("child_id", "INTEGER", 1, None, 0),
|
||||||
|
("window", "TEXT", 1, None, 0),
|
||||||
("due_date", "TEXT", 1, None, 0),
|
("due_date", "TEXT", 1, None, 0),
|
||||||
("due_time", "TEXT", 0, None, 0),
|
("due_time", "TEXT", 0, None, 0),
|
||||||
("generated_at", "TEXT", 1, None, 0),
|
("generated_at", "TEXT", 1, None, 0),
|
||||||
@@ -1849,6 +1851,7 @@ def test_completion_events_columns_types_and_constraints(tmp_path) -> None:
|
|||||||
("event_type", "TEXT", 1, None, 0),
|
("event_type", "TEXT", 1, None, 0),
|
||||||
("actor_source", "TEXT", 1, None, 0),
|
("actor_source", "TEXT", 1, None, 0),
|
||||||
("actor_user_id", "TEXT", 0, None, 0),
|
("actor_user_id", "TEXT", 0, None, 0),
|
||||||
|
("actor_child_id", "INTEGER", 0, None, 0),
|
||||||
("occurred_at", "TEXT", 1, None, 0),
|
("occurred_at", "TEXT", 1, None, 0),
|
||||||
("was_on_time", "INTEGER", 0, None, 0),
|
("was_on_time", "INTEGER", 0, None, 0),
|
||||||
]
|
]
|
||||||
@@ -1879,12 +1882,13 @@ def test_completion_events_foreign_keys_declared(tmp_path) -> None:
|
|||||||
assert declared == {
|
assert declared == {
|
||||||
("quest_instances", "instance_id", "id"),
|
("quest_instances", "instance_id", "id"),
|
||||||
("children", "child_id", "id"),
|
("children", "child_id", "id"),
|
||||||
|
("children", "actor_child_id", "id"),
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
_run(database.close())
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
def test_quest_instances_unique_index_on_definition_and_date(tmp_path) -> None:
|
def test_quest_instances_unique_index_on_widened_key(tmp_path) -> None:
|
||||||
database = _open_db(tmp_path / "task-instances-unique.db")
|
database = _open_db(tmp_path / "task-instances-unique.db")
|
||||||
try:
|
try:
|
||||||
_apply(database)
|
_apply(database)
|
||||||
@@ -1897,15 +1901,20 @@ def test_quest_instances_unique_index_on_definition_and_date(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
implicit = [(name, sql) for name, sql in rows if sql is None]
|
implicit = [(name, sql) for name, sql in rows if sql is None]
|
||||||
assert len(implicit) == 1, (
|
assert len(implicit) == 1, (
|
||||||
"the (definition_id, due_date) UNIQUE constraint must be backed "
|
"the (definition_id, child_id, due_date, window) UNIQUE "
|
||||||
"by exactly one implicit unique index"
|
"constraint must be backed by exactly one implicit unique index"
|
||||||
)
|
)
|
||||||
columns = _run(
|
columns = _run(
|
||||||
database.fetch_all(
|
database.fetch_all(
|
||||||
f"PRAGMA index_info('{implicit[0][0]}')"
|
f"PRAGMA index_info('{implicit[0][0]}')"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert [row[2] for row in columns] == ["definition_id", "due_date"]
|
assert [row[2] for row in columns] == [
|
||||||
|
"definition_id",
|
||||||
|
"child_id",
|
||||||
|
"due_date",
|
||||||
|
"window",
|
||||||
|
]
|
||||||
# The implicit index is genuinely unique: UNIQUE(...) is the only
|
# The implicit index is genuinely unique: UNIQUE(...) is the only
|
||||||
# constraint SQLite backs with a sql-less autoindex on this table.
|
# constraint SQLite backs with a sql-less autoindex on this table.
|
||||||
row = _run(
|
row = _run(
|
||||||
@@ -1932,12 +1941,16 @@ def test_quest_instances_valid_insert_round_trips(tmp_path) -> None:
|
|||||||
instance_id = _insert_instance(database, definition_id=1, child_id=1)
|
instance_id = _insert_instance(database, definition_id=1, child_id=1)
|
||||||
row = _run(
|
row = _run(
|
||||||
database.fetch_one(
|
database.fetch_one(
|
||||||
"SELECT definition_id, child_id, due_date, due_time, generated_at "
|
"SELECT definition_id, child_id, window, due_date, "
|
||||||
|
"due_time, generated_at "
|
||||||
"FROM quest_instances WHERE id = ?",
|
"FROM quest_instances WHERE id = ?",
|
||||||
(instance_id,),
|
(instance_id,),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert row == (1, 1, "2026-09-14", None, "2026-09-14T00:00:00+00:00")
|
assert row == (
|
||||||
|
1, 1, "morning", "2026-09-14", None,
|
||||||
|
"2026-09-14T00:00:00+00:00",
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
_run(database.close())
|
_run(database.close())
|
||||||
|
|
||||||
@@ -1961,7 +1974,7 @@ def test_quest_instances_due_time_round_trips(tmp_path) -> None:
|
|||||||
_run(database.close())
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
def test_quest_instances_duplicate_definition_date_fails(tmp_path) -> None:
|
def test_quest_instances_duplicate_tuple_fails(tmp_path) -> None:
|
||||||
database = _open_db(tmp_path / "task-instances-duplicate.db")
|
database = _open_db(tmp_path / "task-instances-duplicate.db")
|
||||||
try:
|
try:
|
||||||
_apply(database)
|
_apply(database)
|
||||||
@@ -1973,6 +1986,78 @@ def test_quest_instances_duplicate_definition_date_fails(tmp_path) -> None:
|
|||||||
_run(database.close())
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
def test_quest_instances_duplicate_tuple_different_child_allowed(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
"""D-008: same definition, date and window for two different
|
||||||
|
assignees of the same definition are two distinct instances."""
|
||||||
|
database = _open_db(tmp_path / "task-instances-shared.db")
|
||||||
|
try:
|
||||||
|
_apply(database)
|
||||||
|
_setup_definition(database)
|
||||||
|
_run(
|
||||||
|
database.execute(
|
||||||
|
"INSERT INTO children (display_name, created_at) VALUES (?, ?)",
|
||||||
|
("Bo", "2026-09-13T00:00:00+00:00"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_run(
|
||||||
|
database.execute(
|
||||||
|
"INSERT INTO quest_definition_assignees "
|
||||||
|
"(definition_id, child_id) VALUES (1, 2)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_insert_instance(database, definition_id=1, child_id=1)
|
||||||
|
_insert_instance(database, definition_id=1, child_id=2)
|
||||||
|
row = _run(database.fetch_one("SELECT COUNT(*) FROM quest_instances"))
|
||||||
|
assert row == (2,)
|
||||||
|
finally:
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
def test_quest_instances_duplicate_tuple_different_window_allowed(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
"""D-008 twice-daily case: one definition, one child, one date,
|
||||||
|
two windows — two rows."""
|
||||||
|
database = _open_db(tmp_path / "task-instances-twice-daily.db")
|
||||||
|
try:
|
||||||
|
_apply(database)
|
||||||
|
_setup_definition(database)
|
||||||
|
_insert_instance(database, definition_id=1, window="morning")
|
||||||
|
_insert_instance(database, definition_id=1, window="evening")
|
||||||
|
rows = _run(
|
||||||
|
database.fetch_all(
|
||||||
|
"SELECT window FROM quest_instances ORDER BY window"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert [row[0] for row in rows] == ["evening", "morning"]
|
||||||
|
finally:
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
def test_quest_instances_unknown_window_fails(tmp_path) -> None:
|
||||||
|
database = _open_db(tmp_path / "task-instances-bad-window.db")
|
||||||
|
try:
|
||||||
|
_apply(database)
|
||||||
|
_setup_definition(database)
|
||||||
|
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
|
||||||
|
_insert_instance(database, definition_id=1, window="noon")
|
||||||
|
finally:
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
def test_quest_instances_window_not_null_enforced(tmp_path) -> None:
|
||||||
|
database = _open_db(tmp_path / "task-instances-window-null.db")
|
||||||
|
try:
|
||||||
|
_apply(database)
|
||||||
|
_setup_definition(database)
|
||||||
|
with pytest.raises(sqlite3.IntegrityError, match="NOT NULL"):
|
||||||
|
_insert_instance(database, definition_id=1, window=_OMIT)
|
||||||
|
finally:
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
def test_quest_instances_same_date_different_definitions_allowed(
|
def test_quest_instances_same_date_different_definitions_allowed(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -2099,16 +2184,71 @@ def test_completion_events_valid_panel_completion_round_trips(tmp_path) -> None:
|
|||||||
instance_id=instance_id,
|
instance_id=instance_id,
|
||||||
actor_source="panel",
|
actor_source="panel",
|
||||||
actor_user_id=None,
|
actor_user_id=None,
|
||||||
|
actor_child_id=1,
|
||||||
was_on_time=0,
|
was_on_time=0,
|
||||||
)
|
)
|
||||||
row = _run(
|
row = _run(
|
||||||
database.fetch_one(
|
database.fetch_one(
|
||||||
"SELECT actor_source, actor_user_id, was_on_time "
|
"SELECT actor_source, actor_user_id, actor_child_id, "
|
||||||
"FROM completion_events WHERE id = ?",
|
"was_on_time FROM completion_events WHERE id = ?",
|
||||||
(event_id,),
|
(event_id,),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert row == ("panel", None, 0)
|
assert row == ("panel", None, 1, 0)
|
||||||
|
finally:
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("actor_source", "actor_user_id", "actor_child_id"),
|
||||||
|
[
|
||||||
|
("user", "user-1", 1), # admin event must not carry a profile
|
||||||
|
("panel", None, None), # panel event requires the profile
|
||||||
|
("panel", "user-1", 1), # panel must not carry a user id either
|
||||||
|
("panel", "user-1", None),
|
||||||
|
("user", None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_completion_events_actor_child_coherence_enforced(
|
||||||
|
tmp_path, actor_source, actor_user_id, actor_child_id
|
||||||
|
) -> None:
|
||||||
|
"""D-008 actor-pair shape: 'user' events carry actor_user_id and no
|
||||||
|
actor_child_id; 'panel' events carry actor_child_id and no
|
||||||
|
actor_user_id. The CHECKs make both halves non-negotiable."""
|
||||||
|
database = _open_db(tmp_path / "completion-events-coherence.db")
|
||||||
|
try:
|
||||||
|
_apply(database)
|
||||||
|
_setup_definition(database)
|
||||||
|
instance_id = _insert_instance(database, definition_id=1)
|
||||||
|
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
|
||||||
|
_insert_event(
|
||||||
|
database,
|
||||||
|
instance_id=instance_id,
|
||||||
|
event_type="completed",
|
||||||
|
actor_source=actor_source,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
actor_child_id=actor_child_id,
|
||||||
|
was_on_time=1,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
def test_completion_events_actor_child_unknown_child_fails(tmp_path) -> None:
|
||||||
|
database = _open_db(tmp_path / "completion-events-bad-actor-child.db")
|
||||||
|
try:
|
||||||
|
_apply(database)
|
||||||
|
_setup_definition(database)
|
||||||
|
instance_id = _insert_instance(database, definition_id=1)
|
||||||
|
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
|
||||||
|
_insert_event(
|
||||||
|
database,
|
||||||
|
instance_id=instance_id,
|
||||||
|
actor_source="panel",
|
||||||
|
actor_user_id=None,
|
||||||
|
actor_child_id=999,
|
||||||
|
was_on_time=1,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
_run(database.close())
|
_run(database.close())
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user