diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 6de8b0c..45cdc0b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,49 @@ # NestQuest Release Notes +## Version 0.4.0 — 2026-09-15 + +### Scope + +Fourth release of NestQuest, shipping three completed features on top of the data layer and recurrence engine: + +- **Feature 14 — quest domain model (BREAKING rename, D-007/D-008):** `task_definitions`/`task_instances` are now `quest_definitions`/`quest_instances` everywhere; definitions are multi-assignee (`quest_definition_assignees`, no single child column) and multi-window (`quest_definition_windows` — morning/afternoon/evening, clock ranges in `const.py`); the instance key widens to `(definition_id, child_id, due_date, window)` so twice-daily and shared quests materialise one instance per child per window per day; `completion_events` gains `actor_child_id` (the tapped panel profile; admin events stay NULL) with a second actor-pair CHECK. Schema version advances to **6**. +- **Feature 03 — children registry & admin allowlist:** typed business layer for child profiles (create/edit with trimmed required names and duplicate-name warnings, whole-table reorder, deactivation as the only removal path — history is never touched); the admin allowlist with a fail-closed `is_admin` resolver (empty allowlist denies everyone; the last admin cannot be removed); automatic seeding on first setup (the config-flow user is persisted in entry data, with the options-flow list and HA owner accounts as fallbacks) so the owner is never locked out; the options flow gains a multi-select picker of existing Home Assistant users. +- **Feature 05 — presence & custody engine:** a pure `PresenceSchedule`/`PresenceOverride` model with total validation and lossless round-trips; anchor-date cycle arithmetic (D-004 — never ISO week numbers or parity); `PresenceEngine.is_present` with the always-present default for schedule-free children; date overrides beat the repeating pattern; a `next_present_dates` preview for the admin schedule UI; a full-year regression guard walking 2020 (366 days, ISO week 53) and 2015 (365 days, ISO week 53) proving the rotation never inverts at a year boundary. + +### Requirements + +- Minimum Home Assistant version: **2024.6.0**. + +### Breaking changes + +- The database schema advances from version 1 to version 6. Upgrades are automatic and data-preserving (legacy `task_*` tables are renamed; the definitions table is rebuilt for multi-assignee; legacy instances pin to the morning window; panel events backfill `actor_child_id`). **Back up `nestquest.db` before upgrading** — see Rollback. +- Dev installs from before 2026-09-15 migrate automatically on first start. + +### Known Limitations + +- Still no entities, services, calendar platforms, or panel cards: nothing is user-visible in Home Assistant yet (Features 06-10 land the business layer, materialisation, permission gate, and entity surface). +- No materialisation yet: instances are not generated from definitions + presence yet (Feature 07). +- Single-instance only. + +### Rollback + +**Back up `nestquest.db` (plus `-wal`/`-shm` sidecars) BEFORE upgrading.** This release migrates the database to schema version 6 with renamed tables; an older NestQuest build refuses a version-6 database ("newer than this integration understands") and cannot read renamed tables at all — a plain downgrade is NOT possible once the database has been migrated. + +**Rollback procedure (after a migrated database):** + +1. Stop Home Assistant. +2. Restore the pre-upgrade `nestquest.db` backup (and sidecars) you made before upgrading. +3. Downgrade the integration (HACS > Integrations > NestQuest > Redownload > 0.3.0) or replace `custom_components/nestquest/` manually. +4. Restart Home Assistant. + +Without a backup, the only path is staying on 0.4.0 (recommended) or deleting the integration and its database to start fresh (Settings > Devices & Services > NestQuest > Delete, then remove `nestquest.db`). + +**Repository operators:** instead of uninstalling, git-revert the release merge on `main` and restore the pre-upgrade database. + +## Version 0.3.0 — 2026-09-14 + +### Scope + ## Version 0.3.0 — 2026-09-14 ### Scope diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 65693e3..91a962d 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -13,10 +13,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN, LOGGER +from .const import CONF_ADMIN_USER_IDS, DOMAIN, LOGGER from .db import NestQuestDatabase from .migrations import apply_migrations from .store import async_get_db_path +from .admin_allowlist import seed_setup_admin _LOGGER = LOGGER @@ -225,6 +226,28 @@ class NestQuestRuntimeData: remove_update_listener: Callable[[], Any] +async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]: + """Return every Home Assistant owner-account user id. + + The last-resort admin seed: when no persisted allowlist copy and no + flow-context user survive to first setup, the HA owner accounts are + seeded so the household is never left with zero admins (an empty + allowlist fails closed, which would lock the household out of its + own integration). Owner accounts can already do everything in HA, + so granting NestQuest admin is not a privilege escalation. + """ + auth = getattr(hass, "auth", None) + if auth is None: + return [] + users = await auth.async_get_users() + owners = [ + user.id + for user in users + if getattr(user, "is_owner", False) and getattr(user, "id", None) + ] + return owners + + async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: """Set up the NestQuest integration. YAML configuration is not used, returns True.""" return True @@ -241,10 +264,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # record without a live connection (a previous setup's # close-on-failure, or a hand-replaced file) must get the # missing/valid/corrupt classification too, never a raw - # sqlite3 error and never skipped migrations. + # sqlite3 error and never skipped migrations — and the + # same allowlist seeding, so a reopened fresh file is + # never ownerless. db = await _async_open_database( hass, await async_get_db_path(hass) ) + try: + stored_admin_ids = ( + entry.options.get(CONF_ADMIN_USER_IDS) + or entry.data.get(CONF_ADMIN_USER_IDS) + ) + context = getattr(entry, "context", None) + context_user_id = ( + context.get("user_id") + if isinstance(context, dict) + else None + ) + await seed_setup_admin( + db, + stored_admin_ids=stored_admin_ids, + context_user_id=context_user_id, + owner_ids=await _async_owner_user_ids(hass), + ) + except BaseException: + # A seeding failure must not leak the just-opened + # connection: close it before the error propagates, so + # HA's setup retry starts from a clean handle (the + # first-setup path closes symmetrically). + await db.close() + raise existing.database = db entry.runtime_data = existing return True @@ -257,6 +306,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: database = await _async_open_database(hass, await async_get_db_path(hass)) try: + # Seed the admin allowlist on first setup so the owner is never + # locked out: an empty database takes the persisted admin copy + # — the options flow's saved list first (it is the current + # one), else the config flow's user from entry data — and only + # then the HA user still present on the entry context. No-op + # on a non-empty list (fail-closed narrowing is deliberate). + stored_admin_ids = ( + entry.options.get(CONF_ADMIN_USER_IDS) + or entry.data.get(CONF_ADMIN_USER_IDS) + ) + context = getattr(entry, "context", None) + context_user_id = ( + context.get("user_id") + if isinstance(context, dict) + else None + ) + owner_ids = await _async_owner_user_ids(hass) + await seed_setup_admin( + database, + stored_admin_ids=stored_admin_ids, + context_user_id=context_user_id, + owner_ids=owner_ids, + ) remove_update_listener = entry.add_update_listener(_async_update_listener) except BaseException: await database.close() diff --git a/custom_components/nestquest/admin_allowlist.py b/custom_components/nestquest/admin_allowlist.py new file mode 100644 index 0000000..beed12c --- /dev/null +++ b/custom_components/nestquest/admin_allowlist.py @@ -0,0 +1,153 @@ +"""Business layer for the admin allowlist (Feature 03). + +Sits between the options flow / Feature 09 service gate and the typed +:class:`~.dao_children.AdminUsersDao`. The allowlist stores Home +Assistant user IDs — never usernames or display names (feature +guardrail) — and an empty allowlist must fail closed: every function +here refuses to write an empty allowlist, so the owner can never be +accidentally locked out of admin operations by a bad save. + +Shape policy: a user id is a non-empty trimmed string. Duplicate ids +in one write are rejected up front — they would silently map to one +row and hide a caller bug. Writes replace the allowlist atomically: +the diff (additions and removals) is computed and applied inside one +transaction under the connection-scoped lock shared with the DAOs, so +a concurrent reader never observes a half-applied sync. + +Seed policy (:func:`seed_setup_admin`): the allowlist is seeded only +when it is EMPTY, so re-running setup never resurrects a deliberately +narrowed list. The setup user (the HA user who completed the config +flow, via the entry context) is added automatically so the owner is +never locked out on first setup; a stored entry-data copy of the list +re-seeds a lost database. +""" +from __future__ import annotations + +import datetime +import logging + +from .dao_children import AdminUsersDao, _connection_lock +from .db import NestQuestDatabase + +LOGGER = logging.getLogger(__name__) + + +def _now_stamp() -> str: + """UTC second-precision stamp, matching the registry's policy.""" + return datetime.datetime.now(datetime.timezone.utc).isoformat( + timespec="seconds" + ) + + +def _validate_user_id(value: object) -> str: + """Return ``value`` trimmed; raise unless a non-empty string.""" + if not isinstance(value, str): + raise ValueError( + f"admin user id must be a string, got {value!r}" + ) + trimmed = value.strip() + if not trimmed: + raise ValueError("admin user id must not be empty") + return trimmed + + +async def list_admin_ids(database: NestQuestDatabase) -> list[str]: + """Return the allowlisted HA user IDs in storage order.""" + return [row.ha_user_id for row in await AdminUsersDao(database).list()] + + +async def set_admin_ids( + database: NestQuestDatabase, user_ids: list[str] +) -> None: + """Replace the allowlist with exactly ``user_ids`` (atomic sync). + + Refuses an empty list — an empty allowlist must fail closed, and + the only way to reach one through this API would be removing the + last admin, so that removal is refused here with a clear error. + Refuses non-string, empty, or duplicate ids up front. The diff + against the current allowlist (additions and removals) applies + inside one transaction under the connection lock. + """ + if not isinstance(user_ids, list): + raise ValueError("user_ids must be a list of HA user id strings") + normalized: list[str] = [] + for value in user_ids: + normalized.append(_validate_user_id(value)) + if len(set(normalized)) != len(normalized): + raise ValueError("user_ids must not contain duplicates") + if not normalized: + raise ValueError( + "refusing to empty the admin allowlist: at least one admin " + "must remain (an empty allowlist fails closed)" + ) + dao = AdminUsersDao(database) + async with _connection_lock(database): + async with database.transaction(): + current = {row.ha_user_id for row in await dao.list()} + target = set(normalized) + for user_id in sorted(current - target): + await dao.remove(user_id) + for user_id in normalized: + await dao.add(user_id, _now_stamp()) + + +async def is_admin( + database: NestQuestDatabase, user_id: object +) -> bool: + """Resolve an HA user ID against the allowlist — FAILS CLOSED. + + True only for an allowlisted user id. Everything else is False: + a non-allowlisted id, an unknown id, a None or non-string value + (a service call with no user context), and an empty allowlist + (nobody is admin rather than everybody). The Feature 09 gate + wraps every admin-only service with this verdict; it never raises + for malformed input — malformed means not admin. + """ + if not isinstance(user_id, str) or not user_id: + return False + return await AdminUsersDao(database).exists(user_id) + + +async def seed_setup_admin( + database: NestQuestDatabase, + *, + stored_admin_ids: list[str] | None = None, + context_user_id: str | None = None, + owner_ids: list[str] | None = None, +) -> list[str]: + """Seed an empty allowlist so the owner is never locked out. + + Only acts when the allowlist is EMPTY. Preference order: the + stored persisted copy of the list (the options flow's current + selection, then the config flow's original — disaster recovery for + a lost database), then the HA user who completed the config flow + (``context_user_id``), then — when no caller identity survived at + all — every Home Assistant OWNER account (``owner_ids``), so a + first setup whose flow context lost the user id still seeds a + working admin. Returns the allowlist after seeding. Non-empty + allowlists are returned untouched — re-running setup must never + resurrect a deliberately narrowed list. + """ + dao = AdminUsersDao(database) + async with _connection_lock(database): + current = [row.ha_user_id for row in await dao.list()] + if current: + return current + candidates: list[str] = [] + if stored_admin_ids is not None: + if not isinstance(stored_admin_ids, list): + raise ValueError( + "stored_admin_ids must be a list of HA user id strings" + ) + candidates = [_validate_user_id(value) for value in stored_admin_ids] + elif context_user_id is not None: + candidates = [_validate_user_id(context_user_id)] + elif owner_ids: + if not isinstance(owner_ids, list): + raise ValueError( + "owner_ids must be a list of HA user id strings" + ) + candidates = [_validate_user_id(value) for value in owner_ids] + for user_id in candidates: + await dao.add(user_id, _now_stamp()) + return [row.ha_user_id for row in await dao.list()] diff --git a/custom_components/nestquest/children.py b/custom_components/nestquest/children.py new file mode 100644 index 0000000..f4d8c3e --- /dev/null +++ b/custom_components/nestquest/children.py @@ -0,0 +1,280 @@ +"""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 + + +def _validate_child_id(value: object) -> int: + """Reject non-int child ids (bools included) before any lookup. + + SQLite binds Python bools as integers, so ``True`` would silently + address child 1 and a float would round — malformed service input + must never mutate the wrong profile. + """ + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"child_id 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. + """ + _validate_child_id(child_id) + 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. + """ + _validate_child_id(child_id) + 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 reorder_children( + database: NestQuestDatabase, ordered_ids: list[int] +) -> None: + """Rewrite the children's panel display order in one transaction. + + ``ordered_ids`` must be a complete permutation of the children + table — every child id exactly once. A partial list (missing an + existing child), an unknown id, or a duplicate is rejected with + ValueError before any write, so the current order survives a + rejected call. The DAO enforces this inside its own transaction + (check and write are atomic under the connection lock); this + wrapper additionally rejects non-integer ids up front, since + ``bool`` and floats would silently compare against stored integer + ids at the SQL layer. + """ + for child_id in ordered_ids: + _validate_child_id(child_id) + await ChildrenDao(database).reorder(ordered_ids) + + +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() diff --git a/custom_components/nestquest/config_flow.py b/custom_components/nestquest/config_flow.py index a8ccbc2..803b2f1 100644 --- a/custom_components/nestquest/config_flow.py +++ b/custom_components/nestquest/config_flow.py @@ -7,7 +7,7 @@ from homeassistant import config_entries from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from .const import DOMAIN +from .const import CONF_ADMIN_USER_IDS, DOMAIN class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -28,7 +28,14 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Handle the user step of the config flow.""" + """Handle the user step of the config flow. + + The HA user completing the flow is persisted into the entry + data as the initial admin allowlist copy: real HA does not + guarantee the flow context survives to setup, and entry data is + the persisted record first setup seeds from — the owner is + never locked out. + """ await self.async_set_unique_id(DOMAIN, raise_on_progress=False) if self._async_in_progress() or self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") @@ -36,4 +43,12 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form(step_id="user") - return self.async_create_entry(title="NestQuest", data=user_input) \ No newline at end of file + data = dict(user_input) + flow_user_id = ( + self.context.get("user_id") + if isinstance(self.context, dict) + else None + ) + if flow_user_id: + data[CONF_ADMIN_USER_IDS] = [flow_user_id] + return self.async_create_entry(title="NestQuest", data=data) \ No newline at end of file diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index fc45d7c..8167e53 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -20,3 +20,34 @@ DEFAULT_DAY_ROLLOVER_TIME = "00:00" CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout" DEFAULT_PANEL_IDLE_TIMEOUT = 300 + +# Admin allowlist (Feature 03): HA user IDs permitted to call admin-only +# services. Stored as IDs, never usernames (feature guardrail). The +# database allowlist is authoritative; the config entry carries the same +# list as a disaster-recovery copy so a lost database can be re-seeded. +CONF_ADMIN_USER_IDS = "admin_user_ids" + +# Quest windows (D-008): a definition may declare several day windows; +# each produces its own instance per day and groups the panel's Quest +# Log into three columns. The clock ranges are stored here so they stay +# configurable later. Boundaries: a window's end equals the next +# window's start; classification (Feature 07 materialisation) treats a +# due time exactly on the boundary as belonging to the LATER window, so +# 12:00 is afternoon (not morning) and 17:00 is evening (not +# afternoon). Times after 21:00 belong to no window. +WINDOW_MORNING = "morning" +WINDOW_AFTERNOON = "afternoon" +WINDOW_EVENING = "evening" + +QUEST_WINDOWS: tuple[str, ...] = ( + WINDOW_MORNING, + WINDOW_AFTERNOON, + WINDOW_EVENING, +) + +#: Inclusive "HH:MM" bounds per window, in 24-hour local time. +WINDOW_CLOCK_RANGES: dict[str, tuple[str, str]] = { + WINDOW_MORNING: ("00:00", "11:59"), + WINDOW_AFTERNOON: ("12:00", "17:00"), + WINDOW_EVENING: ("17:00", "21:00"), +} diff --git a/custom_components/nestquest/dao_children.py b/custom_components/nestquest/dao_children.py index 88eb62c..57c42a8 100644 --- a/custom_components/nestquest/dao_children.py +++ b/custom_components/nestquest/dao_children.py @@ -191,13 +191,20 @@ class ChildrenDao: async def reorder(self, ordered_ids: list[int]) -> None: """Persist a new sort order: ``ordered_ids[i]`` gets sort_order i. + The list must be a COMPLETE permutation of the children table: + every existing child id appears exactly once. Partial lists + (missing an existing child) and unknown ids (naming a child + that does not exist) are rejected with ValueError BEFORE any + write, leaving the current order untouched — a silent partial + reorder would leave unlisted children with stale positions and + hide a caller bug. Duplicate ids are rejected up front: they + cannot all hold distinct positions, and silently assigning the + last occurrence's index would hide a caller bug. + Runs inside one transaction; positions are staged at offset -len (all negative, so any permutation of the same ids never collides mid-reorder under the wrapper's single-connection - serialization), then finalized 0..n-1. Duplicate ids are - rejected up front: they cannot all hold distinct positions, and - silently assigning the last occurrence's index would hide a - caller bug. + serialization), then finalized 0..n-1. Concurrent reorder() calls on one connection are serialized by a connection-scoped asyncio lock (shared across every ChildrenDao @@ -205,10 +212,10 @@ class ChildrenDao: transaction() rejects a second concurrent transaction with RuntimeError, so racing callers would crash instead of queueing. The lock makes the second call wait, then run - against the first call's final state. + against the first call's final state — which also keeps the + completeness check honest: the re-read of the table happens + inside the same lock and transaction as the writes. """ - if not ordered_ids: - return if len(set(ordered_ids)) != len(ordered_ids): raise ValueError( "reorder() received duplicate child ids; every id must " @@ -216,6 +223,29 @@ class ChildrenDao: ) async with _connection_lock(self._database): async with self._database.transaction(): + rows = await self._database.fetch_all( + f"SELECT id FROM children" + ) + existing = {row[0] for row in rows} + listed = set(ordered_ids) + missing = sorted(existing - listed) + unknown = sorted(listed - existing) + if missing or unknown: + details = [] + if missing: + details.append( + f"missing child ids {missing} (a partial " + "reorder would leave them with stale " + "positions)" + ) + if unknown: + details.append( + f"unknown child ids {unknown}" + ) + raise ValueError( + "reorder() must list every child exactly once; " + + "; ".join(details) + ) offset = len(ordered_ids) await self._database.execute_many( "UPDATE children SET sort_order = ? WHERE id = ?", diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py index e4aa0b6..93437f1 100644 --- a/custom_components/nestquest/dao_instances.py +++ b/custom_components/nestquest/dao_instances.py @@ -1,4 +1,4 @@ -"""Typed DAO layer for task_instances and the append-only completion_events. +"""Typed DAO layer for quest_instances and the append-only completion_events. All SQL for these tables lives in this module per the feature guardrails: callers get typed dataclasses back and never see raw rows @@ -14,12 +14,14 @@ separate reversal event, and instance state derives from the latest event. A package-wide guard test (bottom of the test module) keeps mutation SQL for this table out of every other module. -Upsert semantics: ``TaskInstancesDao.upsert`` is idempotent on -(definition_id, due_date) — re-running materialization for a date -whose instance already exists updates nothing that matters -(child_id/due_time are refreshed only in the degenerate re-generation -case where they changed) and never duplicates a row. The schema's -UNIQUE(definition_id, due_date) is the last line of defense. +Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on +(definition_id, child_id, due_date, window) — re-running +materialization for a tuple whose instance already exists refreshes +only the generation-time snapshot columns and never duplicates a row. +The schema's UNIQUE(definition_id, child_id, due_date, window) is the +last line of defense. The upsert refuses a child that is not an +assignee of the definition and a window outside ``const.QUEST_WINDOWS`` +(D-008 multi-assignee, multi-window model). ``delete_future_uncompleted`` implements the Feature 06/07 rule that reassignment and schedule edits regenerate future instances: it @@ -35,7 +37,7 @@ import datetime from dataclasses import dataclass from .dao_children import _connection_lock -from .dao_rules import _validate_date +from .dao_rules import _validate_date, _validate_window from .db import NestQuestDatabase #: Timestamp policy for completion_events.occurred_at: strict UTC @@ -82,12 +84,13 @@ def _next_day_start(date_str: str) -> str: @dataclass(frozen=True) -class TaskInstanceRecord: - """One row of ``task_instances``.""" +class QuestInstanceRecord: + """One row of ``quest_instances``.""" id: int definition_id: int child_id: int + window: str due_date: str due_time: str | None generated_at: str @@ -103,16 +106,17 @@ class CompletionEventRecord: event_type: str actor_source: str actor_user_id: str | None + actor_child_id: int | None occurred_at: str was_on_time: bool | None _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 = ( "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 @@ -121,14 +125,15 @@ EVENT_COMPLETED = "completed" EVENT_UNCOMPLETED = "uncompleted" -def _instance_from_row(row: tuple) -> TaskInstanceRecord: - return TaskInstanceRecord( +def _instance_from_row(row: tuple) -> QuestInstanceRecord: + return QuestInstanceRecord( id=row[0], definition_id=row[1], child_id=row[2], - due_date=row[3], - due_time=row[4], - generated_at=row[5], + window=row[3], + due_date=row[4], + due_time=row[5], + generated_at=row[6], ) @@ -140,13 +145,14 @@ def _event_from_row(row: tuple) -> CompletionEventRecord: event_type=row[3], actor_source=row[4], actor_user_id=row[5], - occurred_at=row[6], - was_on_time=None if row[7] is None else bool(row[7]), + actor_child_id=row[6], + occurred_at=row[7], + was_on_time=None if row[8] is None else bool(row[8]), ) -class TaskInstancesDao: - """Typed async access to the ``task_instances`` table.""" +class QuestInstancesDao: + """Typed async access to the ``quest_instances`` table.""" def __init__(self, database: NestQuestDatabase) -> None: self._database = database @@ -158,31 +164,40 @@ class TaskInstancesDao: due_date: str, generated_at: str, *, + window: str, due_time: str | None = None, - ) -> TaskInstanceRecord: - """Create the dated instance for (definition_id, due_date) idempotently. + ) -> QuestInstanceRecord: + """Create the dated instance for the widened key idempotently. - Running materialization twice for the same definition/date - yields ONE row: the ON CONFLICT path refreshes the - generation-time snapshot columns (child_id, due_time) and - regenerates the stamp, but can never create a duplicate — the - schema UNIQUE(definition_id, due_date) backs this up. + The instance key is (definition_id, child_id, due_date, window) + (D-008): running materialization twice for the same tuple + yields ONE row — the ON CONFLICT path refreshes the + generation-time snapshot columns (due_time) and regenerates the + 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: - ``due_date`` must be TODAY or later: instances are never generated in the past (Feature 07). The caller's materialization horizon is future-dated by definition. + - ``window`` must be one of ``const.QUEST_WINDOWS``. - An existing instance WITH a completion event is IMMUTABLE: the conflict path must not rewrite its snapshot columns (Feature 07/08). The upsert refuses with ValueError in that case — silently no-oping would mask a materialization bug. + - The child must be an assignee of the definition (D-008 + multi-assignee model). The definition must exist (validated atomically under the connection lock so a concurrent definition deactivation or child deletion cannot slip between check and insert). """ _validate_date(due_date, "due_date") + _validate_window(window) # Fail fast on obviously-past dates BEFORE queuing on the # connection lock (a nice early error); the AUTHORITATIVE # check runs after the lock is acquired, because a caller can @@ -207,82 +222,92 @@ class TaskInstancesDao: f"{today!r}" ) definition = await self._database.fetch_one( - "SELECT child_id FROM task_definitions WHERE id = ?", + "SELECT 1 FROM quest_definitions WHERE id = ?", (definition_id,), ) if definition is None: raise ValueError( - f"task definition {definition_id} does not exist" + f"quest definition {definition_id} does not exist" ) - if definition[0] != child_id: - raise ValueError( - f"task definition {definition_id} is assigned to " - f"child {definition[0]}, not {child_id}; instance " - "must carry the definition's assignee" - ) - child = await self._database.fetch_one( - "SELECT 1 FROM children WHERE id = ?", (child_id,) + assignee = await self._database.fetch_one( + "SELECT 1 FROM quest_definition_assignees " + "WHERE definition_id = ? AND child_id = ?", + (definition_id, child_id), ) - if child is None: - raise ValueError(f"child {child_id} does not exist") + if assignee is None: + raise ValueError( + f"quest definition {definition_id} is not " + f"assigned to child {child_id}; instance must " + "carry an assigned child" + ) existing = await self._database.fetch_one( "SELECT 1 FROM completion_events " - "WHERE instance_id = (SELECT id FROM task_instances " - "WHERE definition_id = ? AND due_date = ?)", - (definition_id, due_date), + "WHERE instance_id = (SELECT id FROM quest_instances " + "WHERE definition_id = ? AND child_id = ? " + "AND due_date = ? AND window = ?)", + (definition_id, child_id, due_date, window), ) if existing is not None: raise ValueError( 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" ) await self._database.execute( - "INSERT INTO task_instances (definition_id, child_id, " - "due_date, due_time, generated_at) " - "VALUES (?, ?, ?, ?, ?) " - "ON CONFLICT (definition_id, due_date) DO UPDATE SET " - "child_id = excluded.child_id, " + "INSERT INTO quest_instances (definition_id, child_id, " + "window, due_date, due_time, generated_at) " + "VALUES (?, ?, ?, ?, ?, ?) " + "ON CONFLICT (definition_id, child_id, due_date, " + "window) DO UPDATE SET " "due_time = excluded.due_time, " "generated_at = excluded.generated_at", ( definition_id, child_id, + window, due_date, due_time, 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 return instance async def get( - self, definition_id: int, due_date: str - ) -> TaskInstanceRecord | None: - """Return the instance for (definition_id, due_date), or None.""" + self, + definition_id: int, + child_id: int, + due_date: str, + window: str, + ) -> QuestInstanceRecord | None: + """Return the instance for the widened key tuple, or None.""" row = await self._database.fetch_one( - f"SELECT {_INSTANCE_COLUMNS} FROM task_instances " - "WHERE definition_id = ? AND due_date = ?", - (definition_id, due_date), + f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances " + "WHERE definition_id = ? AND child_id = ? AND due_date = ? " + "AND window = ?", + (definition_id, child_id, due_date, window), ) return _instance_from_row(row) if row is not None else None - async def get_by_id(self, instance_id: int) -> TaskInstanceRecord | None: + async def get_by_id(self, instance_id: int) -> QuestInstanceRecord | None: """Return the instance with ``instance_id``, or None.""" row = await self._database.fetch_one( - f"SELECT {_INSTANCE_COLUMNS} FROM task_instances WHERE id = ?", + f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances WHERE id = ?", (instance_id,), ) return _instance_from_row(row) if row is not None else None async def list_by_child_and_date( self, child_id: int, due_date: str - ) -> list[TaskInstanceRecord]: + ) -> list[QuestInstanceRecord]: """Return the child's instances due exactly on ``due_date``.""" _validate_date(due_date, "due_date") rows = await self._database.fetch_all( - f"SELECT {_INSTANCE_COLUMNS} FROM task_instances " + f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances " "WHERE child_id = ? AND due_date = ? ORDER BY id", (child_id, due_date), ) @@ -293,7 +318,7 @@ class TaskInstancesDao: child_id: int, range_start: str, range_end: str, - ) -> list[TaskInstanceRecord]: + ) -> list[QuestInstanceRecord]: """Return the child's instances with due_date in the closed range.""" _validate_date(range_start, "range_start") _validate_date(range_end, "range_end") @@ -303,7 +328,7 @@ class TaskInstancesDao: f"{range_end!r} < {range_start!r}" ) rows = await self._database.fetch_all( - f"SELECT {_INSTANCE_COLUMNS} FROM task_instances " + f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances " "WHERE child_id = ? AND due_date >= ? AND due_date <= ? " "ORDER BY due_date, id", (child_id, range_start, range_end), @@ -338,10 +363,10 @@ class TaskInstancesDao: today = datetime.date.today().isoformat() effective_cutoff = max(cutoff_date, today) result = await self._database.execute( - "DELETE FROM task_instances WHERE definition_id = ? " + "DELETE FROM quest_instances WHERE definition_id = ? " "AND due_date >= ? AND NOT EXISTS (" " SELECT 1 FROM completion_events " - " WHERE instance_id = task_instances.id" + " WHERE instance_id = quest_instances.id" ")", (definition_id, effective_cutoff), ) @@ -373,19 +398,21 @@ class CompletionEventsDao: was_on_time: bool | None, *, actor_user_id: str | None = None, + actor_child_id: int | None = None, ) -> CompletionEventRecord: """Append one completion or un-completion event. - ``actor_source`` is 'user' (with ``actor_user_id`` set) or - 'panel' (no user id — the tapped child is ``child_id``); the - actor-pair CHECK enforces this shape at the schema level. - ``occurred_at`` must be the module's one strict UTC shape - (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range queries - stay exact. ``was_on_time`` is required (True/False) for - completions; for un-completions it may be None or the reversed - completion's flag (Feature 08 still deciding). The instance - and child must exist — validated atomically under the - connection lock. + ``actor_source`` is 'user' (an admin action: ``actor_user_id`` + set, ``actor_child_id`` NULL) or 'panel' (a panel tap: no user + id, and ``actor_child_id`` carrying the tapped child profile, + D-008); the actor-pair CHECKs enforce this shape at the schema + level. ``occurred_at`` must be the module's one strict UTC + shape (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range + queries stay exact. ``was_on_time`` is required (True/False) + for completions; for un-completions it may be None or the + reversed completion's flag (Feature 08 still deciding). The + instance, 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): raise ValueError( @@ -405,6 +432,15 @@ class CompletionEventsDao: raise ValueError( "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: raise ValueError( f"was_on_time must be True, False or None, got " @@ -418,16 +454,16 @@ class CompletionEventsDao: async with _connection_lock(self._database): async with self._database.transaction(): instance = await self._database.fetch_one( - "SELECT child_id FROM task_instances WHERE id = ?", + "SELECT child_id FROM quest_instances WHERE id = ?", (instance_id,), ) if instance is None: raise ValueError( - f"task instance {instance_id} does not exist" + f"quest instance {instance_id} does not exist" ) if instance[0] != child_id: raise ValueError( - f"task instance {instance_id} belongs to child " + f"quest instance {instance_id} belongs to child " f"{instance[0]}, not {child_id}" ) child = await self._database.fetch_one( @@ -435,16 +471,28 @@ class CompletionEventsDao: ) if child is None: 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( "INSERT INTO completion_events (instance_id, child_id, " - "event_type, actor_source, actor_user_id, occurred_at, " - "was_on_time) VALUES (?, ?, ?, ?, ?, ?, ?)", + "event_type, actor_source, actor_user_id, " + "actor_child_id, occurred_at, was_on_time) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ( instance_id, child_id, event_type, actor_source, actor_user_id, + actor_child_id, occurred_at, None if was_on_time is None else int(was_on_time), ), @@ -493,7 +541,7 @@ class CompletionEventsDao: ) if due_date_scope: predicate = ( - "SELECT id FROM task_instances WHERE child_id = ? " + "SELECT id FROM quest_instances WHERE child_id = ? " "AND due_date >= ? AND due_date <= ?" ) parameters: list[object] = [child_id, range_start, range_end] diff --git a/custom_components/nestquest/dao_presence.py b/custom_components/nestquest/dao_presence.py index c19e821..5279a25 100644 --- a/custom_components/nestquest/dao_presence.py +++ b/custom_components/nestquest/dao_presence.py @@ -186,6 +186,15 @@ class PresenceOverridesDao: The child must exist (validated atomically under the connection lock). Date shape is validated strictly, and the schema CHECK rejects end < start. + + Overlap policy (Feature 05): an override for a child may not + overlap another override of the SAME child — two different + present/absent answers for one date are unresolvable. The + overlap check (inclusive boundaries) runs inside the same + transaction as the INSERT, so a racing create cannot slip a + conflicting override between the check and the write; a + conflict raises ValueError naming the conflicting override's + id and range. """ _validate_date(start_date, "start_date") _validate_date(end_date, "end_date") @@ -196,6 +205,29 @@ class PresenceOverridesDao: ) if child is None: raise ValueError(f"child {child_id} does not exist") + # Only a well-ordered range has overlap semantics: + # inverted ranges fall through to the INSERT and hit + # the schema's end>=start CHECK, preserving the + # IntegrityError contract the matrix tests rely on. + if end_date >= start_date: + conflicting = await self._database.fetch_one( + f"SELECT {_OVERRIDE_COLUMNS} " + "FROM presence_overrides " + "WHERE child_id = ? AND end_date >= ? " + "AND start_date <= ? ORDER BY id LIMIT 1", + (child_id, start_date, end_date), + ) + else: + conflicting = None + if conflicting is not None: + conflict = _override_from_row(conflicting) + raise ValueError( + f"override for child {child_id} " + f"[{start_date}, {end_date}] overlaps existing " + f"override {conflict.id} " + f"[{conflict.start_date}, {conflict.end_date}] " + f"(is_present={conflict.is_present})" + ) result = await self._database.execute( "INSERT INTO presence_overrides (child_id, " "start_date, end_date, is_present, note) " diff --git a/custom_components/nestquest/dao_rules.py b/custom_components/nestquest/dao_rules.py index 51ba457..d5090ee 100644 --- a/custom_components/nestquest/dao_rules.py +++ b/custom_components/nestquest/dao_rules.py @@ -1,37 +1,44 @@ -"""Typed DAO layer for the schedule_rules and task_definitions tables. +"""Typed DAO layer for the schedule_rules, quest_definitions and +quest_definition_assignees tables. -All SQL for ``schedule_rules`` and ``task_definitions`` lives in this -module per the feature guardrails: callers get typed dataclasses back -and never see raw rows or SQL. Every method is async and runs through +All SQL for ``schedule_rules``, ``quest_definitions`` and +``quest_definition_assignees`` lives in this module per the feature +guardrails: callers get typed dataclasses back and never see raw rows +or SQL. Every method is async and runs through :class:`~.db.NestQuestDatabase`, so each statement executes on the HA executor and the event loop never blocks. Guardrail mapping: - ``schedule_rules`` deletion is delete-if-unreferenced: a rule still - referenced by any task definition is rejected (the done-condition), + referenced by any quest definition is rejected (the done-condition), not cascaded. -- ``task_definitions`` has no delete path at all: definitions are +- ``quest_definitions`` has no delete path at all: definitions are deactivated, never hard-deleted, and history must survive. -- ``set_assignee`` changes future instances only — the DAO writes the - definition row and nothing else; reassignment never rewrites +- Assignment is multi-assignee (D-008): :meth:`add_assignee` and + :meth:`remove_assignee` change future instances only — the DAO writes + the assignee table and nothing else; assignment edits never rewrite existing instances or completion history (Feature 06 guardrail). - Validation of rule well-formedness and child activeness lives partly in the schema CHECKs (rule-type coherence) and partly in - :meth:`TaskDefinitionsDao.create` (active child, rule existence); - no permission checks here, that is the Feature 09 gate's job. + :meth:`QuestDefinitionsDao.create` (rule existence) and + :meth:`QuestDefinitionsDao.add_assignee` (active child); no + permission checks here, that is the Feature 09 gate's job. -Concurrency: ``delete_rule_if_unreferenced`` performs its reference -check and DELETE inside one transaction under the connection-scoped -lock shared with :mod:`.dao_children`, so a definition referencing the -rule cannot appear between check and delete on this connection. +Concurrency: ``delete_rule_if_unreferenced`` and the assignee +mutations perform their checks and writes inside one transaction under +the connection-scoped lock shared with :mod:`.dao_children`, so a +definition referencing the rule (or an assignee appearing) cannot slip +in between check and write on this connection. """ from __future__ import annotations import datetime from dataclasses import dataclass -from .dao_children import _connection_lock +from .const import QUEST_WINDOWS +from .dao_children import ChildRecord, _child_from_row, _connection_lock +from .dao_children import _CHILD_COLUMNS from .db import NestQuestDatabase #: Sentinel distinguishing "argument omitted" from "explicit SQL NULL" @@ -65,6 +72,38 @@ def _validate_date(value: str, field: str) -> None: ) +_TIME_FORMAT = "%H:%M" + + +def _validate_time(value: str, field: str) -> None: + """Raise ValueError unless ``value`` is a strict 24-hour HH:MM. + + Same strictness policy as _validate_date: parse with the exact + format, then round-trip so '9:30' (non-padded) is rejected. Time + shapes are caller-side policy (the schema stores TEXT): windows' + optional ``due_time`` and definition ``due_time`` both use this. + """ + try: + parsed = datetime.datetime.strptime(value, _TIME_FORMAT).time() + except (TypeError, ValueError): + raise ValueError( + f"{field} must be a 24-hour HH:MM time, got {value!r}" + ) from None + if parsed.strftime(_TIME_FORMAT) != value: + raise ValueError( + f"{field} must be a strict HH:MM time, got {value!r}" + ) + + +def _validate_window(window: str) -> None: + """Raise ValueError unless ``window`` is a ``const`` window name.""" + if window not in QUEST_WINDOWS: + raise ValueError( + f"window must be one of {', '.join(QUEST_WINDOWS)}, " + f"got {window!r}" + ) + + @dataclass(frozen=True) class ScheduleRuleRecord: """One row of ``schedule_rules``.""" @@ -81,28 +120,37 @@ class ScheduleRuleRecord: @dataclass(frozen=True) -class TaskDefinitionRecord: - """One row of ``task_definitions``.""" +class QuestDefinitionRecord: + """One row of ``quest_definitions`` (without its assignees).""" id: int title: str description: str | None icon: str | None - child_id: int schedule_rule_id: int due_time: str | None is_active: bool created_at: str +@dataclass(frozen=True) +class QuestDefinitionWindowRecord: + """One row of ``quest_definition_windows``.""" + + definition_id: int + window: str + due_time: str | None + + _RULE_COLUMNS = ( "id, rule_type, interval, weekday_set, day_of_month, nth_weekday, " "month, start_date, end_date" ) _DEFINITION_COLUMNS = ( - "id, title, description, icon, child_id, schedule_rule_id, due_time, " + "id, title, description, icon, schedule_rule_id, due_time, " "is_active, created_at" ) +_WINDOW_COLUMNS = "definition_id, window, due_time" def _rule_from_row(row: tuple) -> ScheduleRuleRecord: @@ -119,17 +167,24 @@ def _rule_from_row(row: tuple) -> ScheduleRuleRecord: ) -def _definition_from_row(row: tuple) -> TaskDefinitionRecord: - return TaskDefinitionRecord( +def _definition_from_row(row: tuple) -> QuestDefinitionRecord: + return QuestDefinitionRecord( id=row[0], title=row[1], description=row[2], icon=row[3], - child_id=row[4], - schedule_rule_id=row[5], - due_time=row[6], - is_active=bool(row[7]), - created_at=row[8], + schedule_rule_id=row[4], + due_time=row[5], + is_active=bool(row[6]), + created_at=row[7], + ) + + +def _window_from_row(row: tuple) -> QuestDefinitionWindowRecord: + return QuestDefinitionWindowRecord( + definition_id=row[0], + window=row[1], + due_time=row[2], ) @@ -258,7 +313,7 @@ class ScheduleRulesDao: async with _connection_lock(self._database): async with self._database.transaction(): row = await self._database.fetch_one( - "SELECT 1 FROM task_definitions " + "SELECT 1 FROM quest_definitions " "WHERE schedule_rule_id = ? LIMIT 1", (rule_id,), ) @@ -271,12 +326,15 @@ class ScheduleRulesDao: return result.rowcount > 0 -class TaskDefinitionsDao: - """Typed async access to the ``task_definitions`` table. +class QuestDefinitionsDao: + """Typed async access to ``quest_definitions`` and its assignees. No delete method exists: definitions are deactivated via :meth:`set_active`, never hard-deleted, so completion history keeps - its references (feature guardrail). + its references (feature guardrail). Assignment is multi-assignee + (D-008): the definition row carries no child; assignees live in + ``quest_definition_assignees`` and are managed explicitly so the + operations stay separately loggable and permission-gateable. """ def __init__(self, database: NestQuestDatabase) -> None: @@ -285,7 +343,6 @@ class TaskDefinitionsDao: async def create( self, title: str, - child_id: int, schedule_rule_id: int, created_at: str, *, @@ -293,53 +350,55 @@ class TaskDefinitionsDao: icon: str | None = None, due_time: str | None = None, is_active: bool = True, - ) -> TaskDefinitionRecord: + assignee_child_ids: list[int] | None = None, + ) -> QuestDefinitionRecord: """Insert one definition and return the record as stored. - Validates the assignment target as part of the same serialized - transaction as the insert: the child must exist and be active, - and the rule must exist, AT INSERT TIME — a child deactivated - or a rule deleted concurrently cannot slip between the checks - and the INSERT, which would otherwise create an invalid - assignment or surface as a raw FK IntegrityError instead of - the documented ValueError. An INACTIVE child would pass the - schema foreign keys, which is why the activeness check lives - here. + Validates the schedule rule as part of the same serialized + transaction as the insert: the rule must exist AT INSERT TIME — + a rule deleted concurrently cannot slip between the check and + the INSERT. ``assignee_child_ids`` (each child must exist and + be active) is applied in the same transaction, so a definition + can never be observed with a stale or missing assignee set; an + empty list or None creates an unassigned definition, which + callers may fill via :meth:`add_assignee`. """ async with _connection_lock(self._database): async with self._database.transaction(): - await self._validate_assignable( - child_id, schedule_rule_id - ) + await self._validate_rule_exists(schedule_rule_id) + for child_id in assignee_child_ids or []: + await self._validate_assignable_child(child_id) result = await self._database.execute( - "INSERT INTO task_definitions (title, description, " - "icon, child_id, schedule_rule_id, due_time, " - "is_active, created_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO quest_definitions (title, description, " + "icon, schedule_rule_id, due_time, is_active, " + "created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", ( title, description, icon, - child_id, schedule_rule_id, due_time, int(is_active), created_at, ), ) - definition = await self.get(result.lastrowid) + definition_id = result.lastrowid + for child_id in assignee_child_ids or []: + await self._database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (?, ?)", + (definition_id, child_id), + ) + definition = await self.get(definition_id) assert definition is not None return definition - async def _validate_assignable( - self, child_id: int, schedule_rule_id: int - ) -> None: - """Raise ValueError unless child (active) and rule both exist. + async def _validate_rule_exists(self, schedule_rule_id: int) -> None: + """Raise ValueError unless the rule exists. MUST be called inside the connection lock so the verdict cannot go stale before the caller's INSERT commits. """ - await self._validate_assignable_child(child_id) rule = await self._database.fetch_one( "SELECT 1 FROM schedule_rules WHERE id = ?", (schedule_rule_id,), @@ -349,28 +408,35 @@ class TaskDefinitionsDao: f"schedule rule {schedule_rule_id} does not exist" ) - async def get(self, definition_id: int) -> TaskDefinitionRecord | None: + async def get(self, definition_id: int) -> QuestDefinitionRecord | None: """Return the definition with ``definition_id``, or None.""" row = await self._database.fetch_one( - f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions " + f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions " "WHERE id = ?", (definition_id,), ) return _definition_from_row(row) if row is not None else None - async def list_by_child(self, child_id: int) -> list[TaskDefinitionRecord]: - """Return all definitions assigned to ``child_id``, newest first.""" + async def list_by_child(self, child_id: int) -> list[QuestDefinitionRecord]: + """Return all definitions assigned to ``child_id``, newest first. + + Assignment is read through ``quest_definition_assignees`` + (D-008): a definition appears once per child regardless of how + many assignees share it. + """ rows = await self._database.fetch_all( - f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions " - "WHERE child_id = ? ORDER BY id DESC", + f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions " + "WHERE id IN (SELECT definition_id " + "FROM quest_definition_assignees WHERE child_id = ?) " + "ORDER BY id DESC", (child_id,), ) return [_definition_from_row(row) for row in rows] - async def list_active(self) -> list[TaskDefinitionRecord]: + async def list_active(self) -> list[QuestDefinitionRecord]: """Return all active definitions, oldest first (stable order).""" rows = await self._database.fetch_all( - f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions " + f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions " "WHERE is_active = 1 ORDER BY id" ) return [_definition_from_row(row) for row in rows] @@ -391,8 +457,9 @@ class TaskDefinitionsDao: SQL NULL, so callers can remove optional metadata (description, icon, due_time). Assignment and activation are deliberately NOT settable here: they have their own explicit - operations (:meth:`set_assignee`, :meth:`set_active`) so they - stay separately loggable and permission-gateable. + operations (:meth:`add_assignee`, :meth:`remove_assignee`, + :meth:`set_active`) so they stay separately loggable and + permission-gateable. """ assignments: list[str] = [] parameters: list[object] = [] @@ -409,7 +476,7 @@ class TaskDefinitionsDao: return 0 parameters.append(definition_id) result = await self._database.execute( - f"UPDATE task_definitions SET {', '.join(assignments)} " + f"UPDATE quest_definitions SET {', '.join(assignments)} " "WHERE id = ?", tuple(parameters), ) @@ -422,37 +489,166 @@ class TaskDefinitionsDao: existing instances and completion history untouched. """ result = await self._database.execute( - "UPDATE task_definitions SET is_active = ? WHERE id = ?", + "UPDATE quest_definitions SET is_active = ? WHERE id = ?", (int(is_active), definition_id), ) return result.rowcount - async def set_assignee( + async def add_assignee( self, definition_id: int, child_id: int - ) -> int: - """Reassign the definition to ``child_id``; rows updated (0 if absent). + ) -> None: + """Assign ``child_id`` to the definition (idempotent). - Validates the new assignee inside the same serialized - transaction as the UPDATE (existing and active AT ASSIGNMENT - TIME), so a child deactivated concurrently cannot become the - assignee. Changes future instances only: already-generated - instances and completion history are never rewritten here. + Validates the assignee inside the same serialized transaction + as the INSERT (existing and active AT ASSIGNMENT TIME), so a + child deactivated concurrently cannot become an assignee. + Assigning an already-assigned child is a no-op, mirroring the + composite primary key's storage-level guarantee. Changes + future instances only: already-generated instances and + completion history are never rewritten here. """ async with _connection_lock(self._database): async with self._database.transaction(): await self._validate_assignable_child(child_id) - result = await self._database.execute( - "UPDATE task_definitions SET child_id = ? " - "WHERE id = ?", - (child_id, definition_id), + definition = await self._database.fetch_one( + "SELECT 1 FROM quest_definitions WHERE id = ?", + (definition_id,), ) - return result.rowcount + if definition is None: + raise ValueError( + f"quest definition {definition_id} does not exist" + ) + await self._database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (?, ?) " + "ON CONFLICT (definition_id, child_id) DO NOTHING", + (definition_id, child_id), + ) + + async def remove_assignee( + self, definition_id: int, child_id: int + ) -> bool: + """Remove the assignee link; True when a row was removed. + + Future materialization stops for this (definition, child) pair; + existing instances and completion history are never touched + here (Feature 06 guardrail). + """ + async with _connection_lock(self._database): + result = await self._database.execute( + "DELETE FROM quest_definition_assignees " + "WHERE definition_id = ? AND child_id = ?", + (definition_id, child_id), + ) + return result.rowcount > 0 + + async def list_assignees(self, definition_id: int) -> list[ChildRecord]: + """Return the definition's assignees as child records. + + Ordered by the children table's stable display order + (``sort_order``, then id) so callers render a deterministic + roster. A definition with no assignees returns []. + """ + rows = await self._database.fetch_all( + f"SELECT {_CHILD_COLUMNS} FROM children " + "WHERE id IN (SELECT child_id " + "FROM quest_definition_assignees WHERE definition_id = ?) " + "ORDER BY sort_order, id", + (definition_id,), + ) + return [_child_from_row(row) for row in rows] + + async def upsert_window( + self, + definition_id: int, + window: str, + *, + due_time: str | None = None, + ) -> QuestDefinitionWindowRecord: + """Declare (or re-declare) ``window`` on the definition. + + The composite primary key makes the write idempotent per + (definition, window): re-declaring updates ``due_time`` in + place instead of duplicating. The window name must be one of + ``const.QUEST_WINDOWS`` (the schema CHECK backs this up) and a + provided ``due_time`` must be strict 24-hour HH:MM. The + definition must exist — validated under the connection lock so + the verdict cannot go stale before the write. + """ + _validate_window(window) + if due_time is not None: + _validate_time(due_time, "due_time") + async with _connection_lock(self._database): + async with self._database.transaction(): + definition = await self._database.fetch_one( + "SELECT 1 FROM quest_definitions WHERE id = ?", + (definition_id,), + ) + if definition is None: + raise ValueError( + f"quest definition {definition_id} does not exist" + ) + await self._database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) VALUES (?, ?, ?) " + "ON CONFLICT (definition_id, window) DO UPDATE SET " + "due_time = excluded.due_time", + (definition_id, window, due_time), + ) + row = await self._database.fetch_one( + f"SELECT {_WINDOW_COLUMNS} " + "FROM quest_definition_windows " + "WHERE definition_id = ? AND window = ?", + (definition_id, window), + ) + assert row is not None + return _window_from_row(row) + + async def list_windows( + self, definition_id: int + ) -> list[QuestDefinitionWindowRecord]: + """Return the definition's windows in ``const`` order. + + Ordered by the canonical window sequence (morning, afternoon, + evening) rather than insertion order, so callers always render + the Quest Log's three columns consistently. A definition with + no windows returns []. + """ + rows = await self._database.fetch_all( + f"SELECT {_WINDOW_COLUMNS} FROM quest_definition_windows " + "WHERE definition_id = ?", + (definition_id,), + ) + by_name = {row[1]: _window_from_row(row) for row in rows} + return [ + by_name[name] + for name in QUEST_WINDOWS + if name in by_name + ] + + async def remove_window( + self, definition_id: int, window: str + ) -> bool: + """Remove the window declaration; True when a row was removed. + + Future materialization stops producing instances for this + (definition, window); existing instances and completion history + are never touched here (Feature 06 guardrail). + """ + _validate_window(window) + async with _connection_lock(self._database): + result = await self._database.execute( + "DELETE FROM quest_definition_windows " + "WHERE definition_id = ? AND window = ?", + (definition_id, window), + ) + return result.rowcount > 0 async def _validate_assignable_child(self, child_id: int) -> None: """Raise ValueError unless the child exists and is active. MUST be called inside the connection lock so the verdict cannot - go stale before the caller's UPDATE commits. + go stale before the caller's INSERT commits. """ child = await self._database.fetch_one( "SELECT is_active FROM children WHERE id = ?", (child_id,) diff --git a/custom_components/nestquest/manifest.json b/custom_components/nestquest/manifest.json index ffef4b2..f65c64c 100644 --- a/custom_components/nestquest/manifest.json +++ b/custom_components/nestquest/manifest.json @@ -10,5 +10,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/talon2king/NestQuest/issues", "requirements": [], - "version": "0.3.0" + "version": "0.4.0" } diff --git a/custom_components/nestquest/migrations.py b/custom_components/nestquest/migrations.py index 0cc4377..2040366 100644 --- a/custom_components/nestquest/migrations.py +++ b/custom_components/nestquest/migrations.py @@ -14,6 +14,32 @@ Version 0 means "the file exists but carries no NestQuest schema yet" the runner against version 0 applies migration 1, which applies the full v1 DDL from :mod:`.schema` and stamps version 1. +Migration 1 was rewritten pre-release (D-007): nothing was ever +shipped, so the v1 DDL now creates the ``quest_*`` tables directly +instead of the legacy ``task_*`` spellings, and no compatibility shim +exists. Dev machines that already hold a version-1 database with +``task_*`` tables are handled by migration 2, which detects the legacy +table names and ``ALTER TABLE ... RENAME TO``s them to their current +names before anything else assumes the new model. Migration 3 brings +pre-D-008 databases to the multi-assignee model: it creates +``quest_definition_assignees`` and, when ``quest_definitions`` still +carries a single ``child_id`` column, rebuilds the table without it, +copying each definition's assignee across. Migration 4 adds the +windows table, migration 5 rebuilds ``quest_instances`` onto the +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 +an async callable taking ``(database, target_version)``. Callables +exist for steps whose SQL depends on the database's current state (a +legacy rename only fires when a legacy table is present; a rebuild only +when the old column shape is present) and own their transaction so they +can bracket it with connection pragmas the runner's uniform transaction +cannot express. + Each migration's statements and its version stamp run inside ONE transaction, and the stamp is written as DELETE + INSERT rather than UPDATE because a version-0 database has no version row to update yet. @@ -43,13 +69,29 @@ from __future__ import annotations import asyncio import logging -from collections.abc import Sequence +from collections.abc import Awaitable, Callable, Sequence +from typing import Union from .db import NestQuestDatabase -from .schema import SCHEMA_V1_STATEMENTS +from .schema import ( + COMPLETION_EVENTS_TABLE_SQL, + QUEST_DEFINITIONS_TABLE_SQL, + QUEST_INSTANCES_TABLE_SQL, + SCHEMA_V1_STATEMENTS, + SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, + SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, +) LOGGER = logging.getLogger(__name__) +#: One migration entry: either an ordered sequence of SQL statements or +#: an async callable taking ``(database, target_version)`` that manages +#: its own transaction and stamps via :func:`stamp_schema_version`. +MigrationStep = Union[ + Sequence[str], + Callable[[NestQuestDatabase, int], Awaitable[None]], +] + #: Metadata table holding the applied schema version. Prefixed with the #: integration domain so a config directory shared with other tools can #: never collide with a foreign ``schema_version`` table. @@ -73,10 +115,305 @@ VERSION_TABLE_DDL = f""" #: version ``n-1`` to version ``n``. MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS) +#: The assignees table DDL, shared with migration 1 via the schema +#: module so a rebuilt and a freshly created table cannot drift. +_ASSIGNEES_TABLE_DDL: str = SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL[0] + +#: Legacy (pre-D-007) table name -> current table name. Order matters: +#: the parent table renames first so the child's foreign key reference +#: is rewritten while its parent's new name is already in place. +LEGACY_TASK_TABLE_RENAMES: tuple[tuple[str, str], ...] = ( + ("task_definitions", "quest_definitions"), + ("task_instances", "quest_instances"), +) + + +async def stamp_schema_version( + database: NestQuestDatabase, version: int +) -> None: + """Write the schema version stamp (inside the caller's transaction). + + INSERT keyed on the singleton row (id = 1) with an upsert: a fresh + file has no row yet, so UPDATE alone would silently match zero rows + there and leave a migrated-but-unstamped database. + """ + await database.execute( + f"INSERT INTO {VERSION_TABLE} (id, version) " + "VALUES (1, ?) " + "ON CONFLICT (id) DO UPDATE SET version = excluded.version", + (version,), + ) + + +async def _table_has_column( + database: NestQuestDatabase, table: str, column: str +) -> bool: + """Return True when ``table`` currently carries ``column``.""" + rows = await database.fetch_all(f"PRAGMA table_info({table})") + 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( + database: NestQuestDatabase, target_version: int +) -> None: + """Rename any surviving ``task_*`` tables to their ``quest_*`` names. + + Dev-machines-only path (D-007): a database stamped version 1 by the + pre-rename runner holds ``task_definitions``/``task_instances``. + Each legacy name still present is renamed; fresh databases find + nothing and this step is a no-op. Modern SQLite rewrites foreign + key clauses in other tables to follow the rename, so the renamed + schema is indistinguishable from a freshly created one apart from + internal autoindex names. Runs in its own transaction together + with the version stamp, so a crash rolls both back. + """ + async with database.transaction(): + for legacy_name, current_name in LEGACY_TASK_TABLE_RENAMES: + row = await database.fetch_one( + "SELECT 1 FROM sqlite_master WHERE type = 'table' " + "AND name = ?", + (legacy_name,), + ) + if row is not None: + await database.execute( + f'ALTER TABLE "{legacy_name}" RENAME TO "{current_name}"' + ) + LOGGER.info( + "Renamed legacy table %s to %s", + legacy_name, + current_name, + ) + await stamp_schema_version(database, target_version) + + +async def _add_definition_assignees( + database: NestQuestDatabase, target_version: int +) -> None: + """Bring definitions to the multi-assignee model (D-008). + + Two shapes arrive here: + + - Fresh or renamed databases whose ``quest_definitions`` already + matches the current canonical DDL (no ``child_id`` column): only + the assignees table needs creating (``IF NOT EXISTS`` is a no-op + when migration 1 already made it). + - Databases stamped at version 2 whose ``quest_definitions`` still + carries the single ``child_id`` column: the table is rebuilt via + SQLite's canonical procedure — create the new table, copy rows, + move each definition's assignee into + ``quest_definition_assignees``, drop the old table, rename — so + ``DROP COLUMN``'s foreign-key restriction never applies and no + definition or instance row is lost. + + The copy runs with ``foreign_keys = OFF`` because dropping the old + table under enforcement would fire the implicit DELETE and violate + the ``quest_instances`` foreign key; ``PRAGMA foreign_key_check`` + runs inside the same transaction before the stamp, so a corrupt + copy aborts the whole migration instead of persisting. The pragma + is a connection-level no-op inside a transaction, hence the + off/transaction/on bracket. + """ + legacy_child_id = await _table_has_column( + database, "quest_definitions", "child_id" + ) + await database.execute("PRAGMA foreign_keys = OFF") + try: + async with database.transaction(): + await database.execute(_ASSIGNEES_TABLE_DDL) + if legacy_child_id: + await database.execute( + "CREATE TABLE quest_definitions_rebuilt " + f"{QUEST_DEFINITIONS_TABLE_SQL}" + ) + await database.execute( + "INSERT INTO quest_definitions_rebuilt " + "(id, title, description, icon, schedule_rule_id, " + "due_time, is_active, created_at) " + "SELECT id, title, description, icon, " + "schedule_rule_id, due_time, is_active, created_at " + "FROM quest_definitions ORDER BY id" + ) + await database.execute( + "INSERT OR IGNORE INTO quest_definition_assignees " + "(definition_id, child_id) " + "SELECT id, child_id FROM quest_definitions" + ) + await database.execute("DROP TABLE quest_definitions") + await database.execute( + "ALTER TABLE quest_definitions_rebuilt " + "RENAME TO quest_definitions" + ) + violations = await database.fetch_all( + "PRAGMA foreign_key_check" + ) + if violations: + raise RuntimeError( + "definition rebuild produced foreign-key " + f"violations: {violations!r}" + ) + await stamp_schema_version(database, target_version) + finally: + 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 -#: entry, add the next one instead. -MIGRATIONS: Sequence[Sequence[str]] = [ +#: entry, add the next one instead. (Migration 1's content was +#: rewritten pre-release per D-007 — no version 1 database shipped.) +MIGRATIONS: Sequence[MigrationStep] = [ MIGRATION_1_V1_DDL, + _rename_legacy_task_tables, + _add_definition_assignees, + # Migration 4 (D-008): the windows table is a plain new table — a + # CREATE IF NOT EXISTS suffices for both fresh databases (which + # already made it in migration 1) and pre-window databases. + 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, ] @@ -140,7 +477,7 @@ def _migration_lock(database: NestQuestDatabase) -> asyncio.Lock: async def apply_migrations( database: NestQuestDatabase, - migrations: Sequence[Sequence[str]] = MIGRATIONS, + migrations: Sequence[MigrationStep] = MIGRATIONS, ) -> int: """Bring the database schema up to the latest version. @@ -189,17 +526,18 @@ async def apply_migrations( for target in range(current + 1, latest + 1): applied = False - async with database.transaction(): - # Re-read the version INSIDE the write transaction: the - # outer read was advisory, and the wrapper serializes - # individual statements, not the read-decide-apply - # decision. Under the transaction's lock the version - # cannot change between the read and the stamp, and the - # loser of an interleaved race (a queued second caller - # re-running apply_migrations after the winner already - # migrated) re-reads here, sees the winner's stamp, and - # skips instead of re-applying a non-idempotent - # migration with a stale version. + step = migrations[target - 1] + if callable(step): + # Callable migrations own their transaction: some steps + # (table rebuilds) must run connection pragmas such as + # ``foreign_keys = OFF`` BEFORE opening one, which the + # runner's uniform transaction cannot express, and they + # stamp inside their own transaction so a failure rolls + # the statements and the version stamp back together. + # The version is re-read here under the migration lock: + # runners on one connection are serialized by it, so + # the version cannot change between this read and the + # callable's commit. in_tx_version = await read_schema_version(database) if in_tx_version > latest: raise RuntimeError( @@ -208,29 +546,39 @@ async def apply_migrations( f"{latest}); downgrades are not supported" ) if in_tx_version < target: - for sql in migrations[target - 1]: - await database.execute(sql) - # INSERT keyed on the singleton row (id = 1): a - # fresh file has no row yet, and migration 1 is the - # only migration a version-0 database can run, so - # the row is seeded by the same statement batch - # when needed. UPDATE alone would silently match - # zero rows there. - await database.execute( - f"INSERT INTO {VERSION_TABLE} (id, version) " - "VALUES (1, ?) " - "ON CONFLICT (id) DO UPDATE SET version = " - "excluded.version", - (target,), - ) + await step(database, target) applied = True + else: + async with database.transaction(): + # Re-read the version INSIDE the write transaction: + # the outer read was advisory, and the wrapper + # serializes individual statements, not the + # read-decide-apply decision. Under the + # transaction's lock the version cannot change + # between the read and the stamp, and the loser of + # an interleaved race (a queued second caller + # re-running apply_migrations after the winner + # already migrated) re-reads here, sees the + # winner's stamp, and skips instead of re-applying + # a non-idempotent migration with a stale version. + in_tx_version = await read_schema_version(database) + if in_tx_version > latest: + raise RuntimeError( + f"Database schema version {in_tx_version} is " + f"newer than this integration understands " + f"(latest known: {latest}); downgrades are " + f"not supported" + ) + if in_tx_version < target: + for sql in step: + await database.execute(sql) + await stamp_schema_version(database, target) + applied = True + applied_statements = len(step) if applied: LOGGER.info( - "NestQuest schema migrated to version %d " - "(%d statement%s)", + "NestQuest schema migrated to version %d", target, - len(migrations[target - 1]), - "s" if len(migrations[target - 1]) != 1 else "", ) return latest \ No newline at end of file diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index eeacafb..84ac218 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -7,15 +7,19 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv from .const import ( + CONF_ADMIN_USER_IDS, CONF_DAY_ROLLOVER_TIME, CONF_HORIZON_DAYS, CONF_PANEL_IDLE_TIMEOUT, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, DEFAULT_PANEL_IDLE_TIMEOUT, + DOMAIN, ) +from .admin_allowlist import list_admin_ids, set_admin_ids MIN_HORIZON_DAYS = 1 MIN_PANEL_IDLE_TIMEOUT = 30 @@ -55,51 +59,168 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: return errors -def _build_schema(current: dict[str, Any]) -> vol.Schema: - """Build the form schema, pre-filled with the current values.""" - return vol.Schema( - { - vol.Required( - CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS] - ): int, - vol.Required( - CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME] - ): str, - vol.Required( - CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT] - ): int, - } - ) +def _build_schema( + current: dict[str, Any], + *, + admin_choices: dict[str, str] | None = None, + admin_default: list[str] | None = None, +) -> vol.Schema: + """Build the form schema, pre-filled with the current values. + + The admin picker is a multi-select of EXISTING Home Assistant users + (never a free-text field — the allowlist stores user IDs); its + default is the current database allowlist, so an untouched submit + re-saves exactly who is already allowed. + """ + fields: dict[Any, Any] = { + vol.Required( + CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS] + ): int, + vol.Required( + CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME] + ): str, + vol.Required( + CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT] + ): int, + } + if admin_choices is not None: + # HA's supported multi-select validator: the frontend can + # serialize the field as a real picker of the given choices. + fields[ + vol.Optional( + CONF_ADMIN_USER_IDS, default=list(admin_default or []) + ) + ] = cv.multi_select(admin_choices) + return vol.Schema(fields) class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): """Handle the options flow for NestQuest.""" + def _async_database(self): + """Return the runtime database for this entry, or None. + + Real HA only opens the options flow for an existing entry, so + the runtime record (and its database) exists; the None branch + covers direct construction in tests and degraded states. + """ + hass_data = getattr(self.hass, "data", None) + if not isinstance(hass_data, dict): + return None + record = hass_data.get(DOMAIN, {}).get(self.config_entry.entry_id) + database = getattr(record, "database", None) + return database if getattr(database, "connected", False) else None + + async def _async_admin_choices(self) -> dict[str, str]: + """Map HA user id -> display name for the picker. + + Real Home Assistant always provides ``hass.auth``; a missing + one (direct construction in tests, degraded harness) degrades + to an empty picker rather than crashing the form. + """ + auth = getattr(self.hass, "auth", None) + if auth is None: + return {} + users = await auth.async_get_users() + return { + user.id: user.name + for user in users + if getattr(user, "id", None) + } + + async def _async_current_admin_ids(self) -> list[str]: + """Return the current allowlist, or [] without a database.""" + database = self._async_database() + if database is None: + return [] + return await list_admin_ids(database) + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage the NestQuest options.""" + admin_choices = await self._async_admin_choices() + admin_default = await self._async_current_admin_ids() if user_input is None: return self.async_show_form( - step_id="init", data_schema=_build_schema(self._current_values()) + step_id="init", + data_schema=_build_schema( + self._current_values(), + admin_choices=admin_choices, + admin_default=admin_default, + ), ) errors = _async_validate(user_input) + # The admin ids are validated only when the key is present: + # schema-validated submits always carry it (vol.Optional's + # default fills from the current allowlist), while raw callers + # omitting the key keep the field untouched. + admin_ids_present = CONF_ADMIN_USER_IDS in user_input + admin_ids = user_input.get(CONF_ADMIN_USER_IDS) if errors: return self.async_show_form( step_id="init", - data_schema=_build_schema(self._current_values()), + data_schema=_build_schema( + self._current_values(), + admin_choices=admin_choices, + admin_default=admin_default, + ), + errors=errors, + ) + if admin_ids_present and ( + not isinstance(admin_ids, list) + or any(not isinstance(value, str) for value in admin_ids) + ): + errors[CONF_ADMIN_USER_IDS] = "invalid_admin" + elif admin_ids_present and not admin_ids: + # An empty allowlist fails closed: saving zero admins is + # refused, never silently applied. + errors[CONF_ADMIN_USER_IDS] = "no_admins" + elif admin_ids_present and any( + value not in admin_choices for value in admin_ids + ): + # The schema enforces membership for real HA submits; this + # backs the raw-input path up so an unknown id can never + # reach the allowlist. + errors[CONF_ADMIN_USER_IDS] = "invalid_admin" + elif admin_ids_present and len(set(admin_ids)) != len(admin_ids): + # set_admin_ids refuses duplicates with ValueError; a flow + # submission must surface that as a field error instead of + # an unhandled crash. + errors[CONF_ADMIN_USER_IDS] = "invalid_admin" + if errors: + return self.async_show_form( + step_id="init", + data_schema=_build_schema( + self._current_values(), + admin_choices=admin_choices, + admin_default=admin_default, + ), errors=errors, ) - return self.async_create_entry( - title="NestQuest", - data={ - CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS], - CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME], - CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT], - }, - ) + # Apply the picker's selection to the allowlist when the + # runtime database is reachable; the selection is always + # stored on the entry as the disaster-recovery copy. + database = self._async_database() + if database is not None and admin_ids_present: + await set_admin_ids(database, admin_ids) + data = { + CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS], + CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME], + CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT], + } + if admin_ids_present: + data[CONF_ADMIN_USER_IDS] = admin_ids + elif admin_default: + # HA replaces entry.options wholesale, so an omitted picker + # field must carry the CURRENT allowlist forward: dropping + # it would leave only the stale config-flow copy in entry + # data, and a later database loss would resurrect admins + # the owner has since removed. + data[CONF_ADMIN_USER_IDS] = list(admin_default) + return self.async_create_entry(title="NestQuest", data=data) def _current_values(self) -> dict[str, Any]: """Resolve current values from entry options, then entry data, then defaults.""" diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py new file mode 100644 index 0000000..465c105 --- /dev/null +++ b/custom_components/nestquest/presence.py @@ -0,0 +1,477 @@ +"""The presence and custody model (Feature 05). + +PURE module: no Home Assistant imports, no database access, no +timezone logic — it operates on plain calendar dates only (feature +guardrail). The later engine (cycle position, ``is_present``, +overrides, previews) builds directly on this model. + +A :class:`PresenceSchedule` answers "on which weekdays of which cycle +week is this child at this house?" for an N-week repeating custody +pattern. Two of the household's three children follow a two-week +rotation; a child with NO schedule row is present every day (that +default lives in the engine, not here). + +Pattern encoding: the model's canonical serialization is the SAME +pipe-separated CSV the database stores (see schema.py's +``presence_schedules.pattern``): segment ``i`` (week index ``i``) is a +comma-separated list of present weekdays, 0 = Monday .. 6 = Sunday, +e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in week 1. An +empty segment means absent every day of that week. Encoding sorts +each segment's weekdays and uses the exact segment order, so +``decode(encode(schedule))`` round-trips losslessly and two schedules +with equal patterns produce equal strings. + +Anchor-date policy: ``anchor_date`` pins the cycle. Cycle position is +computed from anchor-date arithmetic ONLY — never ISO week numbers or +week parity (D-004): 53-week years silently invert parity on January +1st. The arithmetic lives in the engine task; this module validates +that the anchor is a strict calendar date. + +Validation policy: construction is total — every reject happens in +``__init__`` so no invalid schedule can ever exist: +- ``cycle_length_weeks`` must be an integer 1..4. The 4 cap matches + the database CHECK (schema.py) and keeps anchor arithmetic and the + encoded pattern human-readable; revisit both together if a longer + cycle is ever needed. +- ``pattern`` must cover week indices 0..cycle_length_weeks-1 exactly + (no gaps, no extra weeks) with sets of integers 0..6 (bools + rejected: True would silently mean Monday). +- ``anchor_date`` must be a strict ISO YYYY-MM-DD calendar date (or a + ``datetime.date``). +""" +from __future__ import annotations + +import datetime +from dataclasses import dataclass +from types import MappingProxyType + +#: The schema's documented cap for cycle_length_weeks (schema.py keeps +#: the pattern human-readable and the anchor arithmetic small). +MAX_CYCLE_LENGTH_WEEKS = 4 + +_DATE_FORMAT = "%Y-%m-%d" + + +def _parse_date(value: object, field_name: str) -> datetime.date: + """Accept a datetime.date or a strict YYYY-MM-DD string. + + ``datetime.datetime`` is rejected explicitly: it SUBCLASSES + datetime.date, so a bare isinstance check would store a value with + a time component and every later calendar-date comparison (e.g. + ``PresenceOverride.covers``) would raise TypeError. + """ + if isinstance(value, datetime.datetime): + raise ValueError( + f"{field_name} must be a plain calendar date, not a " + f"datetime with a time component: {value!r}" + ) + if isinstance(value, datetime.date): + return value + if not isinstance(value, str): + raise ValueError( + f"{field_name} must be a datetime.date or an ISO date string, " + f"got {value!r}" + ) + try: + parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date() + except (TypeError, ValueError): + raise ValueError( + f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}" + ) from None + if parsed.isoformat() != value: + raise ValueError( + f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}" + ) + return parsed + + +def _validate_week_index(week: object, cycle_length_weeks: int) -> int: + """Reject non-int week indices (bools included) and out-of-cycle.""" + if isinstance(week, bool) or not isinstance(week, int): + raise ValueError( + f"pattern week index must be an integer, got {week!r}" + ) + if not 0 <= week < cycle_length_weeks: + raise ValueError( + f"pattern week index must be in 0..{cycle_length_weeks - 1}, " + f"got {week}" + ) + return week + + +def _validate_weekday_set(week: int, weekdays: object) -> frozenset[int]: + """Validate one week's present-weekday set.""" + if isinstance(weekdays, (str, bytes)) or weekdays is None: + raise ValueError( + f"pattern[{week}] must be an iterable of weekday ints 0..6, " + f"got {weekdays!r}" + ) + try: + members = list(weekdays) + except TypeError: + raise ValueError( + f"pattern[{week}] must be an iterable of weekday ints 0..6, " + f"got {weekdays!r}" + ) from None + for member in members: + if isinstance(member, bool) or not isinstance(member, int): + raise ValueError( + f"pattern[{week}] weekday must be an int 0..6, got {member!r}" + ) + if not 0 <= member <= 6: + raise ValueError( + f"pattern[{week}] weekday must be in 0..6 (Monday=0), " + f"got {member}" + ) + return frozenset(members) + + +def _validate_cycle_length(value: object) -> int: + """Reject non-int and out-of-range cycle lengths (bools included).""" + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError( + f"cycle_length_weeks must be an integer, got {value!r}" + ) + if not 1 <= value <= MAX_CYCLE_LENGTH_WEEKS: + raise ValueError( + f"cycle_length_weeks must be between 1 and " + f"{MAX_CYCLE_LENGTH_WEEKS}, got {value}" + ) + return value + + +def _validate_child_id(value: object) -> int: + """Reject non-int child ids (bools would silently address child 1).""" + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"child_id must be an integer, got {value!r}") + return value + + +@dataclass(frozen=True, init=False) +class PresenceSchedule: + """One child's N-week repeating presence pattern. + + ``pattern`` maps each week index of the cycle (``0`` .. ``n-1``) + to the set of present weekdays (ints, Monday=0). A week with an + empty set means the child is absent every day of that week — which + is distinct from having no schedule at all (the engine treats a + missing schedule as present every day). + """ + + child_id: int + cycle_length_weeks: int + anchor_date: datetime.date + pattern: MappingProxyType # week index -> frozenset of weekdays + + def __init__( + self, + child_id: int, + cycle_length_weeks: int, + anchor_date: object, + pattern: dict[int, object], + ) -> None: + object.__setattr__(self, "child_id", _validate_child_id(child_id)) + cycle = _validate_cycle_length(cycle_length_weeks) + object.__setattr__(self, "cycle_length_weeks", cycle) + object.__setattr__( + self, "anchor_date", _parse_date(anchor_date, "anchor_date") + ) + if not isinstance(pattern, dict): + raise ValueError( + "pattern must map week index to a set of present " + f"weekdays, got {pattern!r}" + ) + normalised: dict[int, frozenset[int]] = {} + for week, weekdays in pattern.items(): + index = _validate_week_index(week, cycle) + normalised[index] = _validate_weekday_set(index, weekdays) + missing = [ + week for week in range(cycle) if week not in normalised + ] + if missing: + raise ValueError( + f"pattern must cover week indices 0..{cycle - 1}; " + f"missing {missing}" + ) + # Frozen must mean frozen: a bare dict would let callers inject + # unvalidated weekdays (or drop required weeks) AFTER + # construction, bypassing total validation. The mapping + # proxy exposes the validated contents read-only; the + # per-week sets are already frozensets. + object.__setattr__(self, "pattern", MappingProxyType(normalised)) + + def encode(self) -> str: + """Serialize to the database's pipe-separated CSV shape. + + Segments appear in week order; each segment is the week's + present weekdays as a sorted comma-separated list (an empty set + encodes as an empty segment). Two schedules with equal + patterns encode identically. + """ + segments = [ + ",".join(str(day) for day in sorted(self.pattern[week])) + for week in range(self.cycle_length_weeks) + ] + return "|".join(segments) + + @classmethod + def decode( + cls, + child_id: int, + anchor_date: object, + encoded: str, + ) -> "PresenceSchedule": + """Build a schedule from its encoded pattern. + + The cycle length is derived from the segment count, mirroring + the database's segment-count CHECK. Decoding is strict: wrong + shapes, non-numeric or out-of-range weekdays are rejected — + the database CHECKs make such rows unrepresentable, and this + is the same contract on the model side. A segment count above + the documented cycle cap is rejected by the ordinary + construction validation. + """ + if not isinstance(encoded, str): + raise ValueError( + f"encoded pattern must be a string, got {encoded!r}" + ) + segments = encoded.split("|") + pattern: dict[int, set[int]] = {} + for week, segment in enumerate(segments): + weekdays: set[int] = set() + if segment: + for part in segment.split(","): + if len(part) != 1 or part not in "0123456": + raise ValueError( + f"encoded segment {week} carries an invalid " + f"weekday {part!r} (single digits 0-6 only)" + ) + weekdays.add(int(part)) + pattern[week] = weekdays + return cls(child_id, len(segments), anchor_date, pattern) + + +@dataclass(frozen=True, init=False) +class PresenceOverride: + """A date-specific present/absent flag beating any pattern. + + Covers a single date (``end_date == start_date``) or an inclusive + range. A concrete range is the storage shape + (``presence_overrides.end_date`` is NOT NULL), so the model + requires both bounds and validates start <= end. Construction and + validation live here so the engine can trust every instance. + """ + + child_id: int + start_date: datetime.date + end_date: datetime.date + is_present: bool + note: str | None + + def __init__( + self, + child_id: int, + start_date: object, + end_date: object, + is_present: bool, + note: str | None = None, + ) -> None: + object.__setattr__(self, "child_id", _validate_child_id(child_id)) + start = _parse_date(start_date, "start_date") + end = _parse_date(end_date, "end_date") + if end < start: + raise ValueError( + f"end_date must be on or after start_date, got " + f"{end.isoformat()} < {start.isoformat()}" + ) + object.__setattr__(self, "start_date", start) + object.__setattr__(self, "end_date", end) + if not isinstance(is_present, bool): + raise ValueError( + f"is_present must be a real bool, got {is_present!r}" + ) + object.__setattr__(self, "is_present", is_present) + if note is not None and not isinstance(note, str): + raise ValueError(f"note must be a string or None, got {note!r}") + object.__setattr__(self, "note", note) + + def covers(self, date: datetime.date) -> bool: + """Return True when the override applies to ``date``.""" + return self.start_date <= date <= self.end_date + + +def cycle_week_index( + schedule: PresenceSchedule, target_date: object +) -> int: + """Return the cycle week index ``target_date`` falls in. + + Anchor-date arithmetic ONLY (D-004): ``((target_date - + anchor_date).days // 7) % cycle_length_weeks``. Python's floor + division makes dates BEFORE the anchor behave correctly — they + walk the cycle backwards (day -1 is the last week of the cycle, + day -8 wraps one further) instead of inverting, which is exactly + what ISO week parity does wrong in 53-week years. The modulo of + a negative in Python is non-negative, so the index is always a + valid week index. + """ + target = _parse_date(target_date, "target_date") + days = (target - schedule.anchor_date).days + return (days // 7) % schedule.cycle_length_weeks + + +#: Search cap for :meth:`PresenceEngine.next_present_dates`: two full +#: years of daily checks. A child absent every day (an all-absent +#: pattern with a blocking override) would otherwise loop forever; the +#: cap bounds the scan and surfaces the situation as an error instead. +MAX_PREVIEW_SCAN_DAYS = 366 * 2 + + +class PresenceEngine: + """Answers "is this child at this house on this date?". + + Pure and side-effect-free: built from an immutable snapshot of the + children's schedules (``child_id -> PresenceSchedule``), it never + touches the database — callers build a snapshot from the DAO layer + and pass it in. A child with NO schedule entry is present every + day (the third household child's case); a child WITH a schedule is + present exactly when the date's weekday is in that schedule's + pattern for the date's cycle week index (anchor-date arithmetic — + :func:`cycle_week_index`, never ISO week parity). + """ + + def __init__( + self, + schedules: dict[int, PresenceSchedule], + overrides: dict[int, list[PresenceOverride]] | None = None, + ) -> None: + if not isinstance(schedules, dict): + raise ValueError( + f"schedules must map child_id to PresenceSchedule, got " + f"{schedules!r}" + ) + for child_id, schedule in schedules.items(): + # Validate the KEY as a model child id too: True and 1.0 + # compare equal to 1, so {True: schedule(1)} would be + # accepted and then behave differently on lookup. + _validate_child_id(child_id) + if not isinstance(schedule, PresenceSchedule): + raise ValueError( + f"schedules[{child_id!r}] must be a PresenceSchedule, " + f"got {schedule!r}" + ) + if schedule.child_id != child_id: + raise ValueError( + f"schedules is keyed {child_id!r} but holds the " + f"schedule of child {schedule.child_id}" + ) + if overrides is None: + overrides = {} + if not isinstance(overrides, dict): + raise ValueError( + "overrides must map child_id to a list of " + f"PresenceOverride, got {overrides!r}" + ) + for child_id, entries in overrides.items(): + _validate_child_id(child_id) + if not isinstance(entries, list): + raise ValueError( + f"overrides[{child_id!r}] must be a list of " + f"PresenceOverride, got {entries!r}" + ) + for entry in entries: + if not isinstance(entry, PresenceOverride): + raise ValueError( + f"overrides[{child_id!r}] entries must be " + f"PresenceOverride, got {entry!r}" + ) + if entry.child_id != child_id: + raise ValueError( + f"overrides is keyed {child_id!r} but holds an " + f"override of child {entry.child_id}" + ) + # Read-only snapshot: reassignment is refused and no mutator + # exists, so a built engine answers consistently for its + # lifetime. The overrides lists are copied to immutable + # tuples — a shallow mapping copy alone would leave caller- + # owned lists reachable through _overrides, and mutating one + # later would change is_present results. + object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules))) + object.__setattr__( + self, + "_overrides", + MappingProxyType( + {child_id: tuple(entries) for child_id, entries in overrides.items()} + ), + ) + + def is_present(self, child_id: object, date: object) -> bool: + """Return whether ``child_id`` is present on ``date``. + + Overrides take precedence over the repeating pattern: when an + override covers the date, its flag IS the answer — including + for a child with no schedule at all. The DAO layer guarantees + a child's overrides never overlap; if a snapshot still carries + two covering the same date, the one with the LATEST start date + wins (deterministic, and matches "the most specific swap wins"). + """ + if isinstance(child_id, bool) or not isinstance(child_id, int): + raise ValueError(f"child_id must be an integer, got {child_id!r}") + target = _parse_date(date, "date") + entries = self._overrides.get(child_id) or [] + covering = [ + entry for entry in entries if entry.covers(target) + ] + if covering: + winner = max(covering, key=lambda entry: entry.start_date) + return winner.is_present + schedule = self._schedules.get(child_id) + if schedule is None: + # No schedule at all: present every day (household default). + return True + week = cycle_week_index(schedule, target) + return target.weekday() in schedule.pattern[week] + + def next_present_dates( + self, + child_id: object, + start_date: object, + count: int, + ) -> list[datetime.date]: + """Return the next ``count`` dates the child is present. + + Walks forward from ``start_date`` (INCLUSIVE — a parent + previewing "starting Monday" expects Monday itself when the + child is present) checking :meth:`is_present` for every day, + so overrides and the pattern are honoured identically. The + admin Schedule tab renders this preview so a parent can confirm + a custody pattern is right before saving it. + + A child absent every scanned day raises ValueError after the + two-year search cap — an always-absent child would otherwise + loop forever. ``count`` must be a positive integer. + """ + _validate_child_id(child_id) + start = _parse_date(start_date, "start_date") + if isinstance(count, bool) or not isinstance(count, int): + raise ValueError(f"count must be an integer, got {count!r}") + if count < 1: + raise ValueError(f"count must be at least 1, got {count}") + found: list[datetime.date] = [] + day = start + for _ in range(MAX_PREVIEW_SCAN_DAYS): + if self.is_present(child_id, day): + found.append(day) + if len(found) == count: + return found + day += datetime.timedelta(days=1) + raise ValueError( + f"child {child_id} is not present on any of the " + f"{MAX_PREVIEW_SCAN_DAYS} days from {start.isoformat()}; " + f"{count} present day(s) cannot be previewed" + ) + + def __setattr__(self, name: object, value: object) -> None: + raise AttributeError( + "PresenceEngine is an immutable snapshot; build a new engine " + "instead of mutating this one" + ) diff --git a/custom_components/nestquest/schema.py b/custom_components/nestquest/schema.py index f76cf6f..ab1fb35 100644 --- a/custom_components/nestquest/schema.py +++ b/custom_components/nestquest/schema.py @@ -86,30 +86,54 @@ date range; a single-day override stores the same date in both columns. ``is_present`` 0/1 marks the child absent/present for the whole range; ``note`` is optional free text. -Task instances: ``task_instances`` deliberately carries NO completion +Multi-assignee definitions (D-008): ``quest_definitions`` carries no +``child_id`` column. Assignment lives in +``quest_definition_assignees``, one row per (definition, child) with a +composite primary key and foreign keys to the definition and the child, +so "brush teeth" is one definition covering three children. The +migration runner rebuilds a pre-D-008 table into this shape and copies +its single-assignee column across. The composite primary key makes +duplicate assignment impossible at the storage layer. + +Quest windows (D-008): ``quest_definition_windows`` declares which day +windows a definition spans — one row per (definition, window), the +composite primary key making a window idempotent per definition. +``window`` is CHECK-constrained to the three spellings ``const`` owns; +``due_time`` optionally pins a due time inside the window and is +caller-validated (strict HH:MM) by the DAO layer, matching the date +policy above. The window clock ranges live in ``const.py``, not here: +this layer stores what the caller declares and never interprets clocks. + +Quest instances: ``quest_instances`` deliberately carries NO completion status column. An instance's current state derives from the latest row in ``completion_events`` (Feature 08), so materialization (Feature 07) can insert instances without knowing anything about completion and the append-only log stays the single source of truth. ``definition_id``, -``due_date`` and ``child_id`` snapshot the materialization-time facts -("why this instance exists"): reassignment and rule edits change future -instances only and never rewrite these rows. ``due_time`` snapshots -the definition's optional due time at generation for the same reason — -the instance keeps its 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). +``child_id``, ``due_date`` and ``window`` snapshot the +materialization-time facts ("why this instance exists"): assignment +edits and rule edits change future instances only and never rewrite +these rows. ``window`` records which declared day window produced the +instance (D-008), CHECK-constrained to the same three spellings +``const`` owns. ``due_time`` snapshots the window's optional due time +at generation for the same reason — the instance keeps its +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 -table-level ``UNIQUE`` constraint rather than a separate ``CREATE -UNIQUE INDEX`` statement. SQLite materializes it as an implicit unique -index (``sqlite_autoindex_task_instances_1``) — the required "unique -index prevents duplicate instances" — and the constraint form cannot be -silently dropped the way a standalone index can, so the duplicate door -stays shut. Idempotent materialization keys on exactly this pair. 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. +Uniqueness: the (definition_id, child_id, due_date, window) tuple is +declared as a table-level ``UNIQUE`` constraint rather than a separate +``CREATE UNIQUE INDEX`` statement. SQLite materializes it as an +implicit unique index (``sqlite_autoindex_quest_instances_1``) — the +required "unique index prevents duplicate instances" — and the +constraint form cannot be silently dropped the way a standalone index +can, so the duplicate door stays shut. Idempotent materialization keys +on exactly this tuple: the twice-daily case (one definition, two +windows, one child, one date) yields two rows and the shared case (one +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 module: children and definitions are deactivated, never deleted, so a @@ -128,18 +152,19 @@ representable — missed is a derived state announced on the HA event bus (Feature 10), never an appended event. ``child_id`` is deliberately denormalized (it is also derivable via -``task_instances``): history reporting (Feature 13) filters and rates +``quest_instances``): history reporting (Feature 13) filters and rates per child directly against the log, and the log should stay self-describing without joins. Actor policy: ``actor_source`` distinguishes an authenticated HA user (``'user'``, with ``actor_user_id`` holding the HA user id) from a panel tap with no user context (``'panel'``, with ``actor_user_id`` -NULL — the tapped child profile is already ``child_id``). A coherence -CHECK makes the pair shape non-negotiable: ``'user'`` requires -``actor_user_id``, ``'panel'`` forbids it. Other actor sources are -rejected; if an unattended system actor is ever needed, that is a -schema migration, not a silent widening. +NULL — the tapped child profile is ``actor_child_id``, D-008). Two +coherence CHECKs make both shapes non-negotiable: ``'user'`` requires +``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` requires +``actor_child_id`` (the tapped profile) and forbids ``actor_user_id``. +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 ``completed`` events — at completion time the due date is known and the @@ -219,18 +244,45 @@ SCHEMA_V1_SCHEDULE_RULES_DDL: list[str] = [ """, ] -SCHEMA_V1_TASK_DEFINITIONS_DDL: list[str] = [ +#: Column body of ``quest_definitions``, shared between the v1 DDL +#: below and the multi-assignee rebuild migration, which must recreate +#: the table in exactly this shape (D-008: one definition, many +#: assignees via ``quest_definition_assignees`` — no ``child_id`` +#: column on the definition itself). +QUEST_DEFINITIONS_TABLE_SQL = """( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + icon TEXT, + schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id), + due_time TEXT, + is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)), + created_at TEXT NOT NULL +)""" + +SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [ + f""" + CREATE TABLE IF NOT EXISTS quest_definitions {QUEST_DEFINITIONS_TABLE_SQL} + """, +] + +SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL: list[str] = [ """ - CREATE TABLE IF NOT EXISTS task_definitions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - description TEXT, - icon TEXT, + CREATE TABLE IF NOT EXISTS quest_definition_assignees ( + definition_id INTEGER NOT NULL REFERENCES quest_definitions(id), child_id INTEGER NOT NULL REFERENCES children(id), - schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id), + PRIMARY KEY (definition_id, child_id) + ) + """, +] + +SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL: list[str] = [ + """ + CREATE TABLE IF NOT EXISTS quest_definition_windows ( + definition_id INTEGER NOT NULL REFERENCES quest_definitions(id), + window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')), due_time TEXT, - is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)), - created_at TEXT NOT NULL + PRIMARY KEY (definition_id, window) ) """, ] @@ -271,43 +323,64 @@ SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [ """, ] -SCHEMA_V1_TASK_INSTANCES_DDL: list[str] = [ - """ - CREATE TABLE IF NOT EXISTS task_instances ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - definition_id INTEGER NOT NULL REFERENCES task_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) - ) +#: 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] = [ + f""" + CREATE TABLE IF NOT EXISTS quest_instances {QUEST_INSTANCES_TABLE_SQL} """, ] -SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [ - """ - CREATE TABLE IF NOT EXISTS completion_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - instance_id INTEGER NOT NULL REFERENCES task_instances(id), - child_id INTEGER NOT NULL REFERENCES children(id), - event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')), - actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')), - actor_user_id TEXT, - occurred_at TEXT NOT NULL, - was_on_time INTEGER CHECK ( - was_on_time IS NULL - OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1)) - ), - CHECK ( - (actor_source = 'user' AND actor_user_id IS NOT NULL) - OR (actor_source = 'panel' AND actor_user_id IS NULL) - ), - CHECK ( - (event_type = 'completed' AND was_on_time IS NOT NULL) - OR (event_type = 'uncompleted') - ) +#: Column body of ``completion_events``, shared between the v1 DDL +#: below and the actor-child rebuild migration, which must recreate the +#: table in exactly this shape (D-008: ``actor_child_id`` records which +#: panel profile was tapped — set for panel events, NULL for admin +#: ones; legacy panel rows backfill to ``child_id``, the child they +#: always were). +COMPLETION_EVENTS_TABLE_SQL = """( + 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 CHECK (event_type IN ('completed', 'uncompleted')), + actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')), + actor_user_id TEXT, + actor_child_id INTEGER REFERENCES children(id), + occurred_at TEXT NOT NULL, + was_on_time INTEGER CHECK ( + was_on_time IS NULL + OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1)) + ), + CHECK ( + (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} """, ] @@ -315,10 +388,12 @@ SCHEMA_V1_STATEMENTS: list[str] = [ *SCHEMA_V1_CHILDREN_DDL, *SCHEMA_V1_ADMIN_USERS_DDL, *SCHEMA_V1_SCHEDULE_RULES_DDL, - *SCHEMA_V1_TASK_DEFINITIONS_DDL, + *SCHEMA_V1_QUEST_DEFINITIONS_DDL, + *SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, + *SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, *SCHEMA_V1_PRESENCE_SCHEDULES_DDL, *SCHEMA_V1_PRESENCE_OVERRIDES_DDL, - *SCHEMA_V1_TASK_INSTANCES_DDL, + *SCHEMA_V1_QUEST_INSTANCES_DDL, *SCHEMA_V1_COMPLETION_EVENTS_DDL, ] diff --git a/custom_components/nestquest/strings.json b/custom_components/nestquest/strings.json index 277492d..a37a39a 100644 --- a/custom_components/nestquest/strings.json +++ b/custom_components/nestquest/strings.json @@ -22,13 +22,16 @@ "data": { "horizon_days": "Generation horizon (days)", "day_rollover_time": "Day rollover time (HH:MM)", - "panel_idle_timeout": "Panel idle timeout (seconds)" + "panel_idle_timeout": "Panel idle timeout (seconds)", + "admin_user_ids": "Admin users (parents)" } } }, "errors": { "invalid": "Invalid value.", - "invalid_time": "Time must use 24-hour HH:MM format." + "invalid_time": "Time must use 24-hour HH:MM format.", + "no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.", + "invalid_admin": "Invalid admin selection." } } -} \ No newline at end of file +} diff --git a/custom_components/nestquest/translations/en.json b/custom_components/nestquest/translations/en.json index 277492d..a37a39a 100644 --- a/custom_components/nestquest/translations/en.json +++ b/custom_components/nestquest/translations/en.json @@ -22,13 +22,16 @@ "data": { "horizon_days": "Generation horizon (days)", "day_rollover_time": "Day rollover time (HH:MM)", - "panel_idle_timeout": "Panel idle timeout (seconds)" + "panel_idle_timeout": "Panel idle timeout (seconds)", + "admin_user_ids": "Admin users (parents)" } } }, "errors": { "invalid": "Invalid value.", - "invalid_time": "Time must use 24-hour HH:MM format." + "invalid_time": "Time must use 24-hour HH:MM format.", + "no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.", + "invalid_admin": "Invalid admin selection." } } -} \ No newline at end of file +} diff --git a/pyproject.toml b/pyproject.toml index cd7c2c2..49140f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nestquest" -version = "0.3.0" +version = "0.4.0" description = "NestQuest Home Assistant Integration" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/conftest.py b/tests/conftest.py index da8c42a..5694471 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,7 +29,7 @@ import tempfile import weakref from pathlib import Path from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -42,6 +42,8 @@ _HA_MODULES = ( "homeassistant.config_entries", "homeassistant.data_entry_flow", "homeassistant.exceptions", + "homeassistant.helpers", + "homeassistant.helpers.config_validation", ) def _find_spec(name: str): @@ -79,6 +81,8 @@ for _parent, _child in ( ("homeassistant", "data_entry_flow"), ("homeassistant", "core"), ("homeassistant", "exceptions"), + ("homeassistant", "helpers"), + ("homeassistant.helpers", "config_validation"), ): setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"]) @@ -181,6 +185,30 @@ class ConfigEntryNotReady(Exception): _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady +import voluptuous as vol + + +def _multi_select(choices: dict): + """Mirror homeassistant.helpers.config_validation.multi_select. + + The options flow's admin picker uses HA's supported multi-select + validator (schema-serializable by HA's frontend); the mock provides + the same contract: a list whose members are keys of ``choices``. + """ + + def _validate(selected): + if not isinstance(selected, list): + raise vol.Invalid("Not a list") + for value in selected: + if value not in choices: + raise vol.Invalid(f"{value} is not a valid option") + return selected + + return _validate + + +_ha_mock("homeassistant.helpers.config_validation").multi_select = _multi_select + # --------------------------------------------------------------------------- # Shared helpers for the behavior tests. @@ -218,12 +246,18 @@ def make_config_entry( entry_id: str = "test_entry", options: dict | None = None, data: dict | None = None, + context: dict | None = None, ): - """Create a stub config entry shaped like HA's ConfigEntry for tests.""" + """Create a stub config entry shaped like HA's ConfigEntry for tests. + + ``context`` mirrors HA's entry context (the flow that created the + entry carries the acting HA user under ``context["user_id"]``). + """ entry = SimpleNamespace( entry_id=entry_id, options=dict(options or {}), data=dict(data or {}), + context=dict(context or {}), ) entry.add_update_listener = None return entry @@ -247,6 +281,10 @@ def make_hass() -> tuple: config=MagicMock(), config_entries=SimpleNamespace(async_reload=None), ) + # hass.auth mirrors the real surface the integration reads: an + # async_get_users list the tests can repoint at will (the setup + # path resolves HA owner accounts as the last-resort admin seed). + hass.auth = SimpleNamespace(async_get_users=AsyncMock(return_value=[])) def _cleanup_config_dir(): import shutil @@ -302,8 +340,15 @@ async def hass(): def make_entry(): """Provide the config-entry factory as a fixture.""" - def _factory(entry_id: str = "test_entry", options: dict | None = None, data: dict | None = None): - return make_config_entry(entry_id=entry_id, options=options, data=data) + def _factory( + entry_id: str = "test_entry", + options: dict | None = None, + data: dict | None = None, + context: dict | None = None, + ): + return make_config_entry( + entry_id=entry_id, options=options, data=data, context=context + ) return _factory diff --git a/tests/test_admin_allowlist.py b/tests/test_admin_allowlist.py new file mode 100644 index 0000000..5640d2d --- /dev/null +++ b/tests/test_admin_allowlist.py @@ -0,0 +1,471 @@ +"""Tests for admin_allowlist.py: the Feature 03 allowlist business layer.""" +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from custom_components.nestquest import async_setup_entry, async_unload_entry +from custom_components.nestquest.admin_allowlist import ( + list_admin_ids, + seed_setup_admin, + set_admin_ids, +) +from custom_components.nestquest.dao_children import AdminUsersDao +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 + + +# --------------------------------------------------------------------------- +# set_admin_ids: read/write with the fail-closed guarantee +# --------------------------------------------------------------------------- + + +def test_set_admin_ids_round_trip(tmp_path) -> None: + async def _body(database): + await set_admin_ids(database, ["user-owner", "user-parent"]) + # Membership is the contract; AdminUsersDao.list orders by id. + assert sorted(await list_admin_ids(database)) == [ + "user-owner", + "user-parent", + ] + # Replace: the removed id is gone, the new one present. + await set_admin_ids(database, ["user-parent", "user-grandma"]) + assert sorted(await list_admin_ids(database)) == [ + "user-grandma", + "user-parent", + ] + return await list_admin_ids(database) + + _with_db(tmp_path, "set-round-trip.db")(_body) + + +def test_set_admin_ids_rejects_empty_allowlist(tmp_path) -> None: + """Removing the last admin (or saving an empty selection) is + refused with a clear error: an empty allowlist fails closed, so + the owner must never be able to reach one through this API.""" + async def _body(database): + await set_admin_ids(database, ["user-owner"]) + with pytest.raises(ValueError, match="at least one admin"): + await set_admin_ids(database, []) + # The refused write left the allowlist intact. + assert await list_admin_ids(database) == ["user-owner"] + return None + + _with_db(tmp_path, "set-empty-refused.db")(_body) + + +@pytest.mark.parametrize( + "bad", [None, "notalist", [42], [None], [""], [" "], 5] +) +def test_set_admin_ids_rejects_malformed_input(tmp_path, bad) -> None: + async def _body(database): + with pytest.raises(ValueError): + await set_admin_ids(database, bad) + assert await list_admin_ids(database) == [] + return None + + _with_db(tmp_path, "set-malformed.db")(_body) + + +def test_set_admin_ids_rejects_duplicates(tmp_path) -> None: + async def _body(database): + with pytest.raises(ValueError, match="duplicates"): + await set_admin_ids(database, ["user-a", "user-a"]) + assert await list_admin_ids(database) == [] + return None + + _with_db(tmp_path, "set-duplicates.db")(_body) + + +def test_set_admin_ids_trims_and_replaces_atomically(tmp_path) -> None: + async def _body(database): + await set_admin_ids(database, [" user-a "]) + assert await list_admin_ids(database) == ["user-a"] + # Sync that both adds and removes in one write. + await set_admin_ids(database, ["user-b", "user-c"]) + assert await list_admin_ids(database) == ["user-b", "user-c"] + return None + + _with_db(tmp_path, "set-trim.db")(_body) + + +# --------------------------------------------------------------------------- +# seed_setup_admin: the owner is never locked out on first setup +# --------------------------------------------------------------------------- + + +def test_seed_adds_context_user_on_first_setup(tmp_path) -> None: + async def _body(database): + seeded = await seed_setup_admin( + database, stored_admin_ids=None, context_user_id="owner-1" + ) + assert seeded == ["owner-1"] + assert await list_admin_ids(database) == ["owner-1"] + return seeded + + _with_db(tmp_path, "seed-context.db")(_body) + + +def test_seed_prefers_stored_entry_ids_over_context(tmp_path) -> None: + async def _body(database): + seeded = await seed_setup_admin( + database, + stored_admin_ids=["user-a", "user-b"], + context_user_id="owner-1", + ) + assert seeded == ["user-a", "user-b"] + return seeded + + _with_db(tmp_path, "seed-stored.db")(_body) + + +def test_seed_leaves_nonempty_allowlist_untouched(tmp_path) -> None: + """Re-running setup must never resurrect a deliberately narrowed + list: only an EMPTY allowlist is seeded.""" + async def _body(database): + await set_admin_ids(database, ["user-kept"]) + seeded = await seed_setup_admin( + database, + stored_admin_ids=["user-a", "user-b"], + context_user_id="owner-1", + ) + assert seeded == ["user-kept"] + assert await list_admin_ids(database) == ["user-kept"] + return seeded + + _with_db(tmp_path, "seed-narrowed.db")(_body) + + +def test_seed_with_nothing_available_leaves_empty(tmp_path) -> None: + async def _body(database): + seeded = await seed_setup_admin( + database, stored_admin_ids=None, context_user_id=None + ) + assert seeded == [] + return seeded + + _with_db(tmp_path, "seed-nothing.db")(_body) + + +def test_seed_rejects_malformed_candidates(tmp_path) -> None: + async def _body(database): + with pytest.raises(ValueError): + await seed_setup_admin( + database, stored_admin_ids=[42], context_user_id=None + ) + with pytest.raises(ValueError): + await seed_setup_admin( + database, stored_admin_ids=None, context_user_id=" " + ) + assert await list_admin_ids(database) == [] + return None + + _with_db(tmp_path, "seed-malformed.db")(_body) + + +# --------------------------------------------------------------------------- +# Setup wiring: async_setup_entry seeds automatically +# --------------------------------------------------------------------------- + + +async def _setup_entry(hass, entry, registry): + from tests.test_lifecycle import _wire + + entry = _wire(entry, registry) + await async_setup_entry(hass, entry) + return entry + + +async def test_setup_seeds_allowlist_from_entry_context( + hass, make_entry +) -> None: + entry = await _setup_entry( + hass, + make_entry(context={"user_id": "ha-owner"}), + hass.registry, + ) + database = entry.runtime_data.database + try: + assert await list_admin_ids(database) == ["ha-owner"] + finally: + await database.close() + assert await async_unload_entry(hass, entry) is True + + +async def test_setup_seeds_allowlist_from_stored_entry_data( + hass, make_entry +) -> None: + """A lost database re-seeded from the entry's stored admin copy.""" + from custom_components.nestquest.const import CONF_ADMIN_USER_IDS + + entry = await _setup_entry( + hass, + make_entry(data={CONF_ADMIN_USER_IDS: ["user-a", "user-b"]}), + hass.registry, + ) + database = entry.runtime_data.database + try: + assert await list_admin_ids(database) == ["user-a", "user-b"] + finally: + await database.close() + assert await async_unload_entry(hass, entry) is True + + +async def test_setup_without_context_leaves_allowlist_empty( + hass, make_entry +) -> None: + """Fail closed: no context user and no stored copy means an empty + allowlist — nobody is admin until the owner configures it.""" + entry = await _setup_entry(hass, make_entry(), hass.registry) + database = entry.runtime_data.database + try: + assert await list_admin_ids(database) == [] + finally: + await database.close() + assert await async_unload_entry(hass, entry) is True + + +async def test_setup_does_not_reseed_narrowed_allowlist( + hass, make_entry +) -> None: + from custom_components.nestquest import async_unload_entry + from custom_components.nestquest.const import CONF_ADMIN_USER_IDS + + entry = await _setup_entry( + hass, make_entry(context={"user_id": "ha-owner"}), hass.registry + ) + database = entry.runtime_data.database + await set_admin_ids(database, ["user-parent"]) + await database.close() + assert await async_unload_entry(hass, entry) is True + + # Reload with the SAME context: the narrowed list survives. + entry_reloaded = await _setup_entry( + hass, make_entry(context={"user_id": "ha-owner"}), hass.registry + ) + database_b = entry_reloaded.runtime_data.database + try: + assert await list_admin_ids(database_b) == ["user-parent"] + finally: + await database.close() + assert await async_unload_entry(hass, entry_reloaded) is True + + +async def test_setup_seed_prefers_options_over_entry_data( + hass, make_entry +) -> None: + """Seed precedence on a fresh database: the options flow's saved + list (the current one) beats the config flow's original copy.""" + from custom_components.nestquest.const import CONF_ADMIN_USER_IDS + + entry = await _setup_entry( + hass, + make_entry( + options={CONF_ADMIN_USER_IDS: ["user-current"]}, + data={CONF_ADMIN_USER_IDS: ["ha-owner"]}, + context={"user_id": "ha-owner"}, + ), + hass.registry, + ) + database = entry.runtime_data.database + try: + assert await list_admin_ids(database) == ["user-current"] + finally: + await database.close() + assert await async_unload_entry(hass, entry) is True + + +async def test_setup_owner_fallback_seeds_owner_accounts( + hass, make_entry +) -> None: + """Last-resort seed: with no persisted copy and no flow-context + user, HA owner accounts are seeded so first setup is never + ownerless (the real flow may lose the context; owners can already + do everything in HA).""" + from unittest.mock import AsyncMock + + hass.auth.async_get_users = AsyncMock( + return_value=[ + SimpleNamespace(id="owner-1", is_owner=True), + SimpleNamespace(id="member-1", is_owner=False), + SimpleNamespace(id="owner-2", is_owner=True), + ] + ) + entry = await _setup_entry(hass, make_entry(), hass.registry) + database = entry.runtime_data.database + try: + assert sorted(await list_admin_ids(database)) == ["owner-1", "owner-2"] + finally: + await database.close() + assert await async_unload_entry(hass, entry) is True + + +async def test_setup_owner_fallback_not_used_when_context_present( + hass, make_entry +) -> None: + """The context user (flow caller) wins over the owner fallback: + a non-owner parent configuring the integration becomes the admin.""" + from unittest.mock import AsyncMock + + hass.auth.async_get_users = AsyncMock( + return_value=[SimpleNamespace(id="owner-1", is_owner=True)] + ) + entry = await _setup_entry( + hass, + make_entry(context={"user_id": "parent-configured"}), + hass.registry, + ) + database = entry.runtime_data.database + try: + assert await list_admin_ids(database) == ["parent-configured"] + finally: + await database.close() + assert await async_unload_entry(hass, entry) is True + + +def test_seed_owner_ids_argument(tmp_path) -> None: + async def _body(database): + # Malformed owner lists are rejected like any other candidate — + # while the allowlist is still empty, so the seed proceeds far + # enough to validate. + with pytest.raises(ValueError): + await seed_setup_admin( + database, + stored_admin_ids=None, + context_user_id=None, + owner_ids=[7], + ) + assert await list_admin_ids(database) == [] + seeded = await seed_setup_admin( + database, + stored_admin_ids=None, + context_user_id=None, + owner_ids=["owner-1"], + ) + assert seeded == ["owner-1"] + return seeded + + _with_db(tmp_path, "seed-owner-arg.db")(_body) + + +async def test_reopened_setup_failure_closes_database( + hass, make_entry, monkeypatch +) -> None: + """A seeding failure on the reopened-record path closes the just- + opened connection before re-raising (no leaked handles across HA's + setup retries).""" + import pytest as pytest_module + from unittest.mock import AsyncMock + + import custom_components.nestquest as nestquest_module + from custom_components.nestquest.db import NestQuestDatabase + + # First: a successful setup to create the runtime record and file. + entry = await _setup_entry(hass, make_entry(), hass.registry) + await async_unload_entry(hass, entry) + + # Now a re-setup whose seeding fails after the reopen. + async def _failing_seed(database, **kwargs): + raise RuntimeError("seed boom") + + monkeypatch.setattr( + nestquest_module, "seed_setup_admin", _failing_seed, raising=True + ) + + close_calls: list[bool] = [] + original_close = NestQuestDatabase.close + + async def _spy_close(self): + close_calls.append(True) + return await original_close(self) + + monkeypatch.setattr(NestQuestDatabase, "close", _spy_close) + + with pytest_module.raises(RuntimeError, match="seed boom"): + await _setup_entry(hass, entry, hass.registry) + # The reopen path opened AND closed a connection; the failure is + # the last thing observed. + assert close_calls[-1] is True + + +# --------------------------------------------------------------------------- +# is_admin: the fail-closed resolver (Feature 09's verdict function) +# --------------------------------------------------------------------------- + + +def test_is_admin_verdict_matrix(tmp_path) -> None: + async def _body(database): + from custom_components.nestquest.admin_allowlist import is_admin + + await set_admin_ids(database, ["user-owner"]) + assert await is_admin(database, "user-owner") is True + assert await is_admin(database, "user-other") is False + # Unknown, malformed, and absent identities all fail closed. + assert await is_admin(database, "") is False + assert await is_admin(database, " user-owner ") is False, ( + "ids are stored trimmed; an untrimmed lookup is a different " + "id and must not match" + ) + assert await is_admin(database, None) is False + assert await is_admin(database, 42) is False + assert await is_admin(database, ["user-owner"]) is False + assert await is_admin(database, True) is False + return None + + _with_db(tmp_path, "resolver-matrix.db")(_body) + + +def test_is_admin_empty_allowlist_fails_closed(tmp_path) -> None: + """An empty allowlist returns False for EVERYONE, not True.""" + async def _body(database): + from custom_components.nestquest.admin_allowlist import is_admin + + for candidate in ("user-owner", "", None, 1): + assert await is_admin(database, candidate) is False + return None + + _with_db(tmp_path, "resolver-empty.db")(_body) + + +def test_is_admin_false_after_last_admin_removal_attempt(tmp_path) -> None: + """The refusal keeps the verdict sound: a refused removal leaves + the admin in place, so is_admin still resolves True.""" + async def _body(database): + from custom_components.nestquest.admin_allowlist import is_admin + + await set_admin_ids(database, ["user-owner"]) + with pytest.raises(ValueError, match="at least one admin"): + await set_admin_ids(database, []) + assert await is_admin(database, "user-owner") is True + return None + + _with_db(tmp_path, "resolver-refusal.db")(_body) diff --git a/tests/test_children_registry.py b/tests/test_children_registry.py new file mode 100644 index 0000000..6b84a18 --- /dev/null +++ b/tests/test_children_registry.py @@ -0,0 +1,842 @@ +"""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, + reorder_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) + + +@pytest.mark.parametrize("bad", [True, False, 1.0, "1", None]) +def test_edit_child_non_int_id_rejected(tmp_path, bad) -> None: + """SQLite binds bools as integers: True must not address child 1, + and floats must not round onto a real profile.""" + async def _body(database): + child = await create_child(database, "Ada") + with pytest.raises(ValueError, match="child_id must be an integer"): + await edit_child(database, bad, display_name="X") + # The refused edit left the profile untouched. + assert (await list_children(database))[0].display_name == "Ada" + return None + + _with_db(tmp_path, "edit-bad-id.db")(_body) + + +@pytest.mark.parametrize("bad", [True, False, 1.0, "1", None]) +def test_set_child_active_non_int_id_rejected(tmp_path, bad) -> None: + async def _body(database): + child = await create_child(database, "Ada") + with pytest.raises(ValueError, match="child_id must be an integer"): + await set_child_active(database, bad, False) + assert (await list_children(database))[0].is_active is True + return None + + _with_db(tmp_path, "set-active-bad-id.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) + + +# --------------------------------------------------------------------------- +# reorder_children: complete permutation or nothing changes +# --------------------------------------------------------------------------- + + +def test_reorder_children_persists_display_order(tmp_path) -> None: + async def _body(database): + first = await create_child(database, "Ada", sort_order=0) + second = await create_child(database, "Bo", sort_order=1) + third = await create_child(database, "Cleo", sort_order=2) + await reorder_children(database, [third.id, first.id, second.id]) + # The active list returns children in the new sort order. + listed = await list_children(database, active_only=True) + assert [c.id for c in listed] == [third.id, first.id, second.id] + assert [c.sort_order for c in listed] == [0, 1, 2] + return listed + + _with_db(tmp_path, "reorder-round-trip.db")(_body) + + +def test_reorder_children_partial_list_rejected_order_unchanged( + tmp_path, +) -> None: + async def _body(database): + first = await create_child(database, "Ada", sort_order=0) + second = await create_child(database, "Bo", sort_order=1) + third = await create_child(database, "Cleo", sort_order=2) + with pytest.raises(ValueError, match="missing child ids"): + await reorder_children(database, [third.id, first.id]) + listed = await list_children(database) + # Nothing changed: rejection happens before any write. + assert [c.id for c in listed] == [first.id, second.id, third.id] + assert [c.sort_order for c in listed] == [0, 1, 2] + return listed + + _with_db(tmp_path, "reorder-partial.db")(_body) + + +def test_reorder_children_unknown_id_rejected_order_unchanged( + tmp_path, +) -> None: + async def _body(database): + first = await create_child(database, "Ada", sort_order=0) + second = await create_child(database, "Bo", sort_order=1) + with pytest.raises(ValueError, match="unknown child ids"): + await reorder_children(database, [second.id, first.id, 999]) + listed = await list_children(database) + assert [c.id for c in listed] == [first.id, second.id] + assert [c.sort_order for c in listed] == [0, 1] + return listed + + _with_db(tmp_path, "reorder-unknown.db")(_body) + + +def test_reorder_children_empty_list_rejected_when_children_exist( + tmp_path, +) -> None: + """An empty list is the maximally partial reorder: rejected while + any child exists (against an empty table it is a complete + permutation and stays a harmless no-op).""" + async def _body(database): + first = await create_child(database, "Ada", sort_order=0) + with pytest.raises(ValueError, match="missing child ids"): + await reorder_children(database, []) + assert [c.sort_order for c in await list_children(database)] == [0] + return None + + _with_db(tmp_path, "reorder-empty.db")(_body) + + +def test_reorder_children_duplicate_and_non_int_rejected(tmp_path) -> None: + async def _body(database): + first = await create_child(database, "Ada", sort_order=0) + second = await create_child(database, "Bo", sort_order=1) + with pytest.raises(ValueError, match="duplicate child ids"): + await reorder_children(database, [first.id, first.id, second.id]) + with pytest.raises(ValueError, match="child_id must be an integer"): + await reorder_children(database, [first.id, second.id, True]) + with pytest.raises(ValueError, match="child_id must be an integer"): + await reorder_children(database, [first.id, second.id, "3"]) + listed = await list_children(database) + assert [c.sort_order for c in listed] == [0, 1] + return listed + + _with_db(tmp_path, "reorder-bad-inputs.db")(_body) + + +def test_reorder_children_inactive_children_included(tmp_path) -> None: + """Inactive children keep their display position: the permutation + covers the whole table, not just the active list.""" + async def _body(database): + first = await create_child(database, "Ada", sort_order=0) + second = await create_child(database, "Bo", sort_order=1) + await set_child_active(database, second.id, False) + # Both ids are required even though one is inactive. + await reorder_children(database, [second.id, first.id]) + listed = await list_children(database) + assert [c.id for c in listed] == [second.id, first.id] + assert [c.sort_order for c in listed] == [0, 1] + # The active list skips the inactive child but keeps position. + assert [c.id for c in await list_children(database, active_only=True)] == [ + first.id + ] + return listed + + _with_db(tmp_path, "reorder-inactive.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}" diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index b744bf4..246e59b 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -154,4 +154,25 @@ def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None: ) assert node.value != "nestquest.db", ( f"Found hard-coded database filename in {py_file.name}:{node.lineno}" - ) \ No newline at end of file + ) + +async def test_flow_persists_flow_user_as_initial_admin(hass, make_flow) -> None: + """The HA user completing the flow lands in entry data as the + initial admin copy: entry.context is not persisted by real HA, so + the durable record must carry the owner.""" + from custom_components.nestquest.const import CONF_ADMIN_USER_IDS + + flow = make_flow(hass) + flow.context = {"user_id": "ha-owner"} + result = await flow.async_step_user({}) + assert result["type"] == "create_entry" + assert result["data"] == {CONF_ADMIN_USER_IDS: ["ha-owner"]} + + +async def test_flow_without_context_user_keeps_data_untouched( + hass, make_flow +) -> None: + flow = make_flow(hass) + result = await flow.async_step_user({"horizon_days": 7}) + assert result["type"] == "create_entry" + assert result["data"] == {"horizon_days": 7} diff --git a/tests/test_const.py b/tests/test_const.py index aea1bd9..85ff105 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -73,6 +73,36 @@ def test_panel_idle_timeout_config() -> None: assert const.DEFAULT_PANEL_IDLE_TIMEOUT > 0 +def test_quest_windows_canonical_order() -> None: + """QUEST_WINDOWS is exactly the three spellings in canonical order.""" + assert const.QUEST_WINDOWS == ("morning", "afternoon", "evening") + assert const.WINDOW_MORNING == "morning" + assert const.WINDOW_AFTERNOON == "afternoon" + assert const.WINDOW_EVENING == "evening" + # No duplicates, every name covered by its individual constant. + assert len(set(const.QUEST_WINDOWS)) == 3 + + +def test_window_clock_ranges_cover_every_window() -> None: + """Each window has a strict HH:MM (start, end) range. + + The ranges follow the documented boundaries (D-008): morning ends + 11:59, afternoon spans 12:00-17:00, evening 17:00-21:00, and each + window's end equals the next window's start (the boundary belongs + to the later window at classification time). + """ + assert const.WINDOW_CLOCK_RANGES == { + "morning": ("00:00", "11:59"), + "afternoon": ("12:00", "17:00"), + "evening": ("17:00", "21:00"), + } + for window in const.QUEST_WINDOWS: + start, end = const.WINDOW_CLOCK_RANGES[window] + for value in (start, end): + parsed = datetime.strptime(value, "%H:%M").time() + assert parsed.strftime("%H:%M") == value + + def test_no_hardcoded_domain_or_db_filename_in_modules() -> None: """Ensure no module other than const.py hardcodes DOMAIN string or SQLITE_DB_FILENAME.""" pkg_dir = Path(const.__file__).parent diff --git a/tests/test_dao_children.py b/tests/test_dao_children.py index e3d78b0..893b855 100644 --- a/tests/test_dao_children.py +++ b/tests/test_dao_children.py @@ -227,24 +227,34 @@ def test_reorder_is_transactional_and_idempotent(tmp_path) -> None: _run(database.close()) -def test_reorder_empty_list_is_noop(tmp_path) -> None: +def test_reorder_empty_list_is_rejected(tmp_path) -> None: + """An empty list is the maximally partial reorder: rejected while + any child exists (Feature 03 reorder contract).""" + import pytest as pytest_module + database = _open_db(tmp_path / "reorder-empty.db") try: dao, _ = _daos(database) _make_child(dao, "Ada") - _run(dao.reorder([])) + with pytest_module.raises(ValueError, match="missing child ids"): + _run(dao.reorder([])) children = _run(dao.list_all()) assert children[0].sort_order == 0 finally: _run(database.close()) -def test_reorder_unknown_id_leaves_it_untouched(tmp_path) -> None: +def test_reorder_unknown_id_is_rejected(tmp_path) -> None: + """An unknown id is rejected before any write; the order is + untouched (Feature 03 reorder contract).""" + import pytest as pytest_module + database = _open_db(tmp_path / "reorder-unknown.db") try: dao, _ = _daos(database) a = _make_child(dao, "Ada") - _run(dao.reorder([a.id, 999])) + with pytest_module.raises(ValueError, match="unknown child ids"): + _run(dao.reorder([a.id, 999])) children = _run(dao.list_all()) assert children[0].sort_order == 0 finally: diff --git a/tests/test_dao_instances.py b/tests/test_dao_instances.py index f0dad52..5894b38 100644 --- a/tests/test_dao_instances.py +++ b/tests/test_dao_instances.py @@ -1,4 +1,4 @@ -"""Tests for dao_instances.py: task_instances + append-only completion_events.""" +"""Tests for dao_instances.py: quest_instances + append-only completion_events.""" from __future__ import annotations import asyncio @@ -11,10 +11,10 @@ from custom_components.nestquest.dao_children import ChildrenDao from custom_components.nestquest.dao_instances import ( CompletionEventRecord, CompletionEventsDao, - TaskInstanceRecord, - TaskInstancesDao, + QuestInstanceRecord, + QuestInstancesDao, ) -from custom_components.nestquest.dao_rules import ScheduleRulesDao, TaskDefinitionsDao +from custom_components.nestquest.dao_rules import ScheduleRulesDao, QuestDefinitionsDao from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.migrations import apply_migrations @@ -76,13 +76,13 @@ async def _prepare(path) -> tuple: await apply_migrations(database) children = ChildrenDao(database) rules = ScheduleRulesDao(database) - definitions = TaskDefinitionsDao(database) - instances = TaskInstancesDao(database) + definitions = QuestDefinitionsDao(database) + instances = QuestInstancesDao(database) events = CompletionEventsDao(database) child = await children.create("Ada", _now_stamp()) rule = await rules.create("daily", D1) definition = await definitions.create( - "Brush teeth", child.id, rule.id, _now_stamp() + "Brush teeth", rule.id, _now_stamp(), assignee_child_ids=[child.id] ) return ( database, @@ -111,7 +111,7 @@ def _with_db(tmp_path, name): # --------------------------------------------------------------------------- -# task_instances: upsert idempotency +# quest_instances: upsert idempotency # --------------------------------------------------------------------------- @@ -119,15 +119,13 @@ def test_upsert_creates_then_is_idempotent(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): first = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) - assert isinstance(first, TaskInstanceRecord) + definition.id, child.id, D1, _now_stamp(), window="morning") + assert isinstance(first, QuestInstanceRecord) 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 rows = await database.fetch_one( - "SELECT COUNT(*) FROM task_instances" + "SELECT COUNT(*) FROM quest_instances" ) assert rows == (1,) return second @@ -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: async def _body(database, children, rules, definitions, instances, events, child, definition): - a = await instances.upsert(definition.id, child.id, D1, _now_stamp()) - b = await instances.upsert(definition.id, child.id, D2, _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(), window="morning") assert a.id != b.id return (a, b) _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: async def _body(database, children, rules, definitions, instances, events, child, definition): 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( definition.id, child.id, D1, f"{D1}T13:00:00+00:00", - due_time="08:30", - ) + due_time="08:30", window="morning") assert second.id == first.id assert second.due_time == "08:30" 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): other = await children.create("Bo", _now_stamp()) 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 _with_db(tmp_path, "upsert-wrong-child.db")(_body) @@ -182,20 +269,18 @@ def test_upsert_rejects_unknown_definition_and_child(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): with pytest.raises(ValueError, match="definition 999"): - await instances.upsert(999, child.id, D1, _now_stamp()) + await instances.upsert(999, child.id, D1, _now_stamp(), window="morning") # Unknown child: the definition-assignee check fires first and - # is the one that matters (an instance must carry its - # definition's assignee). To reach the child-existence check - # at all, the definition would have to claim child 999 — an - # FK-impossible state — so the child check only guards direct - # callers with a matching fake assignee; assert the guard - # order instead of forcing the FK-impossible state. + # is the one that matters (an instance must carry an assigned + # child). Under D-008 the assignee check is a membership test + # against quest_definition_assignees, so child 999 simply is + # not an assignee — the error names the pair. rule = await rules.create("daily", D1) other_definition = await definitions.create( - "X", child.id, rule.id, _now_stamp() + "X", rule.id, _now_stamp(), assignee_child_ids=[child.id] ) - with pytest.raises(ValueError, match="not 999"): - await instances.upsert(other_definition.id, 999, D1, _now_stamp()) + with pytest.raises(ValueError, match="not assigned to child 999"): + await instances.upsert(other_definition.id, 999, D1, _now_stamp(), window="morning") return None _with_db(tmp_path, "upsert-unknown.db")(_body) @@ -206,7 +291,7 @@ def test_upsert_malformed_due_date_raises(tmp_path) -> None: events, child, definition): for bad in ("not-a-date", "2026-9-15", ""): with pytest.raises(ValueError, match="YYYY-MM-DD"): - await instances.upsert(definition.id, child.id, bad, _now_stamp()) + await instances.upsert(definition.id, child.id, bad, _now_stamp(), window="morning") return None _with_db(tmp_path, "upsert-bad-date.db")(_body) @@ -218,11 +303,11 @@ def test_upsert_rejects_past_due_dates(tmp_path) -> None: events, child, definition): yesterday = _future_day(-1) 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 # instances before the day is over). 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 return record @@ -298,7 +383,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date( await asyncio.wait_for(holder_inside.wait(), timeout=5) 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 # its in-lock check while B holds the lock). Shift the @@ -323,13 +408,16 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date( await apply_migrations(database) children = ChildrenDao(database) rules = ScheduleRulesDao(database) - definitions = TaskDefinitionsDao(database) - instances = TaskInstancesDao(database) + definitions = QuestDefinitionsDao(database) + instances = QuestInstancesDao(database) events = CompletionEventsDao(database) child = await children.create("Ada", _now_stamp()) rule = await rules.create("daily", D1) definition = await definitions.create( - "Brush teeth", child.id, rule.id, _now_stamp() + "Brush teeth", + rule.id, + _now_stamp(), + assignee_child_ids=[child.id], ) return await _body( database, children, rules, definitions, instances, @@ -348,17 +436,15 @@ def test_upsert_refuses_immutable_completed_instance(tmp_path) -> None: events, child, definition): generated_at = _now_stamp() 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( instance.id, child.id, "completed", "user", _now_stamp(), True, actor_user_id="user-1", ) with pytest.raises(ValueError, match="immutable"): await instances.upsert( - definition.id, child.id, D1, _now_stamp(), due_time="09:00" - ) - fetched = await instances.get(definition.id, D1) + definition.id, child.id, D1, _now_stamp(), due_time="09:00", window="morning") + fetched = await instances.get(definition.id, child.id, D1, "morning") assert fetched.due_time == "08:00", ( "failed regeneration must not touch the completed instance" ) @@ -369,7 +455,7 @@ def test_upsert_refuses_immutable_completed_instance(tmp_path) -> None: # --------------------------------------------------------------------------- -# task_instances: get / list +# quest_instances: get / list # --------------------------------------------------------------------------- @@ -377,12 +463,11 @@ def test_get_by_definition_and_date_round_trip(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): created = await instances.upsert( - definition.id, child.id, D1, _now_stamp(), due_time="17:30" - ) - fetched = await instances.get(definition.id, D1) + definition.id, child.id, D1, _now_stamp(), due_time="17:30", window="morning") + fetched = await instances.get(definition.id, child.id, D1, "morning") assert fetched == created 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 _with_db(tmp_path, "get-round-trip.db")(_body) @@ -391,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: async def _body(database, children, rules, definitions, instances, 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) assert fetched == created assert await instances.get_by_id(999) is None @@ -406,13 +491,12 @@ def test_list_by_child_and_date_filters(tmp_path) -> None: other = await children.create("Bo", _now_stamp()) rule = await rules.create("daily", D1) other_definition = await definitions.create( - "Make bed", other.id, rule.id, _now_stamp() + "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, D2, _now_stamp()) + await instances.upsert(definition.id, child.id, D1, _now_stamp(), window="morning") + await instances.upsert(definition.id, child.id, D2, _now_stamp(), window="morning") 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) assert len(mine) == 1 assert mine[0].child_id == child.id @@ -426,7 +510,7 @@ def test_list_by_date_range_filters_and_orders(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): for day in (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( child.id, D2, D6 ) @@ -453,25 +537,24 @@ def test_list_by_date_range_rejects_inverted_range(tmp_path) -> None: # --------------------------------------------------------------------------- -# task_instances: delete_future_uncompleted +# quest_instances: delete_future_uncompleted # --------------------------------------------------------------------------- def test_delete_future_uncompleted_removes_only_eligible(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): - await instances.upsert(definition.id, child.id, D1, _now_stamp()) + await instances.upsert(definition.id, child.id, D1, _now_stamp(), window="morning") future_open = await instances.upsert( - definition.id, child.id, D5, _now_stamp() - ) - await instances.upsert(definition.id, child.id, D6, _now_stamp()) + definition.id, child.id, D5, _now_stamp(), window="morning") + await instances.upsert(definition.id, child.id, D6, _now_stamp(), window="morning") deleted = await instances.delete_future_uncompleted( definition.id, D5 ) assert deleted == 2 - assert await instances.get(definition.id, D1) is not None - assert await instances.get(definition.id, D5) is None - assert await instances.get(definition.id, D6) is None + assert await instances.get(definition.id, child.id, D1, "morning") is not None + assert await instances.get(definition.id, child.id, D5, "morning") is None + assert await instances.get(definition.id, child.id, D6, "morning") is None return deleted _with_db(tmp_path, "delete-future.db")(_body) @@ -481,11 +564,9 @@ def test_delete_future_uncompleted_spares_completed(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): completed = await instances.upsert( - definition.id, child.id, D5, _now_stamp() - ) + definition.id, child.id, D5, _now_stamp(), window="morning") open_one = await instances.upsert( - definition.id, child.id, D6, _now_stamp() - ) + definition.id, child.id, D6, _now_stamp(), window="morning") await events.append( completed.id, child.id, "completed", "user", _now_stamp(), True, actor_user_id="user-1", @@ -520,12 +601,12 @@ def test_delete_future_uncompleted_cannot_backdate_cutoff( async def _body(database, children, rules, definitions, instances, events, child, definition): 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( definition.id, "2020-01-01" ) 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 _with_db(tmp_path, "delete-no-backdate.db")(_body) @@ -537,16 +618,16 @@ def test_delete_future_uncompleted_other_definitions_untouched( async def _body(database, children, rules, definitions, instances, events, child, definition): other = await definitions.create( - "Make bed", child.id, (await rules.create("daily", D1)).id, - _now_stamp(), + "Make bed", (await rules.create("daily", D1)).id, + _now_stamp(), assignee_child_ids=[child.id], ) - await instances.upsert(definition.id, child.id, D20, _now_stamp()) - await instances.upsert(other.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(), window="morning") deleted = await instances.delete_future_uncompleted( definition.id, D20 ) 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 _with_db(tmp_path, "delete-scoped.db")(_body) @@ -561,8 +642,7 @@ def test_append_completion_round_trips(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") event = await events.append( instance.id, child.id, "completed", "user", _now_stamp(), True, actor_user_id="user-1", @@ -581,10 +661,10 @@ def test_append_panel_event_round_trips(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") event = await events.append( instance.id, child.id, "completed", "panel", _now_stamp(), False, + actor_child_id=child.id, actor_user_id=None, ) assert event.actor_source == "panel" @@ -599,8 +679,7 @@ def test_append_uncompleted_with_optional_on_time(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") reversal = await events.append( instance.id, child.id, "uncompleted", "user", _now_stamp(), None, actor_user_id="user-1", @@ -621,8 +700,7 @@ def test_append_validation_rejects_bad_shapes(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") with pytest.raises(ValueError, match="event_type"): await events.append( instance.id, child.id, "missed", "user", _now_stamp(), True, @@ -641,17 +719,31 @@ def test_append_validation_rejects_bad_shapes(tmp_path) -> None: with pytest.raises(ValueError, match="must not carry"): await events.append( instance.id, child.id, "completed", "panel", _now_stamp(), True, + actor_child_id=child.id, 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"): 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 # would silently coerce (0.5 -> False) is rejected outright. with pytest.raises(ValueError, match="must be True, False or None"): 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"): await events.append( @@ -669,8 +761,7 @@ def test_append_rejects_non_utc_timestamps(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") for bad in ( "2026-09-14 12:00:00+00:00", # space separator "2026-09-14T12:00:00", # no offset @@ -682,7 +773,7 @@ def test_append_rejects_non_utc_timestamps(tmp_path) -> None: with pytest.raises(ValueError, match="UTC"): await events.append( instance.id, child.id, "completed", "panel", bad, - True, + True, actor_child_id=child.id, ) return None @@ -694,8 +785,7 @@ def test_append_requires_was_on_time_strict_bool(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): 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): with pytest.raises(ValueError, match="must be True, False"): await events.append( @@ -716,8 +806,7 @@ def test_append_rejects_unknown_instance_or_child(tmp_path) -> None: actor_user_id="user-1", ) 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"): await events.append( instance.id, 999, "completed", "user", _now_stamp(), True, @@ -739,17 +828,18 @@ def test_completed_uncompleted_recompleted_three_ordered_events( async def _body(database, children, rules, definitions, instances, events, child, definition): instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") 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( instance.id, child.id, "uncompleted", "user", _now_stamp(), True, actor_user_id="user-1", ) 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) assert [e.event_type for e in history] == [ @@ -770,8 +860,7 @@ def test_get_latest_for_instance_none_when_untouched(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") return await events.get_latest_for_instance(instance.id) assert _with_db(tmp_path, "latest-none.db")(_body) is None @@ -781,10 +870,10 @@ def test_get_latest_returns_uncompleted_when_reversed(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") 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( instance.id, child.id, "uncompleted", "user", _now_stamp(), True, @@ -801,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, events, child, definition): early = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") inside = await instances.upsert( - definition.id, child.id, D2, _now_stamp() - ) + definition.id, child.id, D2, _now_stamp(), window="morning") 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( - early.id, child.id, "completed", "panel", _now_stamp(), True - ) - await events.append( - inside.id, child.id, "completed", "panel", _now_stamp(), False + inside.id, child.id, "completed", "panel", _now_stamp(), False, + actor_child_id=child.id ) await events.append( inside.id, child.id, "uncompleted", "user", _now_stamp(), True, actor_user_id="user-1", ) 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( child.id, D2, D2 @@ -846,8 +935,7 @@ def test_list_events_occurred_at_scope_variant(tmp_path) -> None: async def _body(database, children, rules, definitions, instances, events, child, definition): late_due = await instances.upsert( - definition.id, child.id, 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 instance is due D20. The due-date scope queried on D20 # still finds the event (scoping follows the instance's due @@ -855,7 +943,7 @@ def test_list_events_occurred_at_scope_variant(tmp_path) -> None: # queried on today's date finds it too. event = await events.append( 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( child.id, D20, D20 @@ -943,7 +1031,12 @@ def test_no_mutation_sql_for_completion_events_anywhere() -> None: # UPDATE/DELETE/non-DAO-INSERT paths hit the pattern. insert_exempt = { "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_migrations.py", # seeds frozen legacy shapes to + # exercise the rebuild migrations "tests/test_dao_instances.py", # this guard file; scanned by # its own guard's regex spans below instead } @@ -1027,8 +1120,7 @@ def test_completion_events_dao_instance_has_no_mutation_capability( database.execute = _recording instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") await events.append( instance.id, child.id, "completed", "user", _now_stamp(), True, actor_user_id="user-1", @@ -1058,8 +1150,7 @@ def test_append_concurrent_events_serialize(tmp_path) -> None: prepared ) instance = await instances.upsert( - definition.id, child.id, D1, _now_stamp() - ) + definition.id, child.id, D1, _now_stamp(), window="morning") tasks = [ asyncio.ensure_future( events.append( @@ -1084,13 +1175,13 @@ def test_append_concurrent_events_serialize(tmp_path) -> None: async def _prepare_in(database): children = ChildrenDao(database) rules = ScheduleRulesDao(database) - definitions = TaskDefinitionsDao(database) - instances = TaskInstancesDao(database) + definitions = QuestDefinitionsDao(database) + instances = QuestInstancesDao(database) events = CompletionEventsDao(database) child = await children.create("Ada", _now_stamp()) rule = await rules.create("daily", D1) definition = await definitions.create( - "Brush teeth", child.id, rule.id, _now_stamp() + "Brush teeth", rule.id, _now_stamp(), assignee_child_ids=[child.id] ) return (database, children, rules, definitions, instances, events, child, definition) diff --git a/tests/test_dao_matrix.py b/tests/test_dao_matrix.py index c68b7a1..a2ab3ba 100644 --- a/tests/test_dao_matrix.py +++ b/tests/test_dao_matrix.py @@ -27,7 +27,7 @@ from custom_components.nestquest.dao_children import ( ) from custom_components.nestquest.dao_instances import ( CompletionEventsDao, - TaskInstancesDao, + QuestInstancesDao, ) from custom_components.nestquest.dao_presence import ( PresenceOverridesDao, @@ -35,7 +35,7 @@ from custom_components.nestquest.dao_presence import ( ) from custom_components.nestquest.dao_rules import ( ScheduleRulesDao, - TaskDefinitionsDao, + QuestDefinitionsDao, ) from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.migrations import apply_migrations @@ -70,10 +70,11 @@ ALL_TABLES = ( "children", "admin_users", "schedule_rules", - "task_definitions", + "quest_definitions", + "quest_definition_assignees", "presence_schedules", "presence_overrides", - "task_instances", + "quest_instances", "completion_events", ) @@ -86,10 +87,10 @@ class World: self.children = ChildrenDao(database) self.admins = AdminUsersDao(database) self.rules = ScheduleRulesDao(database) - self.definitions = TaskDefinitionsDao(database) + self.definitions = QuestDefinitionsDao(database) self.schedules = PresenceSchedulesDao(database) self.overrides = PresenceOverridesDao(database) - self.instances = TaskInstancesDao(database) + self.instances = QuestInstancesDao(database) self.events = CompletionEventsDao(database) async def seed(self) -> None: @@ -99,7 +100,10 @@ class World: assert await self.admins.add("user-1", _stamp()) is True self.rule = await self.rules.create("daily", D1) self.definition = await self.definitions.create( - "Brush teeth", self.child.id, self.rule.id, _stamp() + "Brush teeth", + self.rule.id, + _stamp(), + assignee_child_ids=[self.child.id], ) self.schedule = await self.schedules.upsert_by_child( self.child.id, 2, D1, "0,2,4|1,3" @@ -108,7 +112,8 @@ class World: self.child.id, D1, D2, True, note="test" ) self.instance = await self.instances.upsert( - self.definition.id, self.child.id, D1, _stamp() + self.definition.id, self.child.id, D1, _stamp(), + window="morning", ) @@ -201,11 +206,14 @@ def test_matrix_schedule_rules_crud_and_constraints(tmp_path) -> None: assert _with_world(tmp_path / "m-rules.db")(_body) is True -def test_matrix_task_definitions_crud_and_constraints(tmp_path) -> None: +def test_matrix_quest_definitions_crud_and_constraints(tmp_path) -> None: async def _body(w: World): # insert (seeded) + read fetched = await w.definitions.get(w.definition.id) assert fetched.title == "Brush teeth" + assert [c.id for c in await w.definitions.list_assignees( + w.definition.id + )] == [w.child.id] # update await w.definitions.update(w.definition.id, title="Brush TEETH") assert (await w.definitions.get(w.definition.id)).title == ( @@ -213,11 +221,21 @@ def test_matrix_task_definitions_crud_and_constraints(tmp_path) -> None: ) # constraint: FK to a nonexistent child rejected with ValueError with pytest.raises(ValueError, match="does not exist"): - await w.definitions.create("X", 999, w.rule.id, _stamp()) - # constraint: inactive child rejected + await w.definitions.create( + "X", w.rule.id, _stamp(), assignee_child_ids=[999] + ) + # constraint: inactive child rejected on assignment await w.children.set_active(w.child2.id, False) with pytest.raises(ValueError, match="inactive"): - await w.definitions.set_assignee(w.definition.id, w.child2.id) + await w.definitions.add_assignee(w.definition.id, w.child2.id) + # assignee removal + re-adding the active child + assert await w.definitions.remove_assignee( + w.definition.id, w.child.id + ) is True + assert await w.definitions.remove_assignee( + w.definition.id, w.child.id + ) is False + await w.definitions.add_assignee(w.definition.id, w.child.id) # set_active: deactivate, never delete assert await w.definitions.set_active(w.definition.id, False) == 1 assert (await w.definitions.get(w.definition.id)).is_active is False @@ -271,15 +289,18 @@ def test_matrix_presence_overrides_crud_and_constraints(tmp_path) -> None: assert _with_world(tmp_path / "m-overrides.db")(_body) is True -def test_matrix_task_instances_crud_and_constraints(tmp_path) -> None: +def test_matrix_quest_instances_crud_and_constraints(tmp_path) -> None: async def _body(w: World): # 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 # update path: conflict refresh on the OPEN instance — same row # id, snapshot columns (due_time) actually rewritten. refreshed = await w.instances.upsert( - w.definition.id, w.child.id, D1, _stamp(), due_time="17:00" + w.definition.id, w.child.id, D1, _stamp(), + window="morning", due_time="17:00", ) assert refreshed.id == fetched.id, ( "the conflict path must refresh the existing row in place" @@ -292,22 +313,22 @@ def test_matrix_task_instances_crud_and_constraints(tmp_path) -> None: assert _with_world(tmp_path / "m-instances.db")(_body) is True -def test_matrix_task_instances_constraints(tmp_path) -> None: +def test_matrix_quest_instances_constraints(tmp_path) -> None: async def _body(w: World): # D20 has no instance yet: a raw insert succeeds, then an # identical second insert must fail on the UNIQUE constraint — # proving the schema backs the DAO's idempotency. await w.database.execute( - "INSERT INTO task_instances (definition_id, child_id, " - "due_date, due_time, generated_at) " - "VALUES (?, ?, ?, NULL, ?)", + "INSERT INTO quest_instances (definition_id, child_id, " + "window, due_date, due_time, generated_at) " + "VALUES (?, ?, 'morning', ?, NULL, ?)", (w.definition.id, w.child.id, D20, _stamp()), ) with pytest.raises(sqlite3.IntegrityError): await w.database.execute( - "INSERT INTO task_instances (definition_id, child_id, " - "due_date, due_time, generated_at) " - "VALUES (?, ?, ?, NULL, ?)", + "INSERT INTO quest_instances (definition_id, child_id, " + "window, due_date, due_time, generated_at) " + "VALUES (?, ?, 'morning', ?, NULL, ?)", (w.definition.id, w.child.id, D20, _stamp()), ) # delete future uncompleted removes D20's open instance @@ -320,17 +341,18 @@ def test_matrix_task_instances_constraints(tmp_path) -> None: assert _with_world(tmp_path / "m-instances-constraints.db")(_body) is True -def test_matrix_task_instances_upsert_idempotency(tmp_path) -> None: +def test_matrix_quest_instances_upsert_idempotency(tmp_path) -> None: async def _body(w: World): 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( - 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 count = await w.database.fetch_one( - "SELECT COUNT(*) FROM task_instances" + "SELECT COUNT(*) FROM quest_instances" ) # Seed created one (D1) plus this one (D2) — no duplicate. assert count == (2,) @@ -350,14 +372,16 @@ def test_matrix_completion_events_append_read_and_no_mutation( async def _body(w: World): # insert: append a completion then a reversal, then re-complete await w.events.append( - w.instance.id, w.child.id, "completed", "panel", _stamp(), True + w.instance.id, w.child.id, "completed", "panel", _stamp(), True, + actor_child_id=w.child.id, ) await w.events.append( w.instance.id, w.child.id, "uncompleted", "user", _stamp(), None, actor_user_id="user-1", ) await w.events.append( - w.instance.id, w.child.id, "completed", "panel", _stamp(), False + w.instance.id, w.child.id, "completed", "panel", _stamp(), False, + actor_child_id=w.child.id, ) # read: full ordered history + latest history = await w.events.list_by_instance(w.instance.id) diff --git a/tests/test_dao_presence.py b/tests/test_dao_presence.py index c4d183e..0d86cb8 100644 --- a/tests/test_dao_presence.py +++ b/tests/test_dao_presence.py @@ -619,4 +619,173 @@ def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None: ) assert sql_pattern.search(remaining) is None, ( "tests must go through the DAO, not raw SQL, for these tables" - ) \ No newline at end of file + ) + +# --------------------------------------------------------------------------- +# Presence overrides: overlap rejection naming the conflict +# --------------------------------------------------------------------------- + + +def test_override_create_round_trip_single_and_range(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + single = await overrides.create( + child.id, "2026-07-20", "2026-07-20", False + ) + assert (single.start_date, single.end_date) == ("2026-07-20",) * 2 + span = await overrides.create( + child.id, "2026-08-01", "2026-08-05", True, note="traded week" + ) + assert span.is_present is True + assert span.note == "traded week" + # Distinct, non-overlapping ranges coexist. + listed = await overrides.list_by_child_and_range( + child.id, "2026-01-01", "2026-12-31" + ) + assert [row.id for row in listed] == [single.id, span.id] + return listed + + _with_db(tmp_path, "override-round-trip.db")(_body) + + +def test_override_overlap_rejected_and_names_the_conflict(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + existing = await overrides.create( + child.id, "2026-07-20", "2026-07-24", False, note="holiday" + ) + for start, end in ( + ("2026-07-24", "2026-07-26"), # touches the existing end + ("2026-07-18", "2026-07-20"), # touches the existing start + ("2026-07-21", "2026-07-22"), # fully inside + ("2026-07-01", "2026-07-30"), # fully covering + ): + with pytest.raises(ValueError, match="overlaps existing"): + await overrides.create( + child.id, start, end, True, note="swap" + ) + # The error NAMES the conflicting override (id + its range). + with pytest.raises( + ValueError, + match=( + rf"override {existing.id} " + rf"\[2026-07-20, 2026-07-24\].*is_present=False" + ), + ): + await overrides.create(child.id, "2026-07-22", "2026-07-22", True) + # Nothing was written by any rejected attempt. + listed = await overrides.list_by_child_and_range( + child.id, "2026-01-01", "2026-12-31" + ) + assert [row.id for row in listed] == [existing.id] + return existing + + _with_db(tmp_path, "override-overlap.db")(_body) + + +def test_override_overlap_is_per_child(tmp_path) -> None: + """Different children's overrides may cover the same dates: the + conflict rule is per child.""" + async def _body(database, schedules, overrides, children, child): + other = await children.create("Bo", NOW) + await overrides.create(child.id, "2026-07-20", "2026-07-24", False) + twin = await overrides.create( + other.id, "2026-07-20", "2026-07-24", True + ) + assert twin.child_id == other.id + listed = await overrides.list_by_child_and_range( + other.id, "2026-01-01", "2026-12-31" + ) + assert [row.id for row in listed] == [twin.id] + return twin + + _with_db(tmp_path, "override-per-child.db")(_body) + + +def test_override_remove_then_recreate_overlapping(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + first = await overrides.create( + child.id, "2026-07-20", "2026-07-24", False + ) + assert await overrides.delete(first.id) is True + assert await overrides.delete(first.id) is False + replacement = await overrides.create( + child.id, "2026-07-20", "2026-07-24", True, note="swap back" + ) + assert replacement.is_present is True + return replacement + + _with_db(tmp_path, "override-recreate.db")(_body) + + +def test_override_create_racing_conflicts_serialize(tmp_path) -> None: + """Two racing creates for the same child and dates: exactly one + lands, the other is rejected with the overlap error. DETERMINISTIC + gate: task A is paused at its INSERT — inside its open transaction, + after its overlap probe — so task B is created while A provably + holds the connection lock and must queue; B then re-runs the probe + against A's committed row and is rejected.""" + import asyncio as asyncio_module + from unittest.mock import MagicMock + + async def _main(): + from custom_components.nestquest.dao_children import ChildrenDao + from custom_components.nestquest.dao_presence import ( + PresenceOverridesDao, + ) + from custom_components.nestquest.db import NestQuestDatabase + from custom_components.nestquest.migrations import apply_migrations + + armed = {"active": False, "gated": False} + gate_open = asyncio_module.Event() + a_started = asyncio_module.Event() + + hass = MagicMock() + + async def _gated_executor(fn, *args): + if armed["active"]: + # Gate the FIRST _execute after arming: task A's INSERT, + # which runs inside its open transaction (BEGIN already + # executed) after the overlap probe. + 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 + database = NestQuestDatabase(hass) + await database.open(tmp_path / "override-race.db") + try: + await apply_migrations(database) + children = ChildrenDao(database) + overrides = PresenceOverridesDao(database) + child = await children.create("Ada", NOW) + + armed["active"] = True + task_a = asyncio_module.ensure_future( + overrides.create(child.id, "2026-07-20", "2026-07-24", True) + ) + await a_started.wait() + armed["active"] = False + task_b = asyncio_module.ensure_future( + overrides.create(child.id, "2026-07-22", "2026-07-26", False) + ) + await asyncio_module.sleep(0) + gate_open.set() + record_a, error_b = await asyncio_module.gather( + task_a, task_b, return_exceptions=True + ) + assert isinstance(record_a, object) and not isinstance( + record_a, BaseException + ), f"A must land: {record_a!r}" + assert isinstance(error_b, ValueError), ( + f"B must be rejected with the overlap error: {error_b!r}" + ) + assert "overlaps existing" in str(error_b) + listed = await overrides.list_by_child_and_range( + child.id, "2026-01-01", "2026-12-31" + ) + assert [row.id for row in listed] == [record_a.id] + finally: + await database.close() + + asyncio.new_event_loop().run_until_complete(_main()) diff --git a/tests/test_dao_rules.py b/tests/test_dao_rules.py index a27966e..8784e73 100644 --- a/tests/test_dao_rules.py +++ b/tests/test_dao_rules.py @@ -1,4 +1,4 @@ -"""Tests for dao_rules.py: typed DAO for schedule_rules and task_definitions.""" +"""Tests for dao_rules.py: typed DAO for schedule_rules and quest_definitions.""" from __future__ import annotations import asyncio @@ -10,8 +10,9 @@ from custom_components.nestquest.dao_children import ChildrenDao from custom_components.nestquest.dao_rules import ( ScheduleRuleRecord, ScheduleRulesDao, - TaskDefinitionRecord, - TaskDefinitionsDao, + QuestDefinitionRecord, + QuestDefinitionWindowRecord, + QuestDefinitionsDao, ) from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.migrations import apply_migrations @@ -37,7 +38,7 @@ async def _prepare(path) -> tuple: await database.open(path) await apply_migrations(database) rules = ScheduleRulesDao(database) - definitions = TaskDefinitionsDao(database) + definitions = QuestDefinitionsDao(database) children = ChildrenDao(database) child = await children.create("Ada", NOW) return database, rules, definitions, children, child @@ -266,7 +267,7 @@ def test_rule_delete_unreferenced_succeeds(tmp_path) -> None: def test_rule_delete_referenced_is_rejected(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") - await definitions.create("Brush teeth", child.id, rule.id, NOW) + await definitions.create("Brush teeth", rule.id, NOW) assert await rules.delete_if_unreferenced(rule.id) is False fetched = await rules.get(rule.id) assert fetched is not None, ( @@ -288,7 +289,7 @@ def test_rule_delete_after_last_reference_removed_succeeds(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") definition = await definitions.create( - "Brush teeth", child.id, rule.id, NOW + "Brush teeth", rule.id, NOW ) assert await rules.delete_if_unreferenced(rule.id) is False await definitions.set_active(definition.id, False) @@ -322,7 +323,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( await apply_migrations(database) children = ChildrenDao(database) rules = ScheduleRulesDao(database) - definitions = TaskDefinitionsDao(database) + definitions = QuestDefinitionsDao(database) child = await children.create("Ada", NOW) rule_a = await rules.create("daily", "2026-09-14") @@ -335,12 +336,12 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( import custom_components.nestquest.dao_rules as dao_rules original_validate = ( - dao_rules.TaskDefinitionsDao._validate_assignable + dao_rules.QuestDefinitionsDao._validate_rule_exists ) release = asyncio.Event() started = asyncio.Event() - async def _pausing_validate(self, child_id, rule_id): + async def _pausing_validate(self, rule_id): # Run the real validation FIRST (this is the # time-of-check), THEN pause: the delete task gets # scheduled while the create sits between its @@ -348,19 +349,17 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( # transaction must keep the delete queued until the # insert commits, so the create must win the race # and the delete must be rejected as referenced. - await original_validate(self, child_id, rule_id) + await original_validate(self, rule_id) if rule_id == rule_a.id and not started.is_set(): started.set() await release.wait() - dao_rules.TaskDefinitionsDao._validate_assignable = ( + dao_rules.QuestDefinitionsDao._validate_rule_exists = ( _pausing_validate ) try: create_task = asyncio.ensure_future( - definitions.create( - "Brush teeth", child.id, rule_a.id, NOW - ) + definitions.create("Brush teeth", rule_a.id, NOW) ) await started.wait() delete_task = asyncio.ensure_future( @@ -373,7 +372,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( delete_task, create_task, return_exceptions=True ) finally: - dao_rules.TaskDefinitionsDao._validate_assignable = ( + dao_rules.QuestDefinitionsDao._validate_assignable = ( original_validate ) else: @@ -382,7 +381,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( async def _create(): return await definitions.create( - "Brush teeth", child.id, rule_a.id, NOW + "Brush teeth", rule_a.id, NOW ) delete_task = asyncio.ensure_future(_delete()) @@ -405,7 +404,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( assert rule_row is None else: # Create won: the delete was rejected, both rows exist. - assert isinstance(creation, TaskDefinitionRecord), ( + assert isinstance(creation, QuestDefinitionRecord), ( f"create must succeed, got {creation!r}" ) assert rule_row is not None @@ -425,7 +424,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( # --------------------------------------------------------------------------- -# task_definitions: create / get +# quest_definitions: create / get # --------------------------------------------------------------------------- @@ -434,20 +433,24 @@ def test_definition_create_returns_typed_record(tmp_path) -> None: rule = await rules.create("weekly", "2026-09-14", weekday_set="0,2") definition = await definitions.create( "Brush teeth", - child.id, rule.id, NOW, description="Morning and night", icon="mdi:tooth", due_time="08:00", + assignee_child_ids=[child.id], ) - assert isinstance(definition, TaskDefinitionRecord) + assert isinstance(definition, QuestDefinitionRecord) assert definition.title == "Brush teeth" - assert definition.child_id == child.id assert definition.schedule_rule_id == rule.id assert definition.due_time == "08:00" assert definition.is_active is True assert definition.created_at == NOW + assert not hasattr(definition, "child_id"), ( + "D-008: assignment lives in quest_definition_assignees" + ) + assignees = await definitions.list_assignees(definition.id) + assert [c.id for c in assignees] == [child.id] return definition _with_db(tmp_path, "definition-create.db")(_body) @@ -460,26 +463,30 @@ def test_definition_get_missing_returns_none(tmp_path) -> None: assert _with_db(tmp_path, "definition-get-missing.db")(_body) is None -def test_definition_create_unknown_child_raises_value_error( +def test_definition_create_unknown_assignee_raises_value_error( tmp_path, ) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") with pytest.raises(ValueError, match="does not exist"): - await definitions.create("X", 999, rule.id, NOW) + await definitions.create( + "X", rule.id, NOW, assignee_child_ids=[999] + ) return None _with_db(tmp_path, "definition-bad-child.db")(_body) -def test_definition_create_inactive_child_raises_value_error( +def test_definition_create_inactive_assignee_raises_value_error( tmp_path, ) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") await children.set_active(child.id, False) with pytest.raises(ValueError, match="inactive"): - await definitions.create("X", child.id, rule.id, NOW) + await definitions.create( + "X", rule.id, NOW, assignee_child_ids=[child.id] + ) return None _with_db(tmp_path, "definition-inactive-child.db")(_body) @@ -490,14 +497,14 @@ def test_definition_create_unknown_rule_raises_value_error( ) -> None: async def _body(database, rules, definitions, children, child): with pytest.raises(ValueError, match="does not exist"): - await definitions.create("X", child.id, 999, NOW) + await definitions.create("X", 999, NOW) return None _with_db(tmp_path, "definition-bad-rule.db")(_body) # --------------------------------------------------------------------------- -# task_definitions: list by child / list active +# quest_definitions: list by child / list active # --------------------------------------------------------------------------- @@ -505,9 +512,11 @@ def test_definition_list_by_child_filters_and_orders(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") other_child = await children.create("Bo", NOW) - await definitions.create("A", child.id, rule.id, NOW) - await definitions.create("B", child.id, rule.id, NOW) - await definitions.create("C", other_child.id, rule.id, NOW) + await definitions.create("A", rule.id, NOW, assignee_child_ids=[child.id]) + await definitions.create("B", rule.id, NOW, assignee_child_ids=[child.id]) + await definitions.create( + "C", rule.id, NOW, assignee_child_ids=[other_child.id] + ) mine = await definitions.list_by_child(child.id) assert [d.title for d in mine] == ["B", "A"] return mine @@ -527,8 +536,8 @@ def test_definition_list_by_child_missing_child_returns_empty( def test_definition_list_active_excludes_inactive(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") - first = await definitions.create("A", child.id, rule.id, NOW) - await definitions.create("B", child.id, rule.id, NOW) + first = await definitions.create("A", rule.id, NOW) + await definitions.create("B", rule.id, NOW) await definitions.set_active(first.id, False) active = await definitions.list_active() assert [d.title for d in active] == ["B"] @@ -539,7 +548,7 @@ def test_definition_list_active_excludes_inactive(tmp_path) -> None: # --------------------------------------------------------------------------- -# task_definitions: update / set_active / set_assignee +# quest_definitions: update / set_active / set_assignee # --------------------------------------------------------------------------- @@ -547,7 +556,7 @@ def test_definition_update_fields_round_trip(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") definition = await definitions.create( - "A", child.id, rule.id, NOW, due_time="08:00" + "A", rule.id, NOW, due_time="08:00" ) assert await definitions.update( definition.id, @@ -572,9 +581,11 @@ def test_definition_update_cannot_change_assignee_or_active( async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") other = await children.create("Bo", NOW) - definition = await definitions.create("A", child.id, rule.id, NOW) + definition = await definitions.create( + "A", rule.id, NOW, assignee_child_ids=[child.id] + ) # title= / child_id= / is_active= are NOT update() parameters; - # this asserts the API shape: reassignment and activation have + # this asserts the API shape: assignment and activation have # their own explicit methods. import inspect @@ -590,7 +601,10 @@ def test_definition_update_cannot_change_assignee_or_active( definition.id, title="A2" ) == 1 fetched = await definitions.get(definition.id) - assert fetched.child_id == child.id + assert [c.id for c in await definitions.list_assignees(definition.id)] == [ + child.id + ] + assert other.id not in [c.id for c in await definitions.list_assignees(definition.id)] assert fetched.is_active is True return fetched @@ -600,7 +614,7 @@ def test_definition_update_cannot_change_assignee_or_active( def test_definition_update_no_fields_returns_zero(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") - definition = await definitions.create("A", child.id, rule.id, NOW) + definition = await definitions.create("A", rule.id, NOW) return await definitions.update(definition.id) assert _with_db(tmp_path, "definition-update-empty.db")(_body) == 0 @@ -612,7 +626,6 @@ def test_definition_update_can_clear_optional_fields(tmp_path) -> None: rule = await rules.create("daily", "2026-09-14") definition = await definitions.create( "A", - child.id, rule.id, NOW, description="d", @@ -646,7 +659,7 @@ def test_definition_update_missing_returns_zero(tmp_path) -> None: def test_definition_set_active_round_trip(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") - definition = await definitions.create("A", child.id, rule.id, NOW) + definition = await definitions.create("A", rule.id, NOW) assert await definitions.set_active(definition.id, False) == 1 fetched = await definitions.get(definition.id) assert fetched.is_active is False @@ -673,7 +686,7 @@ def test_definition_no_delete_method_exists(tmp_path) -> None: methods = { name for name, _ in inspect.getmembers( - TaskDefinitionsDao, inspect.isfunction + QuestDefinitionsDao, inspect.isfunction ) } assert "delete" not in methods @@ -683,78 +696,244 @@ def test_definition_no_delete_method_exists(tmp_path) -> None: _with_db(tmp_path, "definition-no-delete.db")(_body) -def test_definition_set_assignee_round_trip(tmp_path) -> None: +def test_definition_add_assignee_round_trip(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") other = await children.create("Bo", NOW) - definition = await definitions.create("A", child.id, rule.id, NOW) - assert await definitions.set_assignee(definition.id, other.id) == 1 - fetched = await definitions.get(definition.id) - assert fetched.child_id == other.id - return fetched + definition = await definitions.create( + "A", rule.id, NOW, assignee_child_ids=[child.id] + ) + await definitions.add_assignee(definition.id, other.id) + assignees = await definitions.list_assignees(definition.id) + assert sorted(c.id for c in assignees) == sorted([child.id, other.id]) + # Idempotent: re-adding an existing assignee stays a no-op. + await definitions.add_assignee(definition.id, other.id) + assignees = await definitions.list_assignees(definition.id) + assert sorted(c.id for c in assignees) == sorted([child.id, other.id]) + return assignees - _with_db(tmp_path, "definition-set-assignee.db")(_body) + _with_db(tmp_path, "definition-add-assignee.db")(_body) -def test_definition_set_assignee_inactive_child_raises(tmp_path) -> None: +def test_definition_add_assignee_inactive_child_raises(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") other = await children.create("Bo", NOW) - definition = await definitions.create("A", child.id, rule.id, NOW) + definition = await definitions.create( + "A", rule.id, NOW, assignee_child_ids=[child.id] + ) await children.set_active(other.id, False) with pytest.raises(ValueError, match="inactive"): - await definitions.set_assignee(definition.id, other.id) - fetched = await definitions.get(definition.id) - assert fetched.child_id == child.id, ( - "failed reassignment must leave the current assignee intact" + await definitions.add_assignee(definition.id, other.id) + assignees = await definitions.list_assignees(definition.id) + assert [c.id for c in assignees] == [child.id], ( + "failed assignment must leave the current assignees intact" ) - return fetched + return assignees _with_db(tmp_path, "definition-assignee-inactive.db")(_body) -def test_definition_set_assignee_unknown_child_raises(tmp_path) -> None: +def test_definition_add_assignee_unknown_child_raises(tmp_path) -> None: async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") - definition = await definitions.create("A", child.id, rule.id, NOW) + definition = await definitions.create( + "A", rule.id, NOW, assignee_child_ids=[child.id] + ) with pytest.raises(ValueError, match="does not exist"): - await definitions.set_assignee(definition.id, 999) + await definitions.add_assignee(definition.id, 999) return None _with_db(tmp_path, "definition-assignee-unknown.db")(_body) -def test_definition_set_assignee_missing_definition_returns_zero( +def test_definition_add_assignee_missing_definition_raises(tmp_path) -> None: + async def _body(database, rules, definitions, children, child): + with pytest.raises(ValueError, match="does not exist"): + await definitions.add_assignee(999, child.id) + return None + + _with_db(tmp_path, "definition-assignee-missing.db")(_body) + + +def test_definition_remove_assignee_round_trip(tmp_path) -> None: + async def _body(database, rules, definitions, children, child): + rule = await rules.create("daily", "2026-09-14") + other = await children.create("Bo", NOW) + definition = await definitions.create( + "A", rule.id, NOW, assignee_child_ids=[child.id, other.id] + ) + assert await definitions.remove_assignee(definition.id, other.id) is True + assignees = await definitions.list_assignees(definition.id) + assert [c.id for c in assignees] == [child.id] + # Removing a non-assignee reports False. + assert await definitions.remove_assignee(definition.id, other.id) is False + return assignees + + _with_db(tmp_path, "definition-remove-assignee.db")(_body) + + +def test_definition_list_assignees_orders_by_child_sort_order( tmp_path, ) -> None: async def _body(database, rules, definitions, children, child): - other = await children.create("Bo", NOW) - return await definitions.set_assignee(999, other.id) + rule = await rules.create("daily", "2026-09-14") + second = await children.create("Bo", NOW) + third = await children.create("Cleo", NOW) + await children.reorder([third.id, child.id, second.id]) + definition = await definitions.create( + "A", rule.id, NOW, assignee_child_ids=[second.id, child.id, third.id] + ) + assignees = await definitions.list_assignees(definition.id) + assert [c.id for c in assignees] == [third.id, child.id, second.id] + return assignees - assert _with_db(tmp_path, "definition-assignee-missing.db")(_body) == 0 + _with_db(tmp_path, "definition-assignees-order.db")(_body) -def test_definition_set_assignee_leaves_history_rows_alone( +def test_definition_upsert_window_round_trip(tmp_path) -> None: + async def _body(database, rules, definitions, children, child): + rule = await rules.create("daily", "2026-09-14") + definition = await definitions.create("A", rule.id, NOW) + window = await definitions.upsert_window( + definition.id, "morning", due_time="09:00" + ) + assert window == QuestDefinitionWindowRecord( + definition.id, "morning", "09:00" + ) + # Upsert: re-declaring the same window updates in place, never + # duplicates (one row per definition+window). + updated = await definitions.upsert_window( + definition.id, "morning", due_time="09:30" + ) + assert updated.due_time == "09:30" + rows = await definitions.list_windows(definition.id) + assert [w.window for w in rows] == ["morning"] + # All three windows list in const order regardless of insertion. + await definitions.upsert_window(definition.id, "evening") + await definitions.upsert_window(definition.id, "afternoon") + rows = await definitions.list_windows(definition.id) + assert [w.window for w in rows] == [ + "morning", + "afternoon", + "evening", + ] + assert rows[1].due_time is None + return rows + + _with_db(tmp_path, "definition-window-roundtrip.db")(_body) + + +def test_definition_window_unknown_name_raises(tmp_path) -> None: + async def _body(database, rules, definitions, children, child): + rule = await rules.create("daily", "2026-09-14") + definition = await definitions.create("A", rule.id, NOW) + with pytest.raises(ValueError, match="window must be one of"): + await definitions.upsert_window(definition.id, "noon") + with pytest.raises(ValueError, match="window must be one of"): + await definitions.remove_window(definition.id, "MORNING") + return None + + _with_db(tmp_path, "definition-window-bad-name.db")(_body) + + +def test_definition_window_bad_due_time_raises(tmp_path) -> None: + async def _body(database, rules, definitions, children, child): + rule = await rules.create("daily", "2026-09-14") + definition = await definitions.create("A", rule.id, NOW) + with pytest.raises(ValueError, match="HH:MM"): + await definitions.upsert_window( + definition.id, "morning", due_time="9:30" + ) + with pytest.raises(ValueError, match="HH:MM"): + await definitions.upsert_window( + definition.id, "morning", due_time="25:00" + ) + return None + + _with_db(tmp_path, "definition-window-bad-time.db")(_body) + + +def test_definition_window_missing_definition_raises(tmp_path) -> None: + async def _body(database, rules, definitions, children, child): + with pytest.raises(ValueError, match="does not exist"): + await definitions.upsert_window(999, "morning") + return None + + _with_db(tmp_path, "definition-window-missing.db")(_body) + + +def test_definition_remove_window_round_trip(tmp_path) -> None: + async def _body(database, rules, definitions, children, child): + rule = await rules.create("daily", "2026-09-14") + definition = await definitions.create("A", rule.id, NOW) + await definitions.upsert_window(definition.id, "morning") + assert await definitions.remove_window( + definition.id, "morning" + ) is True + assert await definitions.list_windows(definition.id) == [] + # Removing an absent window reports False. + assert await definitions.remove_window( + definition.id, "morning" + ) is False + return None + + _with_db(tmp_path, "definition-window-remove.db")(_body) + + +def test_definition_unassigned_has_no_assignees(tmp_path) -> None: + async def _body(database, rules, definitions, children, child): + rule = await rules.create("daily", "2026-09-14") + definition = await definitions.create("A", rule.id, NOW) + assert await definitions.list_assignees(definition.id) == [] + return None + + _with_db(tmp_path, "definition-no-assignees.db")(_body) + + +def test_definition_shared_by_multiple_children_via_list_by_child( tmp_path, ) -> None: - """Reassignment writes the definition row only: any instances that - already exist keep their generation-time child_id (Feature 06 + """D-008: one definition covering several children appears for + every assignee through list_by_child, once each.""" + async def _body(database, rules, definitions, children, child): + rule = await rules.create("daily", "2026-09-14") + second = await children.create("Bo", NOW) + definition = await definitions.create( + "A", rule.id, NOW, assignee_child_ids=[child.id, second.id] + ) + for assignee in (child, second): + mine = await definitions.list_by_child(assignee.id) + assert [d.id for d in mine] == [definition.id] + return definition + + _with_db(tmp_path, "definition-shared.db")(_body) + + +def test_definition_assignment_leaves_history_rows_alone( + tmp_path, +) -> None: + """Assignment edits write the assignees table only: any instances + that already exist keep their generation-time child_id (Feature 06 guardrail 'changes future instances only'). """ async def _body(database, rules, definitions, children, child): rule = await rules.create("daily", "2026-09-14") other = await children.create("Bo", NOW) - definition = await definitions.create("A", child.id, rule.id, NOW) + definition = await definitions.create( + "A", rule.id, NOW, assignee_child_ids=[child.id] + ) # Simulate an already-generated instance (materialization owns - # this table, but its row shape proves reassignment isolation). + # this table, but its row shape proves assignment isolation). await database.execute( - "INSERT INTO task_instances (definition_id, child_id, " - "due_date, generated_at) VALUES (?, ?, ?, ?)", + "INSERT INTO quest_instances (definition_id, child_id, " + "window, due_date, generated_at) VALUES (?, ?, 'morning', ?, ?)", (definition.id, child.id, "2026-09-15", NOW), ) - await definitions.set_assignee(definition.id, other.id) + await definitions.add_assignee(definition.id, other.id) + await definitions.remove_assignee(definition.id, child.id) row = await database.fetch_one( - "SELECT child_id FROM task_instances WHERE definition_id = ?", + "SELECT child_id FROM quest_instances WHERE definition_id = ?", (definition.id,), ) assert row == (child.id,) @@ -769,7 +948,7 @@ def test_definition_set_assignee_leaves_history_rows_alone( def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None: - """Guardrail: schedule_rules/task_definitions SQL may appear only + """Guardrail: schedule_rules/quest_definitions SQL may appear only in the DAO modules (and the schema DDL declarations). RECURSIVE scan of package and tests, FROM/INTO/UPDATE/DELETE FROM/JOIN pattern, with exact repo-relative exclusions justified by role: @@ -801,7 +980,7 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None: } sql_pattern = re.compile( r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?" - r"(schedule_rules|task_definitions)\b", + r"(schedule_rules|quest_definitions|quest_definition_assignees|quest_definition_windows)\b", re.IGNORECASE, ) offenders: list[str] = [] @@ -813,16 +992,16 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None: if sql_pattern.search(py.read_text()): offenders.append(relative) assert offenders == [], ( - f"SQL touching schedule_rules/task_definitions leaked into: " + f"SQL touching schedule_rules/quest_definitions/assignees leaked: " f"{offenders}" ) def test_dao_instances_test_file_uses_dao_not_raw_rules_sql() -> None: """Compensating self-scan for the test_dao_instances.py exemption: - that file may query task_instances/completion_events raw (its own + that file may query quest_instances/completion_events raw (its own guard's scope) but must go through the DAO for schedule_rules and - task_definitions, except its single sanctioned definition-create + quest_definitions, except its single sanctioned definition-create helper usage and guard spans. """ import ast @@ -853,12 +1032,12 @@ def test_dao_instances_test_file_uses_dao_not_raw_rules_sql() -> None: ) sql_pattern = re.compile( r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|" - r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|task_definitions)\b", + r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions|quest_definition_assignees|quest_definition_windows)\b", re.IGNORECASE, ) assert sql_pattern.search(remaining) is None, ( "test_dao_instances.py must go through the DAO, not raw SQL, " - "for schedule_rules/task_definitions" + "for schedule_rules/quest_definitions" ) @@ -866,7 +1045,7 @@ def test_dao_rules_test_file_uses_dao_not_raw_table_sql() -> None: """This test module must exercise the DAO, not raw SQL, for these tables. Exceptions: the guard functions' own spans (their regexes mention the names), and the reassignment-isolation test, which - legitimately touches task_instances — a table this DAO does not + legitimately touches quest_instances — a table this DAO does not own — to prove the guardrail that reassignment leaves it alone. """ import ast @@ -898,7 +1077,7 @@ def test_dao_rules_test_file_uses_dao_not_raw_table_sql() -> None: ) sql_pattern = re.compile( r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|" - r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|task_definitions)\b", + r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions|quest_definition_assignees|quest_definition_windows)\b", re.IGNORECASE, ) assert sql_pattern.search(remaining) is None, ( diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 246f8b5..c3e0f17 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -247,12 +247,12 @@ async def test_setup_applies_all_v1_tables(hass, make_entry) -> None: rows = await database.fetch_all( "SELECT name FROM sqlite_master WHERE type = 'table' " "AND name IN ('children', 'admin_users', 'schedule_rules', " - "'task_definitions') ORDER BY name" + "'quest_definitions') ORDER BY name" ) assert [row[0] for row in rows] == [ "admin_users", "children", + "quest_definitions", "schedule_rules", - "task_definitions", ] await async_unload_entry(hass, entry) \ No newline at end of file diff --git a/tests/test_migrations.py b/tests/test_migrations.py index a96226f..7cf0624 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -17,7 +17,10 @@ from custom_components.nestquest.migrations import ( apply_migrations, read_schema_version, ) -from custom_components.nestquest.schema import SCHEMA_V1_STATEMENTS +from custom_components.nestquest.schema import ( + SCHEMA_V1_STATEMENTS, + SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, +) def _run(coro): @@ -62,10 +65,12 @@ def _counts(database) -> dict[str, int]: "children", "admin_users", "schedule_rules", - "task_definitions", + "quest_definitions", + "quest_definition_assignees", + "quest_definition_windows", "presence_schedules", "presence_overrides", - "task_instances", + "quest_instances", "completion_events", ) counts = {} @@ -76,16 +81,16 @@ def _counts(database) -> dict[str, int]: # --------------------------------------------------------------------------- -# Fresh file: version 0 -> version 1 +# Fresh file: version 0 -> latest version # --------------------------------------------------------------------------- -def test_fresh_file_migrates_to_version_1(tmp_path) -> None: +def test_fresh_file_migrates_to_latest_version(tmp_path) -> None: database = _open_db(tmp_path / "fresh.db") try: final = _migrate(database) - assert final == 1 - assert _version(database) == 1 + assert final == len(MIGRATIONS) + assert _version(database) == len(MIGRATIONS) finally: _run(database.close()) @@ -98,10 +103,12 @@ def test_fresh_file_migration_creates_all_v1_tables(tmp_path) -> None: "children", "admin_users", "schedule_rules", - "task_definitions", + "quest_definitions", + "quest_definition_assignees", + "quest_definition_windows", "presence_schedules", "presence_overrides", - "task_instances", + "quest_instances", "completion_events", VERSION_TABLE, } @@ -135,9 +142,496 @@ def test_empty_version_table_reads_as_version_0(tmp_path) -> None: _run(database.close()) -def test_migration_1_is_the_full_v1_ddl() -> None: +def test_migration_list_shape() -> None: + """Migration 1 is the v1 DDL; 2 and 3 are callable steps. + + Nothing ever shipped, so migration 1 was rewritten pre-release + (D-007) to create the quest_* tables directly; migration 2 exists + for dev databases stamped version 1 by the pre-rename runner, + migration 3 brings pre-D-008 definitions to the multi-assignee + 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 len(MIGRATIONS) == 1 + assert callable(MIGRATIONS[1]) + assert callable(MIGRATIONS[2]) + assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL + assert callable(MIGRATIONS[4]) + assert callable(MIGRATIONS[5]) + assert len(MIGRATIONS) == 6 + + +# --------------------------------------------------------------------------- +# Legacy dev database: task_* tables stamped version 1 -> quest_* +# --------------------------------------------------------------------------- + + +_LEGACY_DDL = [ + """ + CREATE TABLE IF NOT EXISTS children ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + display_name TEXT NOT NULL, + colour TEXT, + avatar_ref TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS admin_users ( + ha_user_id TEXT PRIMARY KEY NOT NULL, + added_at TEXT NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS schedule_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_type TEXT NOT NULL, + interval INTEGER NOT NULL DEFAULT 1, + weekday_set TEXT, + day_of_month INTEGER, + nth_weekday INTEGER, + month INTEGER, + start_date TEXT NOT NULL, + end_date TEXT + ) + """, + """ + CREATE TABLE IF NOT EXISTS task_definitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + icon TEXT, + child_id INTEGER NOT NULL REFERENCES children(id), + schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id), + due_time TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS presence_schedules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + child_id INTEGER NOT NULL UNIQUE REFERENCES children(id), + cycle_length_weeks INTEGER NOT NULL, + anchor_date TEXT NOT NULL, + pattern TEXT NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS presence_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + child_id INTEGER NOT NULL REFERENCES children(id), + start_date TEXT NOT NULL, + end_date TEXT NOT NULL, + is_present INTEGER NOT NULL, + note TEXT + ) + """, + """ + CREATE TABLE IF NOT EXISTS task_instances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + definition_id INTEGER NOT NULL REFERENCES task_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) + ) + """, + """ + CREATE TABLE IF NOT EXISTS completion_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instance_id INTEGER NOT NULL REFERENCES task_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_legacy_database(database) -> None: + """Create a database indistinguishable from a pre-rename dev file. + + Applies the legacy task_* DDL, seeds one row in every renamed + table, and stamps version 1 exactly the way the old runner did. + """ + for sql in _LEGACY_DDL: + _run(database.execute(sql)) + _run(database.execute(VERSION_TABLE_DDL)) + _run( + database.execute( + f"INSERT INTO {VERSION_TABLE} (id, version) VALUES (1, 1)" + ) + ) + _run( + database.execute( + "INSERT INTO children (display_name, created_at) " + "VALUES ('Ada', '2026-09-14T00:00:00+00:00')" + ) + ) + _run( + database.execute( + "INSERT INTO schedule_rules (rule_type, start_date) " + "VALUES ('daily', '2026-09-14')" + ) + ) + _run( + database.execute( + "INSERT INTO task_definitions (title, child_id, " + "schedule_rule_id, created_at) " + "VALUES ('Dishes', 1, 1, '2026-09-14T00:00:00+00:00')" + ) + ) + _run( + database.execute( + "INSERT INTO task_instances (definition_id, child_id, " + "due_date, generated_at) " + "VALUES (1, 1, '2026-09-15', '2026-09-14T00:00:00+00:00')" + ) + ) + + +def test_legacy_task_tables_are_renamed_and_keep_their_rows(tmp_path) -> None: + database = _open_db(tmp_path / "legacy.db") + try: + _seed_legacy_database(database) + final = _migrate(database) + assert final == len(MIGRATIONS) + assert _version(database) == len(MIGRATIONS) + tables = _tables(database) + assert "task_definitions" not in tables + assert "task_instances" not in tables + assert {"quest_definitions", "quest_instances"} <= tables + # Every seeded row survived the rename. + assert _run(database.fetch_one("SELECT COUNT(*) FROM children")) == (1,) + assert ( + _run( + database.fetch_one( + "SELECT title FROM quest_definitions WHERE id = 1" + ) + ) + == ("Dishes",) + ) + assert ( + _run( + database.fetch_one( + "SELECT due_date FROM quest_instances WHERE id = 1" + ) + ) + == ("2026-09-15",) + ) + # Migration 3 moved the legacy single-assignee column into the + # assignees table: the definition kept its child, the column is + # gone. + assignees = _run( + database.fetch_one( + "SELECT child_id FROM quest_definition_assignees " + "WHERE definition_id = 1" + ) + ) + assert assignees == (1,) + columns = _run( + database.fetch_all("PRAGMA table_info(quest_definitions)") + ) + assert "child_id" not in {row[1] for row in columns} + # The untouched completion_events table still exists and is empty. + assert "completion_events" in tables + assert ( + _run(database.fetch_one("SELECT COUNT(*) FROM completion_events")) + == (0,) + ) + finally: + _run(database.close()) + + +def test_renamed_instances_foreign_key_follows_the_rename(tmp_path) -> None: + database = _open_db(tmp_path / "legacy-fk.db") + try: + _seed_legacy_database(database) + _migrate(database) + fks = _run(database.fetch_all("PRAGMA foreign_key_list(quest_instances)")) + referenced = {row[2] for row in fks} + assert "quest_definitions" in referenced + assert "task_definitions" not in referenced + # The renamed table is live: a new row validates its FKs. + _run( + database.execute( + "INSERT INTO children (display_name, created_at) " + "VALUES ('Ben', '2026-09-14T00:00:00+00:00')" + ) + ) + with pytest.raises(sqlite3.IntegrityError): + _run( + database.execute( + "INSERT INTO quest_instances (definition_id, child_id, " + "due_date, generated_at) " + "VALUES (999, 2, '2026-09-16', " + "'2026-09-14T00:00:00+00:00')" + ) + ) + finally: + _run(database.close()) + + +def test_legacy_rename_is_idempotent_across_restarts(tmp_path) -> None: + database = _open_db(tmp_path / "legacy-restart.db") + try: + _seed_legacy_database(database) + _migrate(database) + final = _migrate(database) + assert final == len(MIGRATIONS) + tables = _tables(database) + assert "quest_definitions" in tables + assert "task_definitions" not in tables + assert ( + _run(database.fetch_one("SELECT COUNT(*) FROM quest_definitions")) + == (1,) + ) + finally: + _run(database.close()) + + +def test_fresh_database_never_holds_legacy_table_names(tmp_path) -> None: + database = _open_db(tmp_path / "fresh-legacy.db") + try: + _migrate(database) + tables = _tables(database) + assert not any(name.startswith("task_") for name in tables) + assert _version(database) == len(MIGRATIONS) + finally: + _run(database.close()) + + +# --------------------------------------------------------------------------- +# Pre-D-008 database: quest_definitions with child_id stamped version 2 +# --------------------------------------------------------------------------- + +_V2_DEFINITIONS_DDL = [ + """ + CREATE TABLE IF NOT EXISTS quest_definitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + icon TEXT, + child_id INTEGER NOT NULL REFERENCES children(id), + schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id), + due_time TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL + ) + """, +] + +_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: + """Create a database shaped like a post-rename, pre-D-008 dev file. + + Fresh quest_* tables — but quest_definitions still carries the + single child_id column and no assignees table exists — stamped at + 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: + if any(name in sql for name in _v2_replaced): + continue + _run(database.execute(sql)) + for sql in _V2_DEFINITIONS_DDL + _V2_INSTANCES_DDL + _V2_EVENTS_DDL: + _run(database.execute(sql)) + _run(database.execute(VERSION_TABLE_DDL)) + _run( + database.execute( + f"INSERT INTO {VERSION_TABLE} (id, version) VALUES (1, 2)" + ) + ) + _run( + database.execute( + "INSERT INTO children (display_name, created_at) " + "VALUES ('Ada', '2026-09-14T00:00:00+00:00')" + ) + ) + _run( + database.execute( + "INSERT INTO children (display_name, created_at) " + "VALUES ('Bo', '2026-09-14T00:00:00+00:00')" + ) + ) + _run( + database.execute( + "INSERT INTO schedule_rules (rule_type, start_date) " + "VALUES ('daily', '2026-09-14')" + ) + ) + _run( + database.execute( + "INSERT INTO quest_definitions (title, child_id, " + "schedule_rule_id, created_at) " + "VALUES ('Dishes', 2, 1, '2026-09-14T00:00:00+00:00')" + ) + ) + _run( + database.execute( + "INSERT INTO quest_instances (definition_id, child_id, " + "due_date, generated_at) " + "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: + database = _open_db(tmp_path / "v2-upgrade.db") + try: + _seed_v2_multi_assignee_upgrade(database) + final = _migrate(database) + assert final == len(MIGRATIONS) + assert _version(database) == len(MIGRATIONS) + # The definition row survived and lost its child_id column. + row = _run( + database.fetch_one( + "SELECT title, schedule_rule_id, is_active " + "FROM quest_definitions WHERE id = 1" + ) + ) + assert row == ("Dishes", 1, 1) + columns = _run( + database.fetch_all("PRAGMA table_info(quest_definitions)") + ) + assert "child_id" not in {c[1] for c in columns} + # The single assignee moved into the assignees table. + assignees = _run( + database.fetch_all( + "SELECT definition_id, child_id " + "FROM quest_definition_assignees ORDER BY child_id" + ) + ) + assert assignees == [(1, 2)] + # The instance survived the rebuild, pinned to the migrated + # window, still referencing the definition. + instance = _run( + database.fetch_one( + "SELECT definition_id, child_id, window, due_date " + "FROM quest_instances WHERE id = 1" + ) + ) + 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. + assert _run(database.fetch_all("PRAGMA foreign_key_check")) == [] + # The rebuilt table is live for the new model: a definition + # insert carries no child column at all. + _run( + database.execute( + "INSERT INTO quest_definitions (title, schedule_rule_id, " + "created_at) VALUES ('Laundry', 1, " + "'2026-09-14T00:00:00+00:00')" + ) + ) + _run( + database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (2, 1)" + ) + ) + finally: + _run(database.close()) # --------------------------------------------------------------------------- @@ -162,8 +656,8 @@ def test_second_run_applies_nothing_and_keeps_rows(tmp_path) -> None: ) ) final = _migrate(database) - assert final == 1 - assert _version(database) == 1 + assert final == len(MIGRATIONS) + assert _version(database) == len(MIGRATIONS) assert _counts(database)["children"] == 1 assert _counts(database)["admin_users"] == 1 finally: @@ -228,7 +722,7 @@ def test_noop_run_is_logged_at_debug_level(caplog, tmp_path) -> None: with caplog.at_level(logging.DEBUG, logger="custom_components.nestquest.migrations"): _migrate(database) assert any( - "already at version 1" in r.getMessage() + f"already at version {len(MIGRATIONS)}" in r.getMessage() for r in caplog.records ) finally: @@ -241,33 +735,47 @@ def test_noop_run_is_logged_at_debug_level(caplog, tmp_path) -> None: def _failing_migrations(prior_statements: list[str]) -> list[list[str]]: - """Two-migration list where migration 2 fails mid-application.""" - return [ - prior_statements, + """Migration list where the LAST entry fails mid-application. + + Sized one entry longer than the real MIGRATIONS and padded with + harmless no-op fillers, so a fully-migrated database (already at + len(MIGRATIONS)) still has exactly one pending migration — the + failing one — to attempt. + """ + steps: list[list[str]] = [prior_statements] + steps += [ + [ + f"CREATE TABLE IF NOT EXISTS filler_{index} " + "(id INTEGER PRIMARY KEY)" + ] + for index in range(len(MIGRATIONS) - 1) + ] + steps.append( [ "CREATE TABLE IF NOT EXISTS later_table (id INTEGER PRIMARY KEY)", "CREATE TABLE broken (", - ], - ] + ] + ) + return steps def test_failing_migration_rolls_back_to_prior_version(tmp_path) -> None: database = _open_db(tmp_path / "rollback.db") try: - # Manually simulate a database at version 1: apply migration 1's - # statements directly and stamp version 1 through the runner. + # Manually simulate a fully-migrated database: run the real + # migration list, which stamps the latest version. _migrate(database) version_row = _run( database.fetch_one(f"SELECT version FROM {VERSION_TABLE}") ) - assert version_row == (1,) + assert version_row == (len(MIGRATIONS),) with pytest.raises(sqlite3.OperationalError): _migrate(database, _failing_migrations(SCHEMA_V1_STATEMENTS)) # Version stamp unchanged, statements from the failed migration # absent, and the v1 tables untouched. - assert _version(database) == 1 + assert _version(database) == len(MIGRATIONS) assert "later_table" not in _tables(database) assert _counts(database)["children"] == 0 assert _counts(database)["completion_events"] == 0 @@ -275,21 +783,24 @@ def test_failing_migration_rolls_back_to_prior_version(tmp_path) -> None: _run(database.close()) -def test_failing_migration_on_fresh_file_leaves_version_0(tmp_path) -> None: +def test_failing_migration_on_fresh_file_leaves_last_good_version( + tmp_path, +) -> None: database = _open_db(tmp_path / "fresh-rollback.db") try: - # The fresh file still reaches migration 2 (the failing one): - # migration 1 commits, stamping version 1, before migration 2 - # fails and rolls back. The run aborts at version 1 — the last - # fully-applied migration — not at version 0. + # The fresh file reaches the failing migration (the last one): + # earlier migrations commit and stamp before it fails and rolls + # back. The run aborts at the last fully-applied migration — + # not at version 0. + broken = _failing_migrations(SCHEMA_V1_STATEMENTS) with pytest.raises(sqlite3.OperationalError): - _migrate(database, _failing_migrations(SCHEMA_V1_STATEMENTS)) - assert _version(database) == 1 - # Migration 2's tables must not survive the failed attempt. + _migrate(database, broken) + assert _version(database) == len(broken) - 1 + # The failing migration's tables must not survive the attempt. assert "later_table" not in _tables(database) # Migration 1's tables do: it committed before the failure. assert "children" in _tables(database) - assert "task_instances" in _tables(database) + assert "quest_instances" in _tables(database) finally: _run(database.close()) @@ -302,12 +813,12 @@ def test_failed_migration_can_be_retried_after_fix(tmp_path) -> None: with pytest.raises(sqlite3.OperationalError): _migrate(database, broken) # "Fix" the migration and retry: it now applies cleanly. - broken[1] = [ + broken[-1] = [ "CREATE TABLE IF NOT EXISTS later_table (id INTEGER PRIMARY KEY)" ] final = _migrate(database, broken) - assert final == 2 - assert _version(database) == 2 + assert final == len(broken) + assert _version(database) == len(broken) assert "later_table" in _tables(database) finally: _run(database.close()) @@ -575,7 +1086,7 @@ async def test_setup_entry_migrates_fresh_database(hass, make_entry) -> None: entry = await _setup_entry(hass, make_entry(), hass.registry) database = entry.runtime_data.database - assert await read_schema_version(database) == 1 + assert await read_schema_version(database) == len(MIGRATIONS) # The v1 tables exist because the runner applied migration 1. names = { row[0] @@ -626,7 +1137,7 @@ async def test_options_reload_reopens_and_rechecks_version( database_b = entry_reloaded.runtime_data.database assert database_b is not database_a try: - assert await read_schema_version(database_b) == 1 + assert await read_schema_version(database_b) == len(MIGRATIONS) row = await database_b.fetch_one("SELECT COUNT(*) FROM children") assert row == (1,) finally: diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index 9b8dad8..bef04c8 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -14,6 +14,7 @@ from conftest import make_config_entry, make_hass, options_flow_base as _options from custom_components.nestquest import config_flow from custom_components.nestquest import options_flow from custom_components.nestquest.const import ( + CONF_ADMIN_USER_IDS, CONF_DAY_ROLLOVER_TIME, CONF_HORIZON_DAYS, CONF_PANEL_IDLE_TIMEOUT, @@ -46,13 +47,48 @@ def _make_entry(entry_id: str = "test_entry", options: dict | None = None, data: return make_config_entry(entry_id=entry_id, options=options, data=data) -def _make_flow(entry) -> options_flow.NestQuestOptionsFlow: - """Create an options flow instance bound to a stubbed config entry.""" +def _make_flow(entry, *, users=None, database=None) -> options_flow.NestQuestOptionsFlow: + """Create an options flow instance bound to a stubbed config entry. + + ``users`` populates a stubbed hass.auth for the admin picker; + ``database`` wires the runtime record so the flow can read and + write the allowlist like real HA's options flow does. + """ + from unittest.mock import AsyncMock + flow = options_flow.NestQuestOptionsFlow(config_entry=entry) flow.hass = SimpleNamespace() + flow.hass.auth = SimpleNamespace( + async_get_users=AsyncMock(return_value=users or []) + ) + if database is not None: + from custom_components.nestquest.const import DOMAIN + + flow.hass.data = { + DOMAIN: {entry.entry_id: SimpleNamespace(database=database)} + } return flow +def _user(user_id: str, name: str): + return SimpleNamespace(id=user_id, name=name) + + +def _open_allowlist_db(tmp_path, name): + """A real migrated temp database for the flow to read and write.""" + from unittest.mock import AsyncMock, MagicMock + + from custom_components.nestquest.db import NestQuestDatabase + from custom_components.nestquest.migrations import apply_migrations + + hass = MagicMock() + hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) + database = NestQuestDatabase(hass) + _run(database.open(tmp_path / name)) + _run(apply_migrations(database)) + return database + + def _schema_values(schema) -> dict: """Extract the per-field default values from a real voluptuous schema.""" return { @@ -161,7 +197,12 @@ def test_options_flow_falls_back_to_entry_data() -> None: def test_options_flow_schema_applies_stored_defaults() -> None: - """A real voluptuous validate({}) fills every field from the stored defaults.""" + """A real voluptuous validate({}) fills every field from the stored defaults. + + Without a runtime database (and no auth in the bare harness), the + admin picker's default is the empty selection and its choice set is + empty — the three legacy settings are unaffected. + """ entry = _make_entry( options={ CONF_HORIZON_DAYS: 21, @@ -174,6 +215,7 @@ def test_options_flow_schema_applies_stored_defaults() -> None: CONF_HORIZON_DAYS: 21, CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, + CONF_ADMIN_USER_IDS: [], } @@ -373,4 +415,223 @@ def test_options_flow_no_hardcoded_domain_or_db_filename() -> None: ) assert node.value != "nestquest.db", ( f"Hard-coded database filename in {py_file.name}:{node.lineno}" - ) \ No newline at end of file + ) + +# --------------------------------------------------------------------------- +# Admin allowlist picker (Feature 03) +# --------------------------------------------------------------------------- + + +def test_options_flow_picker_lists_existing_users(tmp_path) -> None: + """The picker offers EXISTING HA users (id -> name), never free text.""" + import voluptuous as vol + + database = _open_allowlist_db(tmp_path, "picker-users.db") + try: + from custom_components.nestquest.admin_allowlist import set_admin_ids + + _run(set_admin_ids(database, ["u1"])) + entry = _make_entry() + flow = _make_flow( + entry, + users=[_user("u1", "Joshua"), _user("u2", "Sam")], + database=database, + ) + result = _run(flow.async_step_init(None)) + schema = result["data_schema"] + marker = next( + m for m in schema.schema + if getattr(m, "schema", None) == CONF_ADMIN_USER_IDS + ) + # Default is the CURRENT database allowlist. + values = _schema_values(schema) + assert values[CONF_ADMIN_USER_IDS] == ["u1"] + # Both users are selectable; an unknown id is rejected by the + # schema itself (this is what real HA submits go through). + validated = schema( + { + CONF_HORIZON_DAYS: 7, + CONF_DAY_ROLLOVER_TIME: "03:30", + CONF_PANEL_IDLE_TIMEOUT: 60, + CONF_ADMIN_USER_IDS: ["u1", "u2"], + } + ) + assert validated[CONF_ADMIN_USER_IDS] == ["u1", "u2"] + with pytest.raises(vol.Invalid): + schema( + { + CONF_HORIZON_DAYS: 7, + CONF_DAY_ROLLOVER_TIME: "03:30", + CONF_PANEL_IDLE_TIMEOUT: 60, + CONF_ADMIN_USER_IDS: ["impostor"], + } + ) + finally: + _run(database.close()) + + +def test_options_flow_submit_applies_selection_to_database(tmp_path) -> None: + database = _open_allowlist_db(tmp_path, "picker-apply.db") + try: + from custom_components.nestquest.admin_allowlist import ( + list_admin_ids, + set_admin_ids, + ) + + _run(set_admin_ids(database, ["u1"])) + entry = _make_entry() + flow = _make_flow( + entry, + users=[_user("u1", "Joshua"), _user("u2", "Sam")], + database=database, + ) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_ADMIN_USER_IDS: ["u2"]}) + ) + assert result["type"] == "create_entry" + assert result["data"][CONF_ADMIN_USER_IDS] == ["u2"] + # The allowlist (and the entry's recovery copy) carry the pick. + assert await_gather(list_admin_ids(database)) == ["u2"] + finally: + _run(database.close()) + + +def await_gather(coro): + return _run(coro) + + +def test_options_flow_empty_selection_refused(tmp_path) -> None: + """Saving zero admins is refused: an empty allowlist fails closed.""" + database = _open_allowlist_db(tmp_path, "picker-empty.db") + try: + from custom_components.nestquest.admin_allowlist import ( + list_admin_ids, + set_admin_ids, + ) + + _run(set_admin_ids(database, ["u1"])) + entry = _make_entry() + flow = _make_flow( + entry, + users=[_user("u1", "Joshua")], + database=database, + ) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_ADMIN_USER_IDS: []}) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_ADMIN_USER_IDS: "no_admins"} + assert await_gather(list_admin_ids(database)) == ["u1"] + finally: + _run(database.close()) + + +def test_options_flow_unknown_selection_refused(tmp_path) -> None: + database = _open_allowlist_db(tmp_path, "picker-unknown.db") + try: + from custom_components.nestquest.admin_allowlist import ( + list_admin_ids, + set_admin_ids, + ) + + _run(set_admin_ids(database, ["u1"])) + entry = _make_entry() + flow = _make_flow( + entry, + users=[_user("u1", "Joshua")], + database=database, + ) + result = _run( + flow.async_step_init( + {**VALID_INPUT, CONF_ADMIN_USER_IDS: ["impostor"]} + ) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_ADMIN_USER_IDS: "invalid_admin"} + assert await_gather(list_admin_ids(database)) == ["u1"] + finally: + _run(database.close()) + + +def test_options_flow_submit_without_admin_key_keeps_allowlist(tmp_path) -> None: + """Raw callers omitting the key (pre-picker payloads) do not + touch the allowlist; the CURRENT allowlist is carried forward on + the options copy so no removed admin can be resurrected later.""" + database = _open_allowlist_db(tmp_path, "picker-legacy.db") + try: + from custom_components.nestquest.admin_allowlist import ( + list_admin_ids, + set_admin_ids, + ) + + _run(set_admin_ids(database, ["u1"])) + entry = _make_entry() + flow = _make_flow( + entry, + users=[_user("u1", "Joshua")], + database=database, + ) + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + assert result["data"][CONF_ADMIN_USER_IDS] == ["u1"] + assert await_gather(list_admin_ids(database)) == ["u1"] + finally: + _run(database.close()) + + +def test_options_flow_duplicate_selection_refused(tmp_path) -> None: + """Duplicate ids surface as a field error, not an unhandled + ValueError from the business layer.""" + database = _open_allowlist_db(tmp_path, "picker-dupe.db") + try: + from custom_components.nestquest.admin_allowlist import ( + list_admin_ids, + set_admin_ids, + ) + + _run(set_admin_ids(database, ["u1"])) + entry = _make_entry() + flow = _make_flow( + entry, + users=[_user("u1", "Joshua"), _user("u2", "Sam")], + database=database, + ) + result = _run( + flow.async_step_init( + {**VALID_INPUT, CONF_ADMIN_USER_IDS: ["u2", "u2"]} + ) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_ADMIN_USER_IDS: "invalid_admin"} + assert await_gather(list_admin_ids(database)) == ["u1"] + finally: + _run(database.close()) + + +def test_options_flow_omitted_picker_carries_allowlist_forward( + tmp_path, +) -> None: + """An omitted picker field must not drop the allowlist copy from + the returned options: HA replaces entry.options wholesale, and a + later database loss must not resurrect removed admins from the + stale config-flow copy in entry.data.""" + database = _open_allowlist_db(tmp_path, "picker-carry.db") + try: + from custom_components.nestquest.admin_allowlist import ( + set_admin_ids, + ) + + _run(set_admin_ids(database, ["u2"])) + entry = _make_entry() + flow = _make_flow( + entry, + users=[_user("u1", "Joshua"), _user("u2", "Sam")], + database=database, + ) + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + # The current allowlist rides along on the legacy three-key path. + assert result["data"][CONF_ADMIN_USER_IDS] == ["u2"] + assert result["data"][CONF_HORIZON_DAYS] == VALID_INPUT[CONF_HORIZON_DAYS] + finally: + _run(database.close()) diff --git a/tests/test_presence.py b/tests/test_presence.py new file mode 100644 index 0000000..14db444 --- /dev/null +++ b/tests/test_presence.py @@ -0,0 +1,814 @@ +"""Tests for presence.py: the Feature 05 custody model.""" +from __future__ import annotations + +import datetime + +import pytest + +from custom_components.nestquest.presence import ( + MAX_CYCLE_LENGTH_WEEKS, + PresenceEngine, + PresenceOverride, + PresenceSchedule, + cycle_week_index, +) + + +ANCHOR = datetime.date(2026, 1, 5) # a Monday + +TWO_WEEK_PATTERN = {0: {0, 2, 4}, 1: {1, 3}} + + +# --------------------------------------------------------------------------- +# PresenceSchedule construction +# --------------------------------------------------------------------------- + + +def test_schedule_holds_validated_fields() -> None: + schedule = PresenceSchedule( + 3, 2, "2026-01-05", {0: {0, 2, 4}, 1: {1, 3}} + ) + assert schedule.child_id == 3 + assert schedule.cycle_length_weeks == 2 + assert schedule.anchor_date == ANCHOR + assert schedule.pattern == {0: frozenset({0, 2, 4}), 1: frozenset({1, 3})} + + +def test_schedule_accepts_date_objects() -> None: + schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}}) + assert schedule.anchor_date is ANCHOR + + +def test_schedule_rejects_cycle_length_below_one() -> None: + with pytest.raises(ValueError, match="between 1 and"): + PresenceSchedule(1, 0, ANCHOR, {}) + with pytest.raises(ValueError, match="between 1 and"): + PresenceSchedule(1, -1, ANCHOR, {}) + + +def test_schedule_rejects_cycle_length_above_documented_cap() -> None: + """The 4 cap matches the schema's CHECK — both documents agree.""" + with pytest.raises(ValueError, match="between 1 and"): + PresenceSchedule(1, MAX_CYCLE_LENGTH_WEEKS + 1, ANCHOR, {}) + assert MAX_CYCLE_LENGTH_WEEKS == 4 + + +@pytest.mark.parametrize("bad", [True, "2", 1.0, None]) +def test_schedule_rejects_non_int_cycle_length(bad) -> None: + with pytest.raises(ValueError, match="cycle_length_weeks must be an integer"): + PresenceSchedule(1, bad, ANCHOR, {0: {0}}) + + +def test_schedule_rejects_pattern_missing_week_indices() -> None: + with pytest.raises(ValueError, match="missing \\[1\\]"): + PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}}) + + +def test_schedule_rejects_pattern_with_extra_week_indices() -> None: + with pytest.raises(ValueError, match="week index must be in 0"): + PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}, 2: {2}}) + + +def test_schedule_rejects_pattern_with_non_int_week_keys() -> None: + with pytest.raises(ValueError, match="week index must be an integer"): + PresenceSchedule(1, 1, ANCHOR, {"0": {0}}) + + +def test_schedule_rejects_pattern_with_bool_week_key() -> None: + """True would silently be week 1; bools are never valid indices.""" + with pytest.raises(ValueError, match="week index must be an integer"): + PresenceSchedule(1, 2, ANCHOR, {0: {0}, True: {1}}) + + +@pytest.mark.parametrize("bad_weekday", [-1, 7, 100]) +def test_schedule_rejects_weekday_outside_zero_to_six(bad_weekday) -> None: + with pytest.raises(ValueError, match="weekday must be in 0..6"): + PresenceSchedule(1, 1, ANCHOR, {0: {bad_weekday}}) + + +@pytest.mark.parametrize("bad", [True, "1", 1.5, None]) +def test_schedule_rejects_non_int_weekdays(bad) -> None: + with pytest.raises(ValueError, match="weekday must be an int"): + PresenceSchedule(1, 1, ANCHOR, {0: [bad]}) + + +def test_schedule_rejects_string_and_none_week_sets() -> None: + """A string is iterable — "02" would silently mean {0, 2}.""" + with pytest.raises(ValueError, match="iterable of weekday ints"): + PresenceSchedule(1, 1, ANCHOR, {0: "02"}) + with pytest.raises(ValueError, match="iterable of weekday ints"): + PresenceSchedule(1, 1, ANCHOR, {0: None}) + + +def test_schedule_rejects_non_dict_pattern() -> None: + with pytest.raises(ValueError, match="must map week index"): + PresenceSchedule(1, 1, ANCHOR, "0,2") + + +@pytest.mark.parametrize("bad", [True, 1.0, "2026-01-05", None]) +def test_schedule_rejects_non_int_child_id(bad) -> None: + with pytest.raises(ValueError, match="child_id must be an integer"): + PresenceSchedule(bad, 1, ANCHOR, {0: {0}}) + + +@pytest.mark.parametrize( + "bad", ["2026-1-5", "2026/01/05", "not-a-date", 5, None, True] +) +def test_schedule_rejects_non_strict_anchor_dates(bad) -> None: + with pytest.raises(ValueError, match="anchor_date"): + PresenceSchedule(1, 1, bad, {0: {0}}) + + +def test_schedule_empty_week_means_absent_week() -> None: + """An explicitly empty week is valid: absent every day of it — + distinct from having no schedule (the engine's default).""" + schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: set()}) + assert schedule.pattern[1] == frozenset() + + +def test_schedule_empty_week_encodes_as_empty_segment() -> None: + schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: set()}) + assert schedule.encode() == "0,2|" + + +def test_schedule_equal_patterns_are_equal_and_encode_identically() -> None: + one = PresenceSchedule(1, 2, ANCHOR, {0: {4, 2, 0}, 1: {3, 1}}) + two = PresenceSchedule(2, 2, ANCHOR, {0: {0, 2, 4}, 1: {1, 3}}) + assert one.encode() == two.encode() == "0,2,4|1,3" + + +# --------------------------------------------------------------------------- +# Serialization round-trip +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "encoded", + [ + "0,2,4|1,3", + "0|1,2,3,4,5,6", + "|", + "0,2|", + "|0,2", + "6", + "0,1,2,3,4,5,6|", + ], +) +def test_schedule_decode_encode_round_trips_losslessly(encoded) -> None: + schedule = PresenceSchedule.decode(1, ANCHOR, encoded) + assert schedule.encode() == encoded + again = PresenceSchedule.decode(1, ANCHOR, schedule.encode()) + assert again == schedule + + +def test_schedule_decode_sets_cycle_length_from_segments() -> None: + one_week = PresenceSchedule.decode(1, ANCHOR, "0,2,4") + assert one_week.cycle_length_weeks == 1 + four_week = PresenceSchedule.decode(1, ANCHOR, "0|1|2|3,4") + assert four_week.cycle_length_weeks == 4 + + +def test_schedule_decode_rejects_malformed_segments() -> None: + for bad in ("0,12|1", "0,,2|1", "x|1", "0|-1|2", "8", "0,|1"): + with pytest.raises(ValueError, match="invalid weekday"): + PresenceSchedule.decode(1, ANCHOR, bad) + + +def test_schedule_decode_rejects_non_string() -> None: + with pytest.raises(ValueError, match="must be a string"): + PresenceSchedule.decode(1, ANCHOR, None) + + +def test_schedule_decode_rejects_too_many_segments(tmp_path) -> None: + with pytest.raises(ValueError, match="between 1 and"): + PresenceSchedule.decode(1, ANCHOR, "0|1|2|3|4") + + +# --------------------------------------------------------------------------- +# PresenceOverride +# --------------------------------------------------------------------------- + + +def test_override_single_date_and_range_cover() -> None: + single = PresenceOverride(1, "2026-07-20", "2026-07-20", False) + assert single.covers(datetime.date(2026, 7, 20)) + assert not single.covers(datetime.date(2026, 7, 21)) + span = PresenceOverride(1, "2026-07-20", "2026-07-24", True, note="trade") + assert span.covers(datetime.date(2026, 7, 22)) + assert not span.covers(datetime.date(2026, 7, 19)) + assert not span.covers(datetime.date(2026, 7, 25)) + assert span.note == "trade" + + +def test_override_end_before_start_rejected() -> None: + with pytest.raises(ValueError, match="end_date must be on or after"): + PresenceOverride(1, "2026-07-24", "2026-07-20", True) + + +@pytest.mark.parametrize("bad", ["1", 0, 1.0, None]) +def test_override_rejects_non_bool_is_present(bad) -> None: + with pytest.raises(ValueError, match="is_present must be a real bool"): + PresenceOverride(1, "2026-07-20", "2026-07-20", bad) + + +@pytest.mark.parametrize("bad", [5, ["note"], 1.5]) +def test_override_rejects_non_string_note(bad) -> None: + with pytest.raises(ValueError, match="note must be a string or None"): + PresenceOverride(1, "2026-07-20", "2026-07-20", True, note=bad) + + +@pytest.mark.parametrize("bad", [True, 1.0, "1"]) +def test_override_rejects_non_int_child_id(bad) -> None: + with pytest.raises(ValueError, match="child_id must be an integer"): + PresenceOverride(bad, "2026-07-20", "2026-07-20", True) + + +@pytest.mark.parametrize( + ("start", "end"), [("2026-7-20", "2026-07-20"), ("nope", "2026-07-20")] +) +def test_override_rejects_non_strict_dates(start, end) -> None: + with pytest.raises(ValueError, match="date"): + PresenceOverride(1, start, end, True) + + +def test_schedule_rejects_datetime_anchor() -> None: + """datetime.datetime subclasses date: a bare isinstance check would + store a time component and every date comparison would TypeError.""" + with pytest.raises(ValueError, match="plain calendar date"): + PresenceSchedule(1, 1, datetime.datetime(2026, 1, 5, 9, 30), {0: {0}}) + + +def test_override_rejects_datetime_bounds() -> None: + with pytest.raises(ValueError, match="plain calendar date"): + PresenceOverride( + 1, datetime.datetime(2026, 7, 20), "2026-07-20", True + ) + + +def test_schedule_pattern_is_immutable_after_construction() -> None: + """The frozen schedule must not leak a mutable dict: injecting + unvalidated weekdays or dropping required weeks after construction + would bypass total validation.""" + schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: {1}}) + with pytest.raises(TypeError): + schedule.pattern[0] = {9} + with pytest.raises(TypeError): + del schedule.pattern[1] + # mappingproxy exposes no mutators at all. + assert not any( + hasattr(schedule.pattern, name) + for name in ("clear", "pop", "popitem", "setdefault", "update") + ) + # The validated contents are still readable and encode correctly. + assert schedule.pattern[0] == frozenset({0, 2}) + assert schedule.encode() == "0,2|1" + + +# --------------------------------------------------------------------------- +# Cycle arithmetic: anchor-date only, never ISO week parity +# --------------------------------------------------------------------------- + + +def test_cycle_week_index_follows_the_anchor_formula() -> None: + schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}}) + # The anchor day starts week 0; each whole 7 days steps the cycle. + offsets = {0: 0, 1: 0, 6: 0, 7: 1, 8: 1, 13: 1, 14: 0, 15: 0, 22: 1} + for offset, expected in offsets.items(): + assert ( + cycle_week_index( + schedule, ANCHOR + datetime.timedelta(days=offset) + ) + == expected + ), f"offset {offset}" + + +def test_cycle_week_index_before_the_anchor_walks_backwards() -> None: + """Python floor division: day -1 is the LAST week of the cycle, + wrapping backwards — never inverting.""" + schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}}) + assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=1)) == 1 + assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=7)) == 1 + assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=8)) == 0 + + +def test_cycle_week_index_three_week_pattern() -> None: + schedule = PresenceSchedule( + 1, 3, ANCHOR, {0: {0}, 1: {1}, 2: {2}} + ) + offsets = [0, 7, 14, 21, 28] + assert [cycle_week_index(schedule, ANCHOR + datetime.timedelta(days=o)) for o in offsets] == [ + 0, 1, 2, 0, 1 + ] + + +def test_cycle_week_index_stable_across_three_year_boundaries() -> None: + """The regression this exists for: walking day by day through a + December 31st -> January 1st boundary, the cycle index steps by 0 + or +1 (mod cycle length) and NEVER resets or inverts — including + 2020, a 53-ISO-week year, where ISO week parity flips and would + invert a two-week custody schedule on January 1st.""" + schedule = PresenceSchedule( + 1, 2, datetime.date(2019, 1, 6), {0: {0, 2, 4}, 1: {1, 3}} + ) + # Independently derived expectations (anchor 2019-01-06, index = + # floor(days-since-anchor / 7) % 2, hand-verified) — hardcoded so + # even an implementation that resets or inverts at January 1st + # cannot pass by coincidence: + expected = { + # 2019 -> 2020 boundary + datetime.date(2019, 12, 26): 0, + datetime.date(2019, 12, 27): 0, + datetime.date(2019, 12, 28): 0, + datetime.date(2019, 12, 29): 1, + datetime.date(2019, 12, 30): 1, + datetime.date(2019, 12, 31): 1, + datetime.date(2020, 1, 1): 1, + datetime.date(2020, 1, 2): 1, + datetime.date(2020, 1, 3): 1, + datetime.date(2020, 1, 4): 1, + datetime.date(2020, 1, 5): 0, + datetime.date(2020, 1, 6): 0, + datetime.date(2020, 1, 7): 0, + # 2020 (53 ISO weeks) -> 2021 boundary + datetime.date(2020, 12, 27): 1, + datetime.date(2020, 12, 28): 1, + datetime.date(2020, 12, 29): 1, + datetime.date(2020, 12, 30): 1, + datetime.date(2020, 12, 31): 1, + datetime.date(2021, 1, 1): 1, + datetime.date(2021, 1, 2): 1, + datetime.date(2021, 1, 3): 0, + datetime.date(2021, 1, 4): 0, + datetime.date(2021, 1, 5): 0, + datetime.date(2021, 1, 6): 0, + datetime.date(2021, 1, 7): 0, + # 2021 -> 2022 boundary + datetime.date(2021, 12, 27): 1, + datetime.date(2021, 12, 28): 1, + datetime.date(2021, 12, 29): 1, + datetime.date(2021, 12, 30): 1, + datetime.date(2021, 12, 31): 1, + datetime.date(2022, 1, 1): 1, + datetime.date(2022, 1, 2): 0, + datetime.date(2022, 1, 3): 0, + datetime.date(2022, 1, 4): 0, + datetime.date(2022, 1, 5): 0, + datetime.date(2022, 1, 6): 0, + datetime.date(2022, 1, 7): 0, + } + for day, wanted in expected.items(): + got = cycle_week_index(schedule, day) + assert got == wanted, f"{day}: expected week {wanted}, got {got}" + # And 2020 really is the 53-ISO-week year this guards against. + assert datetime.date(2020, 12, 31).isocalendar()[1] == 53 + + +def test_module_uses_no_iso_week_functions() -> None: + """D-004 guardrail: cycle math never reads ISO week numbers.""" + from pathlib import Path + + source = Path( + __import__("custom_components.nestquest.presence", fromlist=["__file__"]).__file__ + ).read_text() + assert "isocalendar" not in source + assert "isoweek" not in source + + +# --------------------------------------------------------------------------- +# PresenceEngine: is_present with the always-present default +# --------------------------------------------------------------------------- + + +def _three_child_engine() -> PresenceEngine: + """Two children on opposite weeks of one two-week rotation, one + with no schedule at all (the always-present sibling).""" + anchor = datetime.date(2026, 1, 5) # Monday, week 0 + declan = PresenceSchedule( + 1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} + ) + jordyn = PresenceSchedule( + 2, 2, anchor, {0: set(), 1: {0, 1, 2, 3, 4, 5, 6}} + ) + return PresenceEngine({1: declan, 2: jordyn}) + + +def test_engine_child_without_schedule_is_always_present() -> None: + """Every day of the four-week span, all three children get the + expected verdict: Chloe (no schedule) always present; the two + rotation children complementary per the anchor pattern.""" + engine = _three_child_engine() + anchor = datetime.date(2026, 1, 5) + for offset in range(28): + day = anchor + datetime.timedelta(days=offset) + week = ((day - anchor).days // 7) % 2 + assert engine.is_present(3, day) is True, f"offset {offset}: Chloe" + assert engine.is_present(1, day) is (week == 0), ( + f"offset {offset}: Declan should follow week {week}" + ) + assert engine.is_present(2, day) is (week == 1), ( + f"offset {offset}: Jordyn should follow week {week}" + ) + + +def test_engine_alternating_children_are_complementary_for_four_weeks() -> None: + """Every day of a four-week span: the two rotation children swap + presence exactly per the anchor pattern — when one is home the + other is away.""" + engine = _three_child_engine() + anchor = datetime.date(2026, 1, 5) + for offset in range(28): + day = anchor + datetime.timedelta(days=offset) + d = engine.is_present(1, day) + j = engine.is_present(2, day) + assert d == (not j), f"offset {offset}: {d}/{j} not complementary" + expected_week = ((day - anchor).days // 7) % 2 + expected_present = day.weekday() in {0, 1, 2, 3, 4, 5, 6} + assert engine.is_present(1, day) == (expected_week == 0), ( + f"offset {offset}: Declan week {expected_week}" + ) + # Spot-check the swap days. + assert engine.is_present(1, anchor) is True + assert engine.is_present(2, anchor) is False + assert engine.is_present(1, anchor + datetime.timedelta(days=7)) is False + assert engine.is_present(2, anchor + datetime.timedelta(days=7)) is True + + +def test_engine_weekend_absence_respects_the_pattern() -> None: + """A weekday-set pattern excludes days outside the set even in the + child's present week.""" + engine = PresenceEngine( + { + 1: PresenceSchedule( + 1, 2, datetime.date(2026, 1, 5), {0: {1, 3}, 1: {2, 4}} + ) + } + ) + # 2026-01-06 (Tue) is week 0, weekday 1 -> present. + assert engine.is_present(1, datetime.date(2026, 1, 6)) is True + # 2026-01-07 (Wed) is week 0, weekday 2 -> absent. + assert engine.is_present(1, datetime.date(2026, 1, 7)) is False + # 2026-01-14 (Wed) is week 1, weekday 2 -> present. + assert engine.is_present(1, datetime.date(2026, 1, 14)) is True + + +def test_engine_rejects_bad_construction_and_inputs() -> None: + schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}}) + with pytest.raises(ValueError, match="must map child_id"): + PresenceEngine([schedule]) + with pytest.raises(ValueError, match="must be a PresenceSchedule"): + PresenceEngine({1: "not-a-schedule"}) + with pytest.raises(ValueError, match="holds the schedule"): + PresenceEngine({2: schedule}) + with pytest.raises(ValueError, match="child_id must be an integer"): + PresenceEngine({True: schedule}) + with pytest.raises(ValueError, match="child_id must be an integer"): + PresenceEngine({1.0: PresenceSchedule(1, 1, ANCHOR, {0: {0}})}) + engine = PresenceEngine({1: schedule}) + with pytest.raises(ValueError, match="child_id must be an integer"): + engine.is_present(True, ANCHOR) + with pytest.raises(ValueError, match="plain calendar date"): + engine.is_present(1, datetime.datetime(2026, 1, 5)) + + +def test_engine_is_immutable() -> None: + engine = _three_child_engine() + with pytest.raises(AttributeError, match="immutable snapshot"): + engine._schedules = {} + with pytest.raises(AttributeError, match="immutable snapshot"): + engine.something_new = 1 + + +# --------------------------------------------------------------------------- +# Overrides take precedence over the pattern +# --------------------------------------------------------------------------- + + +def _engine_with_overrides(*overrides_for_declan) -> PresenceEngine: + anchor = datetime.date(2026, 1, 5) # Monday + return PresenceEngine( + { + 1: PresenceSchedule( + 1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} + ) + }, + overrides={1: list(overrides_for_declan)} if overrides_for_declan else {}, + ) + + +def test_absent_override_on_normally_present_day_wins() -> None: + # 2026-01-06 is Tuesday of Declan's week 0: normally present. + engine = _engine_with_overrides( + PresenceOverride(1, "2026-01-06", "2026-01-06", False, note="away") + ) + assert engine.is_present(1, datetime.date(2026, 1, 6)) is False + # The pattern resumes outside the override. + assert engine.is_present(1, datetime.date(2026, 1, 7)) is True + + +def test_present_override_on_normally_absent_day_wins() -> None: + # 2026-01-13 is in Declan's week 1 (away all week); a present + # override on the Tuesday of week 1 brings him home for that day. + engine = _engine_with_overrides( + PresenceOverride(1, "2026-01-13", "2026-01-13", True, note="trade") + ) + assert engine.is_present(1, datetime.date(2026, 1, 13)) is True + assert engine.is_present(1, datetime.date(2026, 1, 14)) is False + + +def test_override_applies_without_a_schedule() -> None: + """An override on a schedule-free child still applies: holidays and + swaps must work for the always-present sibling too.""" + engine = PresenceEngine( + {}, + overrides={ + 3: [ + PresenceOverride( + 3, "2026-07-20", "2026-07-24", False, note="holiday" + ) + ] + }, + ) + assert engine.is_present(3, datetime.date(2026, 7, 22)) is False + assert engine.is_present(3, datetime.date(2026, 7, 19)) is True + assert engine.is_present(3, datetime.date(2026, 7, 25)) is True + + +def test_override_range_beats_pattern_for_its_span() -> None: + engine = _engine_with_overrides( + PresenceOverride(1, "2026-01-13", "2026-01-17", True, note="traded week") + ) + for offset in range(12, 17): + day = datetime.date(2026, 1, 1) + datetime.timedelta(days=offset) + assert engine.is_present(1, day) is True, f"{day}" + # Days outside the span stay on the pattern (week 1: absent). + assert engine.is_present(1, datetime.date(2026, 1, 12)) is False + assert engine.is_present(1, datetime.date(2026, 1, 18)) is False + + +def test_engine_override_snapshot_validation() -> None: + schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}}) + entry = PresenceOverride(1, "2026-07-20", "2026-07-20", True) + with pytest.raises(ValueError, match="must map child_id"): + PresenceEngine({1: schedule}, overrides=[entry]) + with pytest.raises(ValueError, match="must be a list"): + PresenceEngine({1: schedule}, overrides={1: entry}) + with pytest.raises(ValueError, match="must be"): + PresenceEngine({1: schedule}, overrides={1: ["nope"]}) + with pytest.raises(ValueError, match="holds an override"): + PresenceEngine({1: schedule}, overrides={2: [entry]}) + with pytest.raises(ValueError, match="child_id must be an integer"): + PresenceEngine({1: schedule}, overrides={True: [entry]}) + + +def test_engine_overlapping_snapshot_uses_latest_start() -> None: + """The DAO layer refuses overlapping overrides per child; if a + snapshot carries them anyway (hand-built), the latest start date + wins deterministically.""" + engine = _engine_with_overrides( + PresenceOverride(1, "2026-01-06", "2026-01-10", False), + PresenceOverride(1, "2026-01-08", "2026-01-12", True), + ) + # Jan 6-7: only the earlier override covers -> absent. + assert engine.is_present(1, datetime.date(2026, 1, 6)) is False + # Jan 8-10: both cover; the LATER start (present) wins. + assert engine.is_present(1, datetime.date(2026, 1, 9)) is True + # Jan 11-12: only the later override covers -> present. + assert engine.is_present(1, datetime.date(2026, 1, 11)) is True + + +def test_engine_override_lists_are_frozen() -> None: + """The overrides snapshot must be truly read-only: the engine is + built directly on the CALLER's list, so mutating that list after + construction would change answers under a shallow copy — under the + frozen snapshot it must not.""" + entry = PresenceOverride(1, "2026-01-06", "2026-01-06", False) + entries = [entry] + schedule = PresenceSchedule( + 1, 2, ANCHOR, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} + ) + engine = PresenceEngine( + {1: schedule}, overrides={1: entries} # caller-owned list + ) + assert engine.is_present(1, datetime.date(2026, 1, 6)) is False + entries.append(PresenceOverride(1, "2026-01-07", "2026-01-07", False)) + # The late append must NOT change the engine's answers... + assert engine.is_present(1, datetime.date(2026, 1, 7)) is True + # Tuples have no append at all (AttributeError), and the mapping + # itself refuses reassignment. + with pytest.raises(AttributeError): + engine._overrides[1].append(entry) + with pytest.raises(AttributeError, match="immutable snapshot"): + engine._overrides = {} + + +# --------------------------------------------------------------------------- +# next_present_dates: the parent's pattern preview +# --------------------------------------------------------------------------- + + +def _preview_engine() -> PresenceEngine: + """Declan: home all of week 0, away all of week 1 (anchor + 2026-01-05, a Monday); an away override Tue Jan 13 (his away week) + would matter only for the override test.""" + return PresenceEngine( + { + 1: PresenceSchedule( + 1, 2, datetime.date(2026, 1, 5), + {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}, + ) + } + ) + + +def test_preview_returns_the_next_present_days_of_a_known_pattern() -> None: + engine = _preview_engine() + # Starting Monday Jan 5 (present week 0): five straight days. + preview = engine.next_present_dates(1, "2026-01-05", 5) + assert preview == [ + datetime.date(2026, 1, 5), + datetime.date(2026, 1, 6), + datetime.date(2026, 1, 7), + datetime.date(2026, 1, 8), + datetime.date(2026, 1, 9), + ] + # Starting mid-week: the rest of week 0 first, then the absent + # week 1 is skipped and the next present week picks up. + preview = engine.next_present_dates(1, "2026-01-07", 3) + assert preview == [ + datetime.date(2026, 1, 7), + datetime.date(2026, 1, 8), + datetime.date(2026, 1, 9), + ] + preview = engine.next_present_dates(1, "2026-01-07", 6) + # Week 0 runs Mon Jan 5 .. Sun Jan 11 (all present); the away week + # (Jan 12-18) is skipped; week 2 resumes Jan 19. + assert preview == [ + datetime.date(2026, 1, 7), + datetime.date(2026, 1, 8), + datetime.date(2026, 1, 9), + datetime.date(2026, 1, 10), + datetime.date(2026, 1, 11), + datetime.date(2026, 1, 19), + ] + + +def test_preview_start_date_is_inclusive() -> None: + engine = _preview_engine() + # Jan 12 (Monday of the away week) is absent: the first present day + # is Jan 19 — the start date itself when present. + preview = engine.next_present_dates(1, "2026-01-19", 1) + assert preview == [datetime.date(2026, 1, 19)] + + +def test_preview_honours_overrides() -> None: + schedule = PresenceSchedule( + 1, 2, datetime.date(2026, 1, 5), + {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}, + ) + engine = PresenceEngine( + {1: schedule}, + overrides={ + 1: [ + PresenceOverride( + 1, "2026-01-05", "2026-01-06", False, note="holiday" + ) + ] + }, + ) + # Jan 5-6 blocked by the override: the preview starts Jan 7. + preview = engine.next_present_dates(1, "2026-01-04", 3) + assert preview == [ + datetime.date(2026, 1, 7), + datetime.date(2026, 1, 8), + datetime.date(2026, 1, 9), + ] + + +def test_preview_schedule_free_child_returns_consecutive_days() -> None: + engine = PresenceEngine({}) + preview = engine.next_present_dates(3, "2026-03-01", 3) + assert preview == [ + datetime.date(2026, 3, 1), + datetime.date(2026, 3, 2), + datetime.date(2026, 3, 3), + ] + + +def test_preview_rejects_bad_inputs() -> None: + engine = _preview_engine() + with pytest.raises(ValueError, match="count must be an integer"): + engine.next_present_dates(1, "2026-01-05", "3") + with pytest.raises(ValueError, match="count must be at least 1"): + engine.next_present_dates(1, "2026-01-05", 0) + with pytest.raises(ValueError, match="child_id must be an integer"): + engine.next_present_dates(True, "2026-01-05", 1) + with pytest.raises(ValueError, match="plain calendar date"): + engine.next_present_dates(1, datetime.datetime(2026, 1, 5), 1) + + +def test_preview_always_absent_child_raises_not_loops() -> None: + """A child absent every scanned day must surface as an error, not + hang the admin preview.""" + engine = PresenceEngine( + { + 1: PresenceSchedule( + 1, 1, datetime.date(2026, 1, 5), {0: set()} + ) + } + ) + with pytest.raises(ValueError, match="not present on any"): + engine.next_present_dates(1, "2026-01-05", 2) + + +# --------------------------------------------------------------------------- +# The week-parity regression guard (Feature 05, D-004) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("year", "length", "reason"), + [ + (2020, 366, "leap year, 53 ISO weeks"), + (2015, 365, "365 days, 53 ISO weeks"), + ], +) +def test_custody_rotation_walks_full_years_without_inversion( + year, length, reason +) -> None: + """REGRESSION GUARD for the week-parity bug (D-004). + + Walks a two-week alternating rotation day by day across a FULL + {length}-day year ({year} — {reason}) plus the following January, + asserting the present/absent sequence never breaks or inverts at + any point, including January 1st. ISO week parity silently flips + at the ISO-week rollover (the Monday on or after January 4th — + January 4th itself in both of these years, which end ISO week 53) + and would invert every custody schedule there; anchor-date + arithmetic must not. + """.format(length=length, year=year, reason=reason) + if year == 2020: + assert datetime.date(2020, 12, 31).isocalendar()[1] == 53 + assert (datetime.date(2021, 1, 1) - datetime.date(2020, 1, 1)).days == 366 + if year == 2015: + assert datetime.date(2015, 12, 31).isocalendar()[1] == 53 + assert (datetime.date(2016, 1, 1) - datetime.date(2015, 1, 1)).days == 365 + + # The first Monday of January (2020: Jan 6; 2015: Jan 5) — an + # on-week anchor, whatever the year. + anchor = datetime.date(year, 1, 1) + datetime.timedelta( + days=(7 - datetime.date(year, 1, 1).weekday()) % 7 + ) + assert anchor.weekday() == 0 + rotation = PresenceSchedule( + 1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} + ) + engine = PresenceEngine({1: rotation}) + + # Walk EVERY day of the year. The expectation is derived from the + # anchor's 7-day BLOCKS, not from the engine: each block of seven + # days starting at the anchor is uniformly present (even block + # index) or uniformly absent (odd block index), and consecutive + # blocks alternate. A parity-based implementation resets on + # January 1st and breaks either the uniformity of a block or the + # alternation between them — most visibly in the block spanning + # December 31st -> January 1st of a 53-ISO-week year. + # Walk the FULL year AND the first two weeks of the following + # January: the block spanning December 31st -> January 1st must + # stay uniform ACROSS the boundary, so an implementation that + # resets when the target year differs from the anchor's fails + # here (the post-boundary days of its final block disagree). + first_day = datetime.date(year, 1, 1) + last_day = datetime.date(year + 1, 1, 14) + days_seen = 0 + day = first_day + while day <= last_day: + days_since_anchor = (day - anchor).days + block = days_since_anchor // 7 # which 7-day block of the cycle + block_start = anchor + datetime.timedelta(days=block * 7) + # Uniformity within the block: every day of this 7-day block + # must answer exactly like the block's first day. + expected = ((block % 2) == 0) + assert engine.is_present(1, day) is expected, ( + f"{day} (block {block}, day offset " + f"{(day - block_start).days}): present=" + f"{engine.is_present(1, day)}, expected {expected}" + ) + # The block's OTHER days must agree — including the days on + # the far side of January 1st. + for offset in range(7): + block_day = block_start + datetime.timedelta(days=offset) + if first_day <= block_day <= last_day: + assert engine.is_present(1, block_day) is expected, ( + f"{block_day} disagrees with its block {block}" + ) + days_seen += 1 + day += datetime.timedelta(days=1) + # The walked year itself contributed exactly its full length. + year_days = sum( + 1 + for offset in range((last_day - first_day).days + 1) + if (first_day + datetime.timedelta(days=offset)).year == year + ) + assert year_days == length, ( + f"walked {year_days} days of {year}, expected {length}" + ) diff --git a/tests/test_schema.py b/tests/test_schema.py index 245a87c..f869176 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -15,8 +15,10 @@ from custom_components.nestquest.schema import ( SCHEMA_V1_PRESENCE_SCHEDULES_DDL, SCHEMA_V1_SCHEDULE_RULES_DDL, SCHEMA_V1_STATEMENTS, - SCHEMA_V1_TASK_DEFINITIONS_DDL, - SCHEMA_V1_TASK_INSTANCES_DDL, + SCHEMA_V1_QUEST_DEFINITIONS_DDL, + SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, + SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, + SCHEMA_V1_QUEST_INSTANCES_DDL, async_apply_ddl, ) @@ -73,15 +75,17 @@ def _insert_rule(database, **overrides): return result.lastrowid -def _insert_definition(database, child_id=1, rule_id=1, **overrides): - """Insert one valid task_definition, returning the definition id. +def _insert_definition(database, rule_id=1, assignees=(1,), **overrides): + """Insert one valid quest_definition, returning the definition id. + ``assignees`` rows are written to ``quest_definition_assignees`` + after the definition insert (the definition itself carries no + child). Pass ``assignees=None`` for an unassigned definition. Pass ``_OMIT`` as a value to leave that column out of the INSERT so its DDL DEFAULT applies. """ values: dict[str, object] = { "title": "Brush teeth", - "child_id": child_id, "schedule_rule_id": rule_id, "created_at": "2026-09-13T00:00:00+00:00", } @@ -91,11 +95,19 @@ def _insert_definition(database, child_id=1, rule_id=1, **overrides): placeholders = ", ".join("?" for _ in values) result = _run( database.execute( - f"INSERT INTO task_definitions ({columns}) VALUES ({placeholders})", + f"INSERT INTO quest_definitions ({columns}) VALUES ({placeholders})", tuple(values.values()), ) ) assert result.lastrowid is not None + for child_id in assignees or (): + _run( + database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (?, ?)", + (result.lastrowid, child_id), + ) + ) return result.lastrowid @@ -156,15 +168,17 @@ def _insert_override(database, child_id, **overrides): # --------------------------------------------------------------------------- -def test_schema_v1_statements_compose_the_six_tables() -> None: +def test_schema_v1_statements_compose_the_eight_tables() -> None: assert SCHEMA_V1_STATEMENTS == [ *SCHEMA_V1_CHILDREN_DDL, *SCHEMA_V1_ADMIN_USERS_DDL, *SCHEMA_V1_SCHEDULE_RULES_DDL, - *SCHEMA_V1_TASK_DEFINITIONS_DDL, + *SCHEMA_V1_QUEST_DEFINITIONS_DDL, + *SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, + *SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, *SCHEMA_V1_PRESENCE_SCHEDULES_DDL, *SCHEMA_V1_PRESENCE_OVERRIDES_DDL, - *SCHEMA_V1_TASK_INSTANCES_DDL, + *SCHEMA_V1_QUEST_INSTANCES_DDL, *SCHEMA_V1_COMPLETION_EVENTS_DDL, ] @@ -256,17 +270,17 @@ def test_schedule_rules_table_exists_after_applying_ddl(tmp_path) -> None: _run(database.close()) -def test_task_definitions_table_exists_after_applying_ddl(tmp_path) -> None: +def test_quest_definitions_table_exists_after_applying_ddl(tmp_path) -> None: database = _open_db(tmp_path / "definitions.db") try: _apply(database) row = _run( database.fetch_one( "SELECT name FROM sqlite_master WHERE type = 'table' " - "AND name = 'task_definitions'" + "AND name = 'quest_definitions'" ) ) - assert row is not None and row[0] == "task_definitions" + assert row is not None and row[0] == "quest_definitions" finally: _run(database.close()) @@ -292,17 +306,16 @@ def test_schedule_rules_columns_types_and_constraints(tmp_path) -> None: _run(database.close()) -def test_task_definitions_columns_types_and_constraints(tmp_path) -> None: +def test_quest_definitions_columns_types_and_constraints(tmp_path) -> None: database = _open_db(tmp_path / "definitions-columns.db") try: _apply(database) - columns = _run(database.fetch_all("PRAGMA table_info(task_definitions)")) + columns = _run(database.fetch_all("PRAGMA table_info(quest_definitions)")) assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [ ("id", "INTEGER", 0, None, 1), ("title", "TEXT", 1, None, 0), ("description", "TEXT", 0, None, 0), ("icon", "TEXT", 0, None, 0), - ("child_id", "INTEGER", 1, None, 0), ("schedule_rule_id", "INTEGER", 1, None, 0), ("due_time", "TEXT", 0, None, 0), ("is_active", "INTEGER", 1, "1", 0), @@ -312,20 +325,73 @@ def test_task_definitions_columns_types_and_constraints(tmp_path) -> None: _run(database.close()) -def test_task_definitions_foreign_keys_declared(tmp_path) -> None: +def test_quest_definitions_foreign_keys_declared(tmp_path) -> None: database = _open_db(tmp_path / "definitions-fks.db") try: _apply(database) - fks = _run(database.fetch_all("PRAGMA foreign_key_list(task_definitions)")) + fks = _run(database.fetch_all("PRAGMA foreign_key_list(quest_definitions)")) declared = {(row[2], row[3], row[4]) for row in fks} assert declared == { - ("children", "child_id", "id"), ("schedule_rules", "schedule_rule_id", "id"), } finally: _run(database.close()) +def test_quest_definition_assignees_table_exists_after_applying_ddl( + tmp_path, +) -> None: + database = _open_db(tmp_path / "assignees.db") + try: + _apply(database) + row = _run( + database.fetch_one( + "SELECT name FROM sqlite_master WHERE type = 'table' " + "AND name = 'quest_definition_assignees'" + ) + ) + assert row is not None and row[0] == "quest_definition_assignees" + finally: + _run(database.close()) + + +def test_quest_definition_assignees_columns_types_and_constraints( + tmp_path, +) -> None: + database = _open_db(tmp_path / "assignees-columns.db") + try: + _apply(database) + columns = _run( + database.fetch_all("PRAGMA table_info(quest_definition_assignees)") + ) + # cid, name, type, notnull, dflt_value, pk — both columns are + # the two-part primary key, so pk is 1 and 2. + assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [ + ("definition_id", "INTEGER", 1, None, 1), + ("child_id", "INTEGER", 1, None, 2), + ] + finally: + _run(database.close()) + + +def test_quest_definition_assignees_foreign_keys_declared(tmp_path) -> None: + database = _open_db(tmp_path / "assignees-fks.db") + try: + _apply(database) + fks = _run( + database.fetch_all( + "PRAGMA foreign_key_list(quest_definition_assignees)" + ) + ) + declared = {(row[2], row[3], row[4]) for row in fks} + assert declared == { + ("quest_definitions", "definition_id", "id"), + ("children", "child_id", "id"), + } + finally: + _run(database.close()) + + # --------------------------------------------------------------------------- # Constraint enforcement # --------------------------------------------------------------------------- @@ -519,7 +585,7 @@ def test_failed_statement_rolls_back_whole_application(tmp_path) -> None: # --------------------------------------------------------------------------- -# schedule_rules and task_definitions constraint enforcement +# schedule_rules and quest_definitions constraint enforcement # --------------------------------------------------------------------------- @@ -813,7 +879,7 @@ def test_schedule_rules_start_date_not_null_enforced(tmp_path) -> None: _run(database.close()) -def test_task_definitions_insert_requires_existing_child_and_rule( +def test_quest_definitions_insert_requires_existing_child_and_rule( tmp_path, ) -> None: database = _open_db(tmp_path / "definitions-fk-unknown.db") @@ -826,33 +892,279 @@ def test_task_definitions_insert_requires_existing_child_and_rule( ) ) rule_id = _insert_rule(database) - definition_id = _insert_definition( - database, child_id=1, rule_id=rule_id - ) + definition_id = _insert_definition(database, rule_id=rule_id) row = _run( database.fetch_one( - "SELECT title, child_id, schedule_rule_id FROM task_definitions " + "SELECT title, schedule_rule_id FROM quest_definitions " "WHERE id = ?", (definition_id,), ) ) - assert row == ("Brush teeth", 1, rule_id) + assert row == ("Brush teeth", rule_id) + assignee = _run( + database.fetch_one( + "SELECT child_id FROM quest_definition_assignees " + "WHERE definition_id = ?", + (definition_id,), + ) + ) + assert assignee == (1,) finally: _run(database.close()) -def test_task_definitions_unknown_child_id_fails(tmp_path) -> None: - database = _open_db(tmp_path / "definitions-bad-child.db") +def test_quest_definition_assignees_unknown_child_fails(tmp_path) -> None: + database = _open_db(tmp_path / "assignees-bad-child.db") try: _apply(database) _insert_rule(database) + definition_id = _insert_definition(database, assignees=None) with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"): - _insert_definition(database, child_id=999) + _run( + database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (?, 999)", + (definition_id,), + ) + ) finally: _run(database.close()) -def test_task_definitions_unknown_schedule_rule_id_fails(tmp_path) -> None: +def test_quest_definition_assignees_unknown_definition_fails(tmp_path) -> None: + database = _open_db(tmp_path / "assignees-bad-definition.db") + try: + _apply(database) + _run( + database.execute( + "INSERT INTO children (display_name, created_at) VALUES (?, ?)", + ("Ada", "2026-09-13T00:00:00+00:00"), + ) + ) + with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"): + _run( + database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (999, 1)" + ) + ) + finally: + _run(database.close()) + + +def test_quest_definition_assignees_duplicate_pair_fails(tmp_path) -> None: + database = _open_db(tmp_path / "assignees-duplicate.db") + try: + _apply(database) + _run( + database.execute( + "INSERT INTO children (display_name, created_at) VALUES (?, ?)", + ("Ada", "2026-09-13T00:00:00+00:00"), + ) + ) + _insert_rule(database) + definition_id = _insert_definition(database) + with pytest.raises(sqlite3.IntegrityError): + _run( + database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (?, 1)", + (definition_id,), + ) + ) + finally: + _run(database.close()) + + +def test_quest_definition_windows_table_exists_after_applying_ddl( + tmp_path, +) -> None: + database = _open_db(tmp_path / "windows.db") + try: + _apply(database) + row = _run( + database.fetch_one( + "SELECT name FROM sqlite_master WHERE type = 'table' " + "AND name = 'quest_definition_windows'" + ) + ) + assert row is not None and row[0] == "quest_definition_windows" + finally: + _run(database.close()) + + +def test_quest_definition_windows_columns_types_and_constraints( + tmp_path, +) -> None: + database = _open_db(tmp_path / "windows-columns.db") + try: + _apply(database) + columns = _run( + database.fetch_all("PRAGMA table_info(quest_definition_windows)") + ) + # cid, name, type, notnull, dflt_value, pk — the composite + # primary key spans definition_id and window. + assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [ + ("definition_id", "INTEGER", 1, None, 1), + ("window", "TEXT", 1, None, 2), + ("due_time", "TEXT", 0, None, 0), + ] + finally: + _run(database.close()) + + +def test_quest_definition_windows_foreign_keys_declared(tmp_path) -> None: + database = _open_db(tmp_path / "windows-fks.db") + try: + _apply(database) + fks = _run( + database.fetch_all( + "PRAGMA foreign_key_list(quest_definition_windows)" + ) + ) + declared = {(row[2], row[3], row[4]) for row in fks} + assert declared == { + ("quest_definitions", "definition_id", "id"), + } + finally: + _run(database.close()) + + +def test_quest_definition_windows_valid_rows_round_trip(tmp_path) -> None: + database = _open_db(tmp_path / "windows-roundtrip.db") + try: + _apply(database) + _run( + database.execute( + "INSERT INTO children (display_name, created_at) VALUES (?, ?)", + ("Ada", "2026-09-13T00:00:00+00:00"), + ) + ) + _insert_rule(database) + definition_id = _insert_definition(database, assignees=None) + for window, due in ( + ("morning", "09:00"), + ("afternoon", None), + ("evening", "19:30"), + ): + _run( + database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) VALUES (?, ?, ?)", + (definition_id, window, due), + ) + ) + rows = _run( + database.fetch_all( + "SELECT window, due_time FROM quest_definition_windows " + "WHERE definition_id = ? ORDER BY window", + (definition_id,), + ) + ) + assert rows == [ + ("afternoon", None), + ("evening", "19:30"), + ("morning", "09:00"), + ] + finally: + _run(database.close()) + + +@pytest.mark.parametrize( + "window", ["Midnight", "MORNING", "noon", "", "evenings"] +) +def test_quest_definition_windows_window_check_enforced( + tmp_path, window +) -> None: + database = _open_db(tmp_path / "windows-check.db") + try: + _apply(database) + _insert_rule(database) + definition_id = _insert_definition(database, assignees=None) + with pytest.raises(sqlite3.IntegrityError, match="CHECK"): + _run( + database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) VALUES (?, ?, NULL)", + (definition_id, window), + ) + ) + finally: + _run(database.close()) + + +def test_quest_definition_windows_duplicate_window_fails(tmp_path) -> None: + database = _open_db(tmp_path / "windows-duplicate.db") + try: + _apply(database) + _insert_rule(database) + definition_id = _insert_definition(database, assignees=None) + _run( + database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) " + "VALUES (?, 'morning', NULL)", + (definition_id,), + ) + ) + with pytest.raises(sqlite3.IntegrityError): + _run( + database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) " + "VALUES (?, 'morning', '09:00')", + (definition_id,), + ) + ) + finally: + _run(database.close()) + + +def test_quest_definition_windows_unknown_definition_fails(tmp_path) -> None: + database = _open_db(tmp_path / "windows-bad-definition.db") + try: + _apply(database) + with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"): + _run( + database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) " + "VALUES (999, 'morning', NULL)" + ) + ) + finally: + _run(database.close()) + + +def test_quest_definition_assignees_multiple_children_allowed( + tmp_path, +) -> None: + database = _open_db(tmp_path / "assignees-multi.db") + try: + _apply(database) + for name in ("Ada", "Ben", "Cleo"): + _run( + database.execute( + "INSERT INTO children (display_name, created_at) " + "VALUES (?, ?)", + (name, "2026-09-13T00:00:00+00:00"), + ) + ) + _insert_rule(database) + definition_id = _insert_definition(database, assignees=(1, 2, 3)) + rows = _run( + database.fetch_all( + "SELECT child_id FROM quest_definition_assignees " + "WHERE definition_id = ? ORDER BY child_id", + (definition_id,), + ) + ) + assert [row[0] for row in rows] == [1, 2, 3] + finally: + _run(database.close()) + + +def test_quest_definitions_unknown_schedule_rule_id_fails(tmp_path) -> None: database = _open_db(tmp_path / "definitions-bad-rule.db") try: _apply(database) @@ -863,24 +1175,24 @@ def test_task_definitions_unknown_schedule_rule_id_fails(tmp_path) -> None: ) ) with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"): - _insert_definition(database, child_id=1, rule_id=999) + _insert_definition(database, rule_id=999) finally: _run(database.close()) -def test_task_definitions_is_active_check_enforced(tmp_path) -> None: +def test_quest_definitions_is_active_check_enforced(tmp_path) -> None: database = _open_db(tmp_path / "definitions-is-active.db") try: _apply(database) _insert_rule(database) with pytest.raises(sqlite3.IntegrityError, match="CHECK"): - _insert_definition(database, child_id=1, rule_id=1, is_active=2) + _insert_definition(database, rule_id=1, is_active=2) finally: _run(database.close()) @pytest.mark.parametrize("bad_is_active", ["abc", 1.5]) -def test_task_definitions_is_active_non_integer_fails( +def test_quest_definitions_is_active_non_integer_fails( tmp_path, bad_is_active ) -> None: database = _open_db(tmp_path / "definitions-is-active-typeof.db") @@ -895,37 +1207,37 @@ def test_task_definitions_is_active_non_integer_fails( _insert_rule(database) with pytest.raises(sqlite3.IntegrityError, match="CHECK"): _insert_definition( - database, child_id=1, rule_id=1, is_active=bad_is_active + database, rule_id=1, is_active=bad_is_active ) finally: _run(database.close()) -def test_task_definitions_title_not_null_enforced(tmp_path) -> None: +def test_quest_definitions_title_not_null_enforced(tmp_path) -> None: database = _open_db(tmp_path / "definitions-title.db") try: _apply(database) _insert_rule(database) with pytest.raises(sqlite3.IntegrityError, match="NOT NULL"): - _insert_definition(database, child_id=1, rule_id=1, title=None) + _insert_definition(database, rule_id=1, title=None) finally: _run(database.close()) -def test_task_definitions_created_at_not_null_enforced(tmp_path) -> None: +def test_quest_definitions_created_at_not_null_enforced(tmp_path) -> None: database = _open_db(tmp_path / "definitions-created-at.db") try: _apply(database) _insert_rule(database) with pytest.raises(sqlite3.IntegrityError, match="NOT NULL"): _insert_definition( - database, child_id=1, rule_id=1, created_at=None + database, rule_id=1, created_at=None ) finally: _run(database.close()) -def test_task_definitions_defaults_apply_on_insert(tmp_path) -> None: +def test_quest_definitions_defaults_apply_on_insert(tmp_path) -> None: database = _open_db(tmp_path / "definitions-defaults.db") try: _apply(database) @@ -937,11 +1249,11 @@ def test_task_definitions_defaults_apply_on_insert(tmp_path) -> None: ) _insert_rule(database) definition_id = _insert_definition( - database, child_id=1, rule_id=1, is_active=_OMIT + database, rule_id=1, is_active=_OMIT ) row = _run( database.fetch_one( - "SELECT is_active, due_time FROM task_definitions WHERE id = ?", + "SELECT is_active, due_time FROM quest_definitions WHERE id = ?", (definition_id,), ) ) @@ -966,10 +1278,10 @@ def test_applying_extended_ddl_twice_keeps_new_table_rows(tmp_path) -> None: ) ) _insert_rule(database) - _insert_definition(database, child_id=1, rule_id=1) + _insert_definition(database, rule_id=1) _apply(database) counts = [] - for table in ("children", "schedule_rules", "task_definitions"): + for table in ("children", "schedule_rules", "quest_definitions"): row = _run(database.fetch_one(f"SELECT COUNT(*) FROM {table}")) counts.append(row[0]) assert counts == [1, 1, 1] @@ -1406,12 +1718,12 @@ def test_applying_presence_ddl_twice_keeps_rows(tmp_path) -> None: # --------------------------------------------------------------------------- -# task_instances and completion_events: helpers +# quest_instances and completion_events: helpers # --------------------------------------------------------------------------- -def _insert_instance(database, definition_id=1, **overrides): - """Insert one valid task_instance, returning the instance id. +def _insert_instance(database, definition_id=1, window="morning", **overrides): + """Insert one valid quest_instance, returning the instance id. Pass ``_OMIT`` as a value to leave that column out of the INSERT so its DDL DEFAULT applies. @@ -1419,6 +1731,7 @@ def _insert_instance(database, definition_id=1, **overrides): values: dict[str, object] = { "definition_id": definition_id, "child_id": 1, + "window": window, "due_date": "2026-09-14", "generated_at": "2026-09-14T00:00:00+00:00", } @@ -1428,7 +1741,7 @@ def _insert_instance(database, definition_id=1, **overrides): placeholders = ", ".join("?" for _ in values) result = _run( database.execute( - f"INSERT INTO task_instances ({columns}) VALUES ({placeholders})", + f"INSERT INTO quest_instances ({columns}) VALUES ({placeholders})", tuple(values.values()), ) ) @@ -1466,28 +1779,28 @@ def _insert_event(database, instance_id=1, **overrides): def _setup_definition(database) -> None: - """Create one child, one schedule rule and one task definition.""" + """Create one child, one schedule rule and one quest definition.""" _child(database) _insert_rule(database) - _insert_definition(database, child_id=1, rule_id=1) + _insert_definition(database, rule_id=1) # --------------------------------------------------------------------------- -# task_instances and completion_events: DDL shape +# quest_instances and completion_events: DDL shape # --------------------------------------------------------------------------- -def test_task_instances_table_exists_after_applying_ddl(tmp_path) -> None: +def test_quest_instances_table_exists_after_applying_ddl(tmp_path) -> None: database = _open_db(tmp_path / "task-instances.db") try: _apply(database) row = _run( database.fetch_one( "SELECT name FROM sqlite_master WHERE type = 'table' " - "AND name = 'task_instances'" + "AND name = 'quest_instances'" ) ) - assert row is not None and row[0] == "task_instances" + assert row is not None and row[0] == "quest_instances" finally: _run(database.close()) @@ -1507,16 +1820,17 @@ def test_completion_events_table_exists_after_applying_ddl(tmp_path) -> None: _run(database.close()) -def test_task_instances_columns_types_and_constraints(tmp_path) -> None: +def test_quest_instances_columns_types_and_constraints(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-columns.db") try: _apply(database) - columns = _run(database.fetch_all("PRAGMA table_info(task_instances)")) + columns = _run(database.fetch_all("PRAGMA table_info(quest_instances)")) # cid, name, type, notnull, dflt_value, pk assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [ ("id", "INTEGER", 0, None, 1), ("definition_id", "INTEGER", 1, None, 0), ("child_id", "INTEGER", 1, None, 0), + ("window", "TEXT", 1, None, 0), ("due_date", "TEXT", 1, None, 0), ("due_time", "TEXT", 0, None, 0), ("generated_at", "TEXT", 1, None, 0), @@ -1537,6 +1851,7 @@ def test_completion_events_columns_types_and_constraints(tmp_path) -> None: ("event_type", "TEXT", 1, None, 0), ("actor_source", "TEXT", 1, None, 0), ("actor_user_id", "TEXT", 0, None, 0), + ("actor_child_id", "INTEGER", 0, None, 0), ("occurred_at", "TEXT", 1, None, 0), ("was_on_time", "INTEGER", 0, None, 0), ] @@ -1544,14 +1859,14 @@ def test_completion_events_columns_types_and_constraints(tmp_path) -> None: _run(database.close()) -def test_task_instances_foreign_keys_declared(tmp_path) -> None: +def test_quest_instances_foreign_keys_declared(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-fks.db") try: _apply(database) - fks = _run(database.fetch_all("PRAGMA foreign_key_list(task_instances)")) + fks = _run(database.fetch_all("PRAGMA foreign_key_list(quest_instances)")) declared = {(row[2], row[3], row[4]) for row in fks} assert declared == { - ("task_definitions", "definition_id", "id"), + ("quest_definitions", "definition_id", "id"), ("children", "child_id", "id"), } finally: @@ -1565,14 +1880,15 @@ def test_completion_events_foreign_keys_declared(tmp_path) -> None: fks = _run(database.fetch_all("PRAGMA foreign_key_list(completion_events)")) declared = {(row[2], row[3], row[4]) for row in fks} assert declared == { - ("task_instances", "instance_id", "id"), + ("quest_instances", "instance_id", "id"), ("children", "child_id", "id"), + ("children", "actor_child_id", "id"), } finally: _run(database.close()) -def test_task_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") try: _apply(database) @@ -1580,26 +1896,31 @@ def test_task_instances_unique_index_on_definition_and_date(tmp_path) -> None: rows = _run( database.fetch_all( "SELECT name, sql FROM sqlite_master WHERE type = 'index' " - "AND tbl_name = 'task_instances'" + "AND tbl_name = 'quest_instances'" ) ) implicit = [(name, sql) for name, sql in rows if sql is None] assert len(implicit) == 1, ( - "the (definition_id, due_date) UNIQUE constraint must be backed " - "by exactly one implicit unique index" + "the (definition_id, child_id, due_date, window) UNIQUE " + "constraint must be backed by exactly one implicit unique index" ) columns = _run( database.fetch_all( 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 # constraint SQLite backs with a sql-less autoindex on this table. row = _run( database.fetch_one( "SELECT COUNT(DISTINCT name) FROM pragma_index_list" - "('task_instances') WHERE \"unique\" = 1" + "('quest_instances') WHERE \"unique\" = 1" ) ) assert row == (1,) @@ -1608,11 +1929,11 @@ def test_task_instances_unique_index_on_definition_and_date(tmp_path) -> None: # --------------------------------------------------------------------------- -# task_instances: constraint enforcement +# quest_instances: constraint enforcement # --------------------------------------------------------------------------- -def test_task_instances_valid_insert_round_trips(tmp_path) -> None: +def test_quest_instances_valid_insert_round_trips(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-valid.db") try: _apply(database) @@ -1620,17 +1941,21 @@ def test_task_instances_valid_insert_round_trips(tmp_path) -> None: instance_id = _insert_instance(database, definition_id=1, child_id=1) row = _run( database.fetch_one( - "SELECT definition_id, child_id, due_date, due_time, generated_at " - "FROM task_instances WHERE id = ?", + "SELECT definition_id, child_id, window, due_date, " + "due_time, generated_at " + "FROM quest_instances WHERE 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: _run(database.close()) -def test_task_instances_due_time_round_trips(tmp_path) -> None: +def test_quest_instances_due_time_round_trips(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-due-time.db") try: _apply(database) @@ -1640,7 +1965,7 @@ def test_task_instances_due_time_round_trips(tmp_path) -> None: ) row = _run( database.fetch_one( - "SELECT due_time FROM task_instances WHERE id = ?", + "SELECT due_time FROM quest_instances WHERE id = ?", (instance_id,), ) ) @@ -1649,7 +1974,7 @@ def test_task_instances_due_time_round_trips(tmp_path) -> None: _run(database.close()) -def test_task_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") try: _apply(database) @@ -1661,7 +1986,79 @@ def test_task_instances_duplicate_definition_date_fails(tmp_path) -> None: _run(database.close()) -def test_task_instances_same_date_different_definitions_allowed( +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( tmp_path, ) -> None: database = _open_db(tmp_path / "task-instances-multi-def.db") @@ -1675,29 +2072,29 @@ def test_task_instances_same_date_different_definitions_allowed( ) ) rule_id = _insert_rule(database) - _insert_definition(database, child_id=2, rule_id=rule_id) + _insert_definition(database, rule_id=rule_id, assignees=(2,)) _insert_instance(database, definition_id=1, due_date="2026-09-14") _insert_instance(database, definition_id=2, due_date="2026-09-14") - row = _run(database.fetch_one("SELECT COUNT(*) FROM task_instances")) + row = _run(database.fetch_one("SELECT COUNT(*) FROM quest_instances")) assert row == (2,) finally: _run(database.close()) -def test_task_instances_same_definition_different_dates_allowed(tmp_path) -> None: +def test_quest_instances_same_definition_different_dates_allowed(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-multi-date.db") try: _apply(database) _setup_definition(database) _insert_instance(database, definition_id=1, due_date="2026-09-14") _insert_instance(database, definition_id=1, due_date="2026-09-15") - row = _run(database.fetch_one("SELECT COUNT(*) FROM task_instances")) + row = _run(database.fetch_one("SELECT COUNT(*) FROM quest_instances")) assert row == (2,) finally: _run(database.close()) -def test_task_instances_unknown_definition_fails(tmp_path) -> None: +def test_quest_instances_unknown_definition_fails(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-bad-def.db") try: _apply(database) @@ -1708,7 +2105,7 @@ def test_task_instances_unknown_definition_fails(tmp_path) -> None: _run(database.close()) -def test_task_instances_unknown_child_fails(tmp_path) -> None: +def test_quest_instances_unknown_child_fails(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-bad-child.db") try: _apply(database) @@ -1719,7 +2116,7 @@ def test_task_instances_unknown_child_fails(tmp_path) -> None: _run(database.close()) -def test_task_instances_due_date_not_null_enforced(tmp_path) -> None: +def test_quest_instances_due_date_not_null_enforced(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-due-null.db") try: _apply(database) @@ -1730,7 +2127,7 @@ def test_task_instances_due_date_not_null_enforced(tmp_path) -> None: _run(database.close()) -def test_task_instances_generated_at_not_null_enforced(tmp_path) -> None: +def test_quest_instances_generated_at_not_null_enforced(tmp_path) -> None: database = _open_db(tmp_path / "task-instances-generated-null.db") try: _apply(database) @@ -1787,16 +2184,71 @@ def test_completion_events_valid_panel_completion_round_trips(tmp_path) -> None: instance_id=instance_id, actor_source="panel", actor_user_id=None, + actor_child_id=1, was_on_time=0, ) row = _run( database.fetch_one( - "SELECT actor_source, actor_user_id, was_on_time " - "FROM completion_events WHERE id = ?", + "SELECT actor_source, actor_user_id, actor_child_id, " + "was_on_time FROM completion_events WHERE 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: _run(database.close()) @@ -2026,8 +2478,8 @@ def test_applying_full_ddl_twice_keeps_rows(tmp_path) -> None: for table in ( "children", "schedule_rules", - "task_definitions", - "task_instances", + "quest_definitions", + "quest_instances", "completion_events", ): row = _run(database.fetch_one(f"SELECT COUNT(*) FROM {table}")) diff --git a/tests/test_startup_db.py b/tests/test_startup_db.py index f6620b5..da77d61 100644 --- a/tests/test_startup_db.py +++ b/tests/test_startup_db.py @@ -18,6 +18,7 @@ from custom_components.nestquest import ( ) from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.migrations import ( + MIGRATIONS, VERSION_TABLE, apply_migrations, ) @@ -114,12 +115,13 @@ def test_missing_file_opens_fresh(tmp_path) -> None: assert not db_path.exists() database = _run(_async_open_database(_make_hass_mock(), db_path)) try: - # The file was created fresh and migrations stamped version 1. + # The file was created fresh and migrations stamped the + # latest schema version. assert db_path.exists() row = _run( database.fetch_one(f"SELECT version FROM {VERSION_TABLE}") ) - assert row == (1,) + assert row == (len(MIGRATIONS),) finally: _run(database.close()) @@ -151,7 +153,7 @@ def test_valid_file_opens_and_migrations_noop(tmp_path) -> None: row = _run( database.fetch_one(f"SELECT version FROM {VERSION_TABLE}") ) - assert row == (1,) + assert row == (len(MIGRATIONS),) finally: _run(database.close()) # Opening a valid file must not rewrite it (the no-op path). @@ -401,7 +403,7 @@ async def test_setup_with_missing_file_creates_and_loads( row = await database.fetch_one( f"SELECT version FROM {VERSION_TABLE}" ) - assert row == (1,) + assert row == (len(MIGRATIONS),) finally: await database.close() assert await async_unload_entry(hass, entry) is True diff --git a/uv.lock b/uv.lock index 13bd8ec..164d325 100644 --- a/uv.lock +++ b/uv.lock @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "nestquest" -version = "0.3.0" +version = "0.4.0" source = { virtual = "." } [package.dev-dependencies]