diff --git a/custom_components/nestquest/dao_rules.py b/custom_components/nestquest/dao_rules.py index d5090ee..5669368 100644 --- a/custom_components/nestquest/dao_rules.py +++ b/custom_components/nestquest/dao_rules.py @@ -40,6 +40,7 @@ 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 +from .recurrence import RuleType, RuleValidationError, ScheduleRule #: Sentinel distinguishing "argument omitted" from "explicit SQL NULL" #: in update methods: passing ``None`` must mean clearing a nullable @@ -119,6 +120,26 @@ class ScheduleRuleRecord: end_date: str | None +@dataclass(frozen=True) +class ScheduleRuleStorage: + """The eight value columns a ScheduleRule maps to (no id column). + + ``id`` is assigned by the database on insert, so the model<->storage + mapping concerns only the value columns. ``nth_weekday_weekday`` is + deliberately absent here: for MONTHLY_WEEKDAY its value is folded + into ``weekday_set`` as that column's single CSV element. + """ + + rule_type: str + interval: int + weekday_set: str | None + day_of_month: int | None + nth_weekday: int | None + month: int | None + start_date: str + end_date: str | None + + @dataclass(frozen=True) class QuestDefinitionRecord: """One row of ``quest_definitions`` (without its assignees).""" @@ -142,6 +163,24 @@ class QuestDefinitionWindowRecord: due_time: str | None +@dataclass(frozen=True) +class QuestDefinitionSnapshot: + """A definition plus its rule, assignees and windows, read atomically. + + Returned by :meth:`QuestDefinitionsDao.create_with_rule_and_windows` + and :meth:`QuestDefinitionsDao.edit_definition` with the rule, + assignees and windows read inside the SAME transaction that wrote + them, so the snapshot is a consistent creation/edit-time view — a + concurrent rule, assignee or window mutation cannot change it + between the write and the read. + """ + + definition: QuestDefinitionRecord + rule: ScheduleRuleRecord + assignees: list[ChildRecord] + windows: list[QuestDefinitionWindowRecord] + + _RULE_COLUMNS = ( "id, rule_type, interval, weekday_set, day_of_month, nth_weekday, " "month, start_date, end_date" @@ -188,6 +227,250 @@ def _window_from_row(row: tuple) -> QuestDefinitionWindowRecord: ) +#: Storage columns that are forbidden for each rule type. A populated +#: forbidden column means the row's shape is invalid and must raise on +#: read, never be silently dropped. ``weekday_set`` is absent for +#: DAILY/MONTHLY_DAY/YEARLY; MONTHLY_WEEKDAY is the only shape allowed a +#: non-NULL ``nth_weekday``; YEARLY is the only shape allowed ``month``. +_FORBIDDEN_STORAGE_FIELDS: dict[str, tuple[RuleType, ...]] = { + "weekday_set": (RuleType.DAILY, RuleType.MONTHLY_DAY, RuleType.YEARLY), + "day_of_month": ( + RuleType.DAILY, + RuleType.WEEKLY, + RuleType.CUSTOM_DAYS, + RuleType.MONTHLY_WEEKDAY, + ), + "nth_weekday": ( + RuleType.DAILY, + RuleType.WEEKLY, + RuleType.CUSTOM_DAYS, + RuleType.MONTHLY_DAY, + RuleType.YEARLY, + ), + "month": ( + RuleType.DAILY, + RuleType.WEEKLY, + RuleType.CUSTOM_DAYS, + RuleType.MONTHLY_DAY, + RuleType.MONTHLY_WEEKDAY, + ), +} + + +def schedule_rule_to_storage(rule: ScheduleRule) -> ScheduleRuleStorage: + """Map a validated ScheduleRule onto its storage columns. + + ``rule_type`` stores as ``RuleType.storage_value`` (MONTHLY_DAY and + MONTHLY_WEEKDAY both store ``'monthly'``); ``weekday_set`` stores as + a sorted CSV list. MONTHLY_WEEKDAY folds its model-only + ``nth_weekday_weekday`` into ``weekday_set`` as that list's single + element; every other type leaves the column as its own set + (WEEKLY/CUSTOM_DAYS) or NULL. + """ + shape = rule.rule_type + if shape is RuleType.MONTHLY_WEEKDAY: + weekday_set = str(rule.nth_weekday_weekday) + elif shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS: + weekday_set = ",".join(str(w) for w in sorted(rule.weekday_set)) + else: + weekday_set = None + return ScheduleRuleStorage( + rule_type=shape.storage_value, + interval=rule.interval, + weekday_set=weekday_set, + day_of_month=rule.day_of_month, + nth_weekday=rule.nth_weekday, + month=rule.month, + start_date=rule.start_date, + end_date=rule.end_date, + ) + + +def schedule_rule_from_storage(storage: ScheduleRuleStorage) -> ScheduleRule: + """Reconstruct a ScheduleRule from storage columns. + + Disambiguates MONTHLY_DAY from MONTHLY_WEEKDAY by which field is + populated, and raises RuleValidationError rather than silently + mis-decoding an ambiguous or invalid shape. + """ + rule_type = storage.rule_type + if rule_type == "daily": + shape = RuleType.DAILY + elif rule_type == "weekly": + shape = RuleType.WEEKLY + elif rule_type == "monthly": + shape = _disambiguate_monthly(storage) + elif rule_type == "yearly": + shape = RuleType.YEARLY + elif rule_type == "custom": + shape = RuleType.CUSTOM_DAYS + else: + raise RuleValidationError( + f"unknown storage rule_type {rule_type!r}" + ) + + _reject_forbidden_storage_fields(storage, shape) + + weekday_set = None + nth_weekday_weekday = None + if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS: + weekday_set = _weekdays_from_csv(storage.weekday_set) + elif shape is RuleType.MONTHLY_WEEKDAY: + nth_weekday_weekday = _single_weekday_from_csv(storage.weekday_set) + + return ScheduleRule( + rule_type=shape, + interval=storage.interval, + weekday_set=weekday_set, + day_of_month=storage.day_of_month, + nth_weekday=storage.nth_weekday, + nth_weekday_weekday=nth_weekday_weekday, + month=storage.month, + start_date=storage.start_date, + end_date=storage.end_date, + ) + + +def schedule_rule_storage_from_record( + record: ScheduleRuleRecord, +) -> ScheduleRuleStorage: + """Copy a rule row's value columns into a storage mapping. + + ``ScheduleRuleRecord`` is the read shape (it carries ``id``); + :func:`schedule_rule_from_storage` consumes the value-only + :class:`ScheduleRuleStorage` shape, so callers re-reading a stored + rule convert through this helper. + """ + return ScheduleRuleStorage( + rule_type=record.rule_type, + interval=record.interval, + weekday_set=record.weekday_set, + day_of_month=record.day_of_month, + nth_weekday=record.nth_weekday, + month=record.month, + start_date=record.start_date, + end_date=record.end_date, + ) + + +def _reject_forbidden_storage_fields( + storage: ScheduleRuleStorage, shape: RuleType +) -> None: + """Raise unless every populated column is permitted for ``shape``. + + The model constructor rejects a forbidden ``day_of_month``, + ``nth_weekday``, or ``month``, but ``weekday_set`` never reaches it + (it is folded for MONTHLY_WEEKDAY and dropped otherwise), so a daily + row carrying ``weekday_set="0"`` would otherwise decode as a valid + daily rule and silently lose the stored data. This validates every + shape column explicitly so a populated-forbidden column raises + instead of being discarded. + """ + for field, forbidden_shapes in _FORBIDDEN_STORAGE_FIELDS.items(): + value = getattr(storage, field) + if value is not None and shape in forbidden_shapes: + raise RuleValidationError( + f"{shape.value} storage rows must not set {field}, " + f"got {value!r}" + ) + + +def _disambiguate_monthly(storage: ScheduleRuleStorage) -> RuleType: + """Resolve the shared 'monthly' storage value to a model type. + + MONTHLY_DAY names ``day_of_month`` only; MONTHLY_WEEKDAY names + ``nth_weekday`` only. Neither, or both, is ambiguous and raises. + """ + day_of_month = storage.day_of_month + nth_weekday = storage.nth_weekday + if day_of_month is not None and nth_weekday is None: + return RuleType.MONTHLY_DAY + if nth_weekday is not None and day_of_month is None: + return RuleType.MONTHLY_WEEKDAY + raise RuleValidationError( + "storage rule_type 'monthly' is ambiguous: must name exactly one " + "of day_of_month or nth_weekday, got " + f"day_of_month={day_of_month!r}, nth_weekday={nth_weekday!r}" + ) + + +def _weekdays_from_csv(value: str | None) -> frozenset[int]: + """Parse a weekday CSV column into a frozenset of ints. + + ``None`` and ``""`` both yield an empty frozenset (the caller decides + whether empty is legal); a non-integer token raises + RuleValidationError rather than leaking a raw ValueError. + """ + if value is None or value == "": + return frozenset() + try: + entries = [int(token) for token in value.split(",")] + except ValueError: + raise RuleValidationError( + f"weekday_set must be a CSV of integers, got {value!r}" + ) from None + return frozenset(entries) + + +def _single_weekday_from_csv(value: str | None) -> int: + """The one weekday a MONTHLY_WEEKDAY stores in weekday_set. + + Parses the CSV into tokens FIRST and requires exactly one token, + so a duplicate pair like ``"1,1"`` is rejected rather than silently + canonicalized to a single weekday on re-storage. An empty or + multi-element column raises RuleValidationError. + """ + if value is None or value == "": + raise RuleValidationError( + "MONTHLY_WEEKDAY storage weekday_set must hold exactly one " + f"weekday, got {value!r}" + ) + tokens = value.split(",") + if len(tokens) != 1: + raise RuleValidationError( + "MONTHLY_WEEKDAY storage weekday_set must hold exactly one " + f"weekday, got {value!r}" + ) + try: + return int(tokens[0]) + except ValueError: + raise RuleValidationError( + "MONTHLY_WEEKDAY storage weekday_set must be an integer, " + f"got {value!r}" + ) from None + + +async def _insert_rule_row( + database: NestQuestDatabase, storage: ScheduleRuleStorage +) -> int: + """Insert one ``schedule_rules`` row and return its new id. + + Runs inside the caller's transaction (no lock or transaction of its + own) so a multi-table write can persist the rule together with the + rows that reference it. The caller is responsible for validating + the storage fields — :meth:`ScheduleRulesDao.create` validates the + date shape, and :meth:`QuestDefinitionsDao.create_with_rule_and_windows` + receives storage already mapped from a validated + :class:`~.recurrence.ScheduleRule`. + """ + result = await database.execute( + "INSERT INTO schedule_rules (rule_type, interval, weekday_set, " + "day_of_month, nth_weekday, month, start_date, end_date) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + storage.rule_type, + storage.interval, + storage.weekday_set, + storage.day_of_month, + storage.nth_weekday, + storage.month, + storage.start_date, + storage.end_date, + ), + ) + return result.lastrowid + + class ScheduleRulesDao: """Typed async access to the ``schedule_rules`` table.""" @@ -218,22 +501,20 @@ class ScheduleRulesDao: _validate_date(start_date, "start_date") if end_date is not None: _validate_date(end_date, "end_date") - result = await self._database.execute( - "INSERT INTO schedule_rules (rule_type, interval, weekday_set, " - "day_of_month, nth_weekday, month, start_date, end_date) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - ( - rule_type, - interval, - weekday_set, - day_of_month, - nth_weekday, - month, - start_date, - end_date, + rule_id = await _insert_rule_row( + self._database, + ScheduleRuleStorage( + rule_type=rule_type, + interval=interval, + weekday_set=weekday_set, + day_of_month=day_of_month, + nth_weekday=nth_weekday, + month=month, + start_date=start_date, + end_date=end_date, ), ) - rule = await self.get(result.lastrowid) + rule = await self.get(rule_id) assert rule is not None return rule @@ -393,6 +674,191 @@ class QuestDefinitionsDao: assert definition is not None return definition + async def create_with_rule_and_windows( + self, + title: str, + schedule_rule: ScheduleRuleStorage, + created_at: str, + assignee_child_ids: list[int], + windows: list[tuple[str, str | None]], + *, + description: str | None = None, + icon: str | None = None, + ) -> QuestDefinitionSnapshot: + """Insert a rule, definition, assignees and windows atomically. + + The whole write — the schedule rule row, the definition row, + every assignee link and every window declaration — runs inside + ONE transaction under the connection lock, so a validation or + write failure rolls the lot back together (all-or-nothing): the + caller can never observe a definition with a missing rule, + assignee or window, and a rejected create leaves no partial + rows. + + Returns a :class:`QuestDefinitionSnapshot` whose assignees and + windows are read back inside the same transaction, so the + returned view cannot be raced by a concurrent assignee/window + mutation after the write. + + The rule arrives as pre-validated storage fields (mapped from a + :class:`~.recurrence.ScheduleRule` via + :func:`schedule_rule_to_storage`); its dates are re-validated + here so a hand-built storage can never persist a malformed date. + The definition-level ``due_time`` column is deliberately left + NULL — per-window due times supersede it (D-008). Assignees are + validated as existing-and-active at insert time, and windows are + validated for name and strict HH:MM due time, matching + :meth:`upsert_window`. + """ + _validate_date(schedule_rule.start_date, "start_date") + if schedule_rule.end_date is not None: + _validate_date(schedule_rule.end_date, "end_date") + async with _connection_lock(self._database): + async with self._database.transaction(): + for child_id in assignee_child_ids: + await self._validate_assignable_child(child_id) + rule_id = await _insert_rule_row(self._database, schedule_rule) + result = await self._database.execute( + "INSERT INTO quest_definitions (title, description, " + "icon, schedule_rule_id, due_time, is_active, " + "created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (title, description, icon, rule_id, None, 1, created_at), + ) + definition_id = result.lastrowid + for child_id in assignee_child_ids: + await self._database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (?, ?)", + (definition_id, child_id), + ) + for window, due_time in windows: + _validate_window(window) + if due_time is not None: + _validate_time(due_time, "due_time") + await self._database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) VALUES (?, ?, ?)", + (definition_id, window, due_time), + ) + definition = await self.get(definition_id) + assignees = await self.list_assignees(definition_id) + window_records = await self.list_windows(definition_id) + rule_record = await ScheduleRulesDao(self._database).get( + rule_id + ) + assert definition is not None + assert rule_record is not None + return QuestDefinitionSnapshot( + definition=definition, + rule=rule_record, + assignees=assignees, + windows=window_records, + ) + + async def edit_definition( + self, + definition_id: int, + *, + title: str | None | object = _UNSET, + description: str | None | object = _UNSET, + icon: str | None | object = _UNSET, + rule: ScheduleRuleStorage | object = _UNSET, + windows: list[tuple[str, str | None]] | object = _UNSET, + ) -> QuestDefinitionSnapshot: + """Edit a definition's metadata, rule and windows atomically. + + Mirrors :meth:`create_with_rule_and_windows`: everything runs + inside ONE transaction under the connection lock, and the + returned snapshot's rule, assignees and windows are read back + inside that transaction. Arguments default to the module sentinel + ``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL + NULL so optional metadata (description, icon) can be cleared. + + - ``title``/``description``/``icon`` update the definition row + in place; the definition-level ``due_time`` column is never + written (per-window due times supersede it, D-008). + - ``rule`` (a pre-validated :class:`ScheduleRuleStorage`) + overwrites the referenced ``schedule_rules`` row in place: the + definition keeps its ``schedule_rule_id``, so no new or + orphaned rule row appears. + - ``windows`` REPLACES the whole window set: the current rows + are deleted and the given set re-inserted, so missing windows + are added, changed due times updated and removed windows + dropped. + + Assignment is deliberately not settable here (multi-assignee is + managed by :meth:`add_assignee`/:meth:`remove_assignee`), and + activation is untouched. Raises ValueError when the definition + does not exist. Edits change future instances only: no + ``quest_instances`` or ``completion_events`` row is touched. + """ + async with _connection_lock(self._database): + async with self._database.transaction(): + definition = await self.get(definition_id) + if definition is None: + raise ValueError( + f"quest definition {definition_id} does not exist" + ) + assignments: list[str] = [] + parameters: list[object] = [] + for column, value in ( + ("title", title), + ("description", description), + ("icon", icon), + ): + if value is not _UNSET: + assignments.append(f"{column} = ?") + parameters.append(value) + if assignments: + parameters.append(definition_id) + await self._database.execute( + f"UPDATE quest_definitions SET " + f"{', '.join(assignments)} WHERE id = ?", + tuple(parameters), + ) + if rule is not _UNSET: + await ScheduleRulesDao(self._database).update( + definition.schedule_rule_id, + rule_type=rule.rule_type, + interval=rule.interval, + weekday_set=rule.weekday_set, + day_of_month=rule.day_of_month, + nth_weekday=rule.nth_weekday, + month=rule.month, + start_date=rule.start_date, + end_date=rule.end_date, + ) + if windows is not _UNSET: + await self._database.execute( + "DELETE FROM quest_definition_windows " + "WHERE definition_id = ?", + (definition_id,), + ) + for window, due_time in windows: + _validate_window(window) + if due_time is not None: + _validate_time(due_time, "due_time") + await self._database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) " + "VALUES (?, ?, ?)", + (definition_id, window, due_time), + ) + updated = await self.get(definition_id) + assignees = await self.list_assignees(definition_id) + window_records = await self.list_windows(definition_id) + rule_record = await ScheduleRulesDao(self._database).get( + updated.schedule_rule_id + ) + assert updated is not None + assert rule_record is not None + return QuestDefinitionSnapshot( + definition=updated, + rule=rule_record, + assignees=assignees, + windows=window_records, + ) + async def _validate_rule_exists(self, schedule_rule_id: int) -> None: """Raise ValueError unless the rule exists. @@ -441,6 +907,66 @@ class QuestDefinitionsDao: ) return [_definition_from_row(row) for row in rows] + async def _snapshots_for( + self, definitions: list[QuestDefinitionRecord] + ) -> list[QuestDefinitionSnapshot]: + """Read each definition's rule, assignees and windows. + + MUST be called inside the connection lock (and ideally a + transaction) so the definitions handed in and the rows this + reads back cannot be raced by a concurrent edit between list + and bundle. Each rule is read through + :class:`ScheduleRulesDao` on the same connection, so the + snapshot's rule record is the one the definition references AT + the moment of the locked read. + """ + rules_dao = ScheduleRulesDao(self._database) + snapshots: list[QuestDefinitionSnapshot] = [] + for definition in definitions: + rule_record = await rules_dao.get(definition.schedule_rule_id) + assert rule_record is not None + snapshots.append( + QuestDefinitionSnapshot( + definition=definition, + rule=rule_record, + assignees=await self.list_assignees(definition.id), + windows=await self.list_windows(definition.id), + ) + ) + return snapshots + + async def list_snapshots_active(self) -> list[QuestDefinitionSnapshot]: + """Return every active definition with its rule, assignees and + windows, all read inside one locked transaction. + + The definitive list and each definition's linked rows are read + in a single BEGIN..COMMIT span under the connection lock, so a + concurrent atomic edit cannot interleave between the definition + list and the bundle read: the caller receives a coherent + snapshot (each definition's rule, assignees and windows as they + were at one instant), never a mixed old-rule/new-windows state. + """ + async with _connection_lock(self._database): + async with self._database.transaction(): + definitions = await self.list_active() + return await self._snapshots_for(definitions) + + async def list_snapshots_by_child( + self, child_id: int + ) -> list[QuestDefinitionSnapshot]: + """Return each definition assigned to ``child_id`` with its rule, + assignees and windows, all read inside one locked transaction. + + Mirrors :meth:`list_snapshots_active` but selects by assignment + (``list_by_child`` order: newest first). Locks read-and-bundle + into one transaction so the bundle never mixes one edit's + definition with another's windows. + """ + async with _connection_lock(self._database): + async with self._database.transaction(): + definitions = await self.list_by_child(child_id) + return await self._snapshots_for(definitions) + async def update( self, definition_id: int, @@ -496,14 +1022,18 @@ class QuestDefinitionsDao: async def add_assignee( self, definition_id: int, child_id: int - ) -> None: + ) -> list[ChildRecord]: """Assign ``child_id`` to the definition (idempotent). 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 + composite primary key's storage-level guarantee. Returns the + definition's assignees read back INSIDE the same transaction + that performed the write, so the returned roster is a + consistent post-assignment snapshot — a concurrent assign or + unassign cannot race it after the write commits. Changes future instances only: already-generated instances and completion history are never rewritten here. """ @@ -524,6 +1054,7 @@ class QuestDefinitionsDao: "ON CONFLICT (definition_id, child_id) DO NOTHING", (definition_id, child_id), ) + return await self.list_assignees(definition_id) async def remove_assignee( self, definition_id: int, child_id: int diff --git a/custom_components/nestquest/quest_definitions.py b/custom_components/nestquest/quest_definitions.py new file mode 100644 index 0000000..ca38322 --- /dev/null +++ b/custom_components/nestquest/quest_definitions.py @@ -0,0 +1,551 @@ +"""Business layer for creating and editing quest definitions (Feature 06). + +Sits between the Feature 09 service gate and the typed +:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the +all-or-nothing create/edit here, and never touch the DAO SQL directly. +:func:`create_quest_definition` is the create path, +:func:`edit_quest_definition` the edit path, :func:`assign_child` and +:func:`unassign_child` the assignment paths, and +:func:`set_quest_definition_active` the deactivate/reactivate path. +There is no permission check here (that is Feature 09). + +Validation policy mirrors :mod:`.children`: + +- ``title`` is required and trimmed; empty or whitespace-only raises + ValueError. +- ``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule` + (its constructor is total — rule validation is NOT re-implemented + here). +- ``assignee_child_ids`` must be a non-empty list (strictly a ``list``); + every id is a plain int (bools and floats rejected, since SQLite would + bind them onto a real child) and must reference an existing ACTIVE + child (the DAO enforces the active/exists check inside the same + transaction). Duplicate ids are rejected here, before the composite + primary key could surface them as an :class:`sqlite3.IntegrityError`. +- ``windows`` must be a non-empty list (strictly a ``list``); each entry + is a window name from :data:`~.const.QUEST_WINDOWS` — either a bare + name (no due time) or a ``(name, due_time)`` pair whose due time is a + strict 24-hour HH:MM string. Duplicate window names are rejected + here, before the composite primary key could surface them as an + :class:`sqlite3.IntegrityError`. + +The rule, definition, assignees and windows are persisted in ONE +transaction (the DAO's :meth:`~.dao_rules.QuestDefinitionsDao.create_with_rule_and_windows`), +so a rejected or failed create persists nothing. The definition-level +``due_time`` column is superseded by per-window due times (D-008) and is +never set here. ``created_at`` is stamped UTC ISO-8601 at second +precision, matching :mod:`.children`. +""" +from __future__ import annotations + +import datetime +from dataclasses import dataclass + +from .dao_children import ChildRecord, _connection_lock +from .dao_rules import ( + QuestDefinitionRecord, + QuestDefinitionSnapshot, + QuestDefinitionWindowRecord, + QuestDefinitionsDao, + ScheduleRuleStorage, + ScheduleRulesDao, + _UNSET, + _validate_date, + _validate_time, + _validate_window, + schedule_rule_from_storage, + schedule_rule_storage_from_record, + schedule_rule_to_storage, +) +from .db import NestQuestDatabase +from .recurrence import ScheduleRule, occurs_on + + +@dataclass(frozen=True) +class CreatedQuestDefinition: + """The persisted result of a create, edit or activation change. + + ``definition`` is the stored row; ``rule`` is the schedule rule + decoded back through the storage mapping; ``assignees`` and + ``windows`` are the definition's linked child records and window + declarations, read through the DAO. + """ + + definition: QuestDefinitionRecord + rule: ScheduleRule + assignees: list[ChildRecord] + windows: list[QuestDefinitionWindowRecord] + + +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. + """ + 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_assignee_ids(value: object) -> list[int]: + """Return the assignee ids as plain ints, or raise naming the field. + + The argument must be a non-empty list — strictly a ``list``: tuples + and other sequences are rejected. Each entry must be a real int + (bools and floats rejected) so SQLite cannot bind ``True`` onto + child 1 or a float onto a neighbouring profile. + """ + if type(value) is not list: + raise ValueError( + f"assignee_child_ids must be a list, got {type(value).__name__}" + ) + if not value: + raise ValueError("assignee_child_ids must not be empty") + ids: list[int] = [] + for entry in value: + if isinstance(entry, bool) or not isinstance(entry, int): + raise ValueError( + "assignee_child_ids must contain integer child ids, " + f"got {entry!r}" + ) + ids.append(entry) + if len(set(ids)) != len(ids): + raise ValueError( + "assignee_child_ids must not contain duplicate child ids" + ) + return ids + + +def _normalize_windows( + value: object, +) -> list[tuple[str, str | None]]: + """Normalize the windows argument to ``(name, due_time)`` pairs. + + The outer argument must be a non-empty list — strictly a ``list``: + tuples and other sequences are rejected, while each individual entry + may still be a ``(window, due_time)`` tuple. A bare window name + (due time None) is also accepted. The name must be a + :data:`~.const.QUEST_WINDOWS` spelling and a present due time must be + a strict 24-hour HH:MM, both validated up front so a malformed + window is rejected before any write. + """ + if type(value) is not list: + raise ValueError( + f"windows must be a list, got {type(value).__name__}" + ) + if not value: + raise ValueError("windows must not be empty") + normalized: list[tuple[str, str | None]] = [] + seen: set[str] = set() + for index, entry in enumerate(value): + if isinstance(entry, str): + name: object = entry + due_time: object = None + elif isinstance(entry, (tuple, list)) and len(entry) == 2: + name, due_time = entry[0], entry[1] + else: + raise ValueError( + f"windows[{index}] must be a window name or a " + f"(name, due_time) pair, got {entry!r}" + ) + _validate_window(name) + if due_time is not None: + _validate_time(due_time, "due_time") + if name in seen: + raise ValueError( + f"windows must not contain duplicate window {name!r}" + ) + seen.add(name) + normalized.append((name, due_time)) + return normalized + + +def _validate_definition_id(value: object) -> int: + """Reject non-int definition ids (bools included) before any lookup. + + SQLite binds Python bools as integers, so ``True`` would silently + address definition 1 and a float would round — malformed service + input must never mutate the wrong definition. + """ + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"definition_id 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 create_quest_definition( + database: NestQuestDatabase, + title: str, + rule: ScheduleRule, + assignee_child_ids: list[int], + windows: list[str | tuple[str, str | None]], + *, + description: str | None = None, + icon: str | None = None, +) -> CreatedQuestDefinition: + """Create a quest definition (rule + assignees + windows) atomically. + + Validates the arguments up front, then persists the schedule rule, + the definition, its assignees and its windows in one transaction via + the DAO. Returns the stored definition together with its decoded + rule, assignees and windows — all read back inside that same + transaction, so the returned bundle is a consistent creation + snapshot. Raises ValueError on any rejected argument; a rejected or + failed create persists nothing. + """ + name = _validate_text(title, "title", required=True) + assert name is not None + if not isinstance(rule, ScheduleRule): + raise ValueError(f"rule must be a ScheduleRule, got {rule!r}") + assignee_ids = _validate_assignee_ids(assignee_child_ids) + window_specs = _normalize_windows(windows) + description_value = _validate_text(description, "description") + icon_value = _validate_text(icon, "icon") + + storage = schedule_rule_to_storage(rule) + dao = QuestDefinitionsDao(database) + try: + snapshot = await dao.create_with_rule_and_windows( + name, + storage, + _now_stamp(), + assignee_ids, + window_specs, + description=description_value, + icon=icon_value, + ) + except ValueError as error: + # The DAO's assignee check names only the child; re-raise so the + # public contract names the field the caller actually passed. + message = str(error) + if message.startswith("child "): + raise ValueError( + f"assignee_child_ids: {message}" + ) from error + raise + decoded_rule = schedule_rule_from_storage(storage) + return CreatedQuestDefinition( + definition=snapshot.definition, + rule=decoded_rule, + assignees=snapshot.assignees, + windows=snapshot.windows, + ) + + +async def edit_quest_definition( + database: NestQuestDatabase, + definition_id: int, + *, + title: str | object = _UNSET, + description: str | None | object = _UNSET, + icon: str | None | object = _UNSET, + rule: ScheduleRule | object = _UNSET, + windows: list[str | tuple[str, str | None]] | object = _UNSET, +) -> CreatedQuestDefinition: + """Edit a quest definition's metadata, rule and windows atomically. + + Arguments default to the sentinel ``_UNSET`` meaning "leave this + field alone". A provided ``title`` is validated like create's + (required and trimmed); ``description`` and ``icon`` are optional + and may be cleared by passing ``None`` explicitly. A provided + ``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule` + and REPLACES the definition's schedule rule IN PLACE — the existing + ``schedule_rules`` row is updated, never orphaned or duplicated. A + provided ``windows`` list REPLACES the whole window set (each entry + a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due + time), matching create's non-empty contract. Assignment is NOT + settable here. Raises ValueError when the definition does not + exist or any argument is rejected. + + Returns the updated definition together with its decoded rule, + assignees and windows — all read back inside the DAO's transaction, + so the returned bundle is a consistent edit snapshot. Edits change + future instances only; no ``quest_instances`` or + ``completion_events`` row is touched. + """ + _validate_definition_id(definition_id) + + title_value: str | None | object = _UNSET + if title is not _UNSET: + name = _validate_text(title, "title", required=True) + assert name is not None + title_value = name + + description_value: str | None | object = _UNSET + if description is not _UNSET: + description_value = _validate_text(description, "description") + + icon_value: str | None | object = _UNSET + if icon is not _UNSET: + icon_value = _validate_text(icon, "icon") + + rule_storage: ScheduleRuleStorage | object = _UNSET + if rule is not _UNSET: + if not isinstance(rule, ScheduleRule): + raise ValueError(f"rule must be a ScheduleRule, got {rule!r}") + rule_storage = schedule_rule_to_storage(rule) + + window_specs: list[tuple[str, str | None]] | object = _UNSET + if windows is not _UNSET: + window_specs = _normalize_windows(windows) + + dao = QuestDefinitionsDao(database) + try: + snapshot = await dao.edit_definition( + definition_id, + title=title_value, + description=description_value, + icon=icon_value, + rule=rule_storage, + windows=window_specs, + ) + except ValueError as error: + # The DAO's missing-definition error names only the id; re-raise + # so the public contract names the field the caller passed (matching + # assign/unassign/deactivate). + message = str(error) + if message.startswith("quest definition "): + raise ValueError(f"definition_id: {message}") from error + raise + + decoded_rule = schedule_rule_from_storage( + schedule_rule_storage_from_record(snapshot.rule) + ) + + return CreatedQuestDefinition( + definition=snapshot.definition, + rule=decoded_rule, + assignees=snapshot.assignees, + windows=snapshot.windows, + ) + + +async def assign_child( + database: NestQuestDatabase, + definition_id: int, + child_id: int, +) -> list[ChildRecord]: + """Assign ``child_id`` to the definition (idempotent). + + Validates that both ids are plain ints (bools and floats rejected) + and that the child exists and is active — the DAO enforces the + active/exists check inside its transaction, and also confirms the + definition exists. Assigning an already-assigned child is a no-op + (no duplicate row). Returns the definition's assignees read back + inside the DAO's same transaction, a consistent post-assignment + roster (a concurrent assign/unassign cannot race it after the + write). Raises ValueError on any rejected argument. Only the + ``quest_definition_assignees`` link is written; existing instances + and completion history are never touched. + """ + _validate_definition_id(definition_id) + _validate_child_id(child_id) + dao = QuestDefinitionsDao(database) + try: + return await dao.add_assignee(definition_id, child_id) + except ValueError as error: + # The DAO names only the child/definition; re-raise so the + # public contract names the field the caller actually passed. + message = str(error) + if message.startswith("child "): + raise ValueError(f"child_id: {message}") from error + if message.startswith("quest definition "): + raise ValueError(f"definition_id: {message}") from error + raise + + +async def unassign_child( + database: NestQuestDatabase, + definition_id: int, + child_id: int, +) -> bool: + """Unassign ``child_id`` from the definition; True when removed. + + Validates both ids are plain ints (bools and floats rejected) and + that the definition exists, then removes the (definition, child) + link. Returns True when a row was removed and False when the child + was not currently assigned. Raises ValueError on a rejected + argument. Only the ``quest_definition_assignees`` link is written; + existing instances and completion history are never touched. + """ + _validate_definition_id(definition_id) + _validate_child_id(child_id) + dao = QuestDefinitionsDao(database) + definition = await dao.get(definition_id) + if definition is None: + raise ValueError( + f"definition_id: quest definition {definition_id} does not exist" + ) + return await dao.remove_assignee(definition_id, child_id) + + +async def set_quest_definition_active( + database: NestQuestDatabase, + definition_id: int, + is_active: bool, +) -> CreatedQuestDefinition: + """Deactivate or reactivate a definition; returns the updated view. + + Flipping the flag is the only removal path (Feature 06 guardrail): + a deactivated definition stops future instance generation but its + row, schedule rule, assignees and windows all survive, and existing + instances plus completion history are never touched. Reactivation + re-enables future generation; generating or removing instances is + Feature 07 and does not happen here. + + ``definition_id`` must be a plain int (bools and floats rejected, + since SQLite would bind ``True`` onto definition 1) and + ``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 naming the + offending field when the id is malformed, ``is_active`` is not a + bool, or the definition does not exist. + + Atomicity: the existence check, the UPDATE and the readback of the + definition, its rule, assignees and windows all run inside the + connection-scoped lock, so the returned bundle is a consistent + snapshot that a concurrent mutation cannot race. + """ + _validate_definition_id(definition_id) + if not isinstance(is_active, bool): + raise ValueError(f"is_active must be a real bool, got {is_active!r}") + async with _connection_lock(database): + dao = QuestDefinitionsDao(database) + if await dao.get(definition_id) is None: + raise ValueError( + f"definition_id: quest definition {definition_id} " + "does not exist" + ) + await dao.set_active(definition_id, is_active) + updated = await dao.get(definition_id) + assert updated is not None + rule_record = await ScheduleRulesDao(database).get( + updated.schedule_rule_id + ) + assert rule_record is not None + assignees = await dao.list_assignees(definition_id) + windows = await dao.list_windows(definition_id) + rule = schedule_rule_from_storage( + schedule_rule_storage_from_record(rule_record) + ) + return CreatedQuestDefinition( + definition=updated, + rule=rule, + assignees=assignees, + windows=windows, + ) + + +def _bundle_snapshot( + snapshot: QuestDefinitionSnapshot, +) -> CreatedQuestDefinition: + """Decode a DAO snapshot into the layer's rich view. + + The rule record carried by the snapshot is the one the definition + referenced at read time, so decoding it here (rather than re-reading + through :class:`~.dao_rules.ScheduleRulesDao`) preserves the + coherence the DAO's locked snapshot guarantees: the rule, assignees + and windows all come from one atomic read, never three separate ones. + """ + rule = schedule_rule_from_storage( + schedule_rule_storage_from_record(snapshot.rule) + ) + return CreatedQuestDefinition( + definition=snapshot.definition, + rule=rule, + assignees=snapshot.assignees, + windows=snapshot.windows, + ) + + +async def list_active_definitions( + database: NestQuestDatabase, +) -> list[CreatedQuestDefinition]: + """Return every ACTIVE definition with its decoded rule and links. + + Ordered by rising definition id, matching the DAO's ``list_active`` + order, so callers get a stable, repeatable sequence. Each entry is + the same rich :class:`CreatedQuestDefinition` snapshot as the rest of + the layer: the stored row plus its decoded + :class:`~.recurrence.ScheduleRule`, assignees and windows. The + definitions and their links are read by the DAO inside one locked + transaction, so each bundle is a coherent snapshot that a concurrent + edit cannot interleave. + """ + dao = QuestDefinitionsDao(database) + snapshots = await dao.list_snapshots_active() + return [_bundle_snapshot(snapshot) for snapshot in snapshots] + + +async def list_definitions_for_child( + database: NestQuestDatabase, + child_id: int, +) -> list[CreatedQuestDefinition]: + """Return the definitions assigned to ``child_id`` (rich view). + + ``child_id`` must be a plain int (bools and floats rejected, since + SQLite would bind ``True`` onto child 1). Results are ordered by + rising definition id, matching ``list_active_definitions`` and + ``list_definitions_firing_on``. Each entry is a + :class:`CreatedQuestDefinition` snapshot bundling the definition with + its decoded rule, assignees and windows, read atomically by the DAO. + """ + _validate_child_id(child_id) + dao = QuestDefinitionsDao(database) + snapshots = await dao.list_snapshots_by_child(child_id) + bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots] + return sorted(bundles, key=lambda bundle: bundle.definition.id) + + +async def list_definitions_firing_on( + database: NestQuestDatabase, + date: str, +) -> list[CreatedQuestDefinition]: + """Return the ACTIVE definitions whose rule fires on ``date``. + + ``date`` must be a strict ISO calendar date (YYYY-MM-DD); a malformed + value raises ValueError naming the field. Each active definition's + stored rule is decoded through the task-1 storage mapping and handed + to the recurrence engine's :func:`~.recurrence.occurs_on`; only the + definitions whose decoded rule fires on the date are returned, in + rising definition-id order, each as the same rich + :class:`CreatedQuestDefinition` snapshot as the rest of the layer. + The definitions and their rules are read inside one locked + transaction, so a bundle can never filter on one rule while + presenting another state's windows. + """ + _validate_date(date, "date") + target = datetime.datetime.strptime(date, "%Y-%m-%d").date() + dao = QuestDefinitionsDao(database) + snapshots = await dao.list_snapshots_active() + bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots] + return [bundle for bundle in bundles if occurs_on(bundle.rule, target)] diff --git a/tests/test_dao_rules.py b/tests/test_dao_rules.py index 8784e73..34565b3 100644 --- a/tests/test_dao_rules.py +++ b/tests/test_dao_rules.py @@ -9,13 +9,21 @@ import pytest from custom_components.nestquest.dao_children import ChildrenDao from custom_components.nestquest.dao_rules import ( ScheduleRuleRecord, + ScheduleRuleStorage, ScheduleRulesDao, QuestDefinitionRecord, QuestDefinitionWindowRecord, QuestDefinitionsDao, + schedule_rule_from_storage, + schedule_rule_to_storage, ) from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.migrations import apply_migrations +from custom_components.nestquest.recurrence import ( + RuleType, + RuleValidationError, + ScheduleRule, +) def _run(coro): @@ -423,6 +431,249 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( assert create_won is False +# --------------------------------------------------------------------------- +# ScheduleRule <-> storage mapping +# --------------------------------------------------------------------------- + + +_RULE_STORAGE_CASES = [ + ( + ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01"), + ScheduleRuleStorage( + "daily", 1, None, None, None, None, "2026-09-01", None + ), + ), + ( + ScheduleRule( + rule_type=RuleType.DAILY, + interval=3, + start_date="2026-09-01", + end_date="2026-12-31", + ), + ScheduleRuleStorage( + "daily", 3, None, None, None, None, "2026-09-01", "2026-12-31" + ), + ), + ( + ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={2, 0, 4}, + start_date="2026-09-01", + ), + ScheduleRuleStorage( + "weekly", 1, "0,2,4", None, None, None, "2026-09-01", None + ), + ), + ( + ScheduleRule( + rule_type=RuleType.CUSTOM_DAYS, + weekday_set={1, 3, 5}, + interval=2, + start_date="2026-09-01", + ), + ScheduleRuleStorage( + "custom", 2, "1,3,5", None, None, None, "2026-09-01", None + ), + ), + ( + ScheduleRule( + rule_type=RuleType.MONTHLY_DAY, + day_of_month=15, + start_date="2026-09-01", + ), + ScheduleRuleStorage( + "monthly", 1, None, 15, None, None, "2026-09-01", None + ), + ), + ( + ScheduleRule( + rule_type=RuleType.MONTHLY_DAY, + day_of_month=31, + interval=3, + start_date="2026-09-01", + ), + ScheduleRuleStorage( + "monthly", 3, None, 31, None, None, "2026-09-01", None + ), + ), + ( + ScheduleRule( + rule_type=RuleType.MONTHLY_WEEKDAY, + nth_weekday=2, + nth_weekday_weekday=1, + start_date="2026-09-01", + ), + ScheduleRuleStorage( + "monthly", 1, "1", None, 2, None, "2026-09-01", None + ), + ), + ( + ScheduleRule( + rule_type=RuleType.MONTHLY_WEEKDAY, + nth_weekday=-1, + nth_weekday_weekday=4, + interval=2, + start_date="2026-09-01", + ), + ScheduleRuleStorage( + "monthly", 2, "4", None, -1, None, "2026-09-01", None + ), + ), + ( + ScheduleRule( + rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01" + ), + ScheduleRuleStorage( + "yearly", 1, None, None, None, 6, "2026-09-01", None + ), + ), + ( + ScheduleRule( + rule_type=RuleType.YEARLY, + month=2, + day_of_month=29, + start_date="2026-09-01", + ), + ScheduleRuleStorage( + "yearly", 1, None, 29, None, 2, "2026-09-01", None + ), + ), +] + + +@pytest.mark.parametrize("rule, storage", _RULE_STORAGE_CASES) +def test_schedule_rule_to_storage_maps_each_type(rule, storage) -> None: + assert schedule_rule_to_storage(rule) == storage + + +@pytest.mark.parametrize("rule, storage", _RULE_STORAGE_CASES) +def test_schedule_rule_from_storage_rebuilds_each_type(rule, storage) -> None: + assert schedule_rule_from_storage(storage) == rule + + +@pytest.mark.parametrize("rule, storage", _RULE_STORAGE_CASES) +def test_schedule_rule_round_trips_losslessly_both_directions( + rule, storage +) -> None: + assert schedule_rule_from_storage(schedule_rule_to_storage(rule)) == rule + assert schedule_rule_to_storage(schedule_rule_from_storage(storage)) == storage + + +@pytest.mark.parametrize( + "storage", + [ + # monthly with neither day_of_month nor nth_weekday + ScheduleRuleStorage( + "monthly", 1, None, None, None, None, "2026-09-01", None + ), + # monthly with both day_of_month and nth_weekday + ScheduleRuleStorage( + "monthly", 1, None, 15, 2, None, "2026-09-01", None + ), + # MONTHLY_WEEKDAY whose weekday_set is None + ScheduleRuleStorage( + "monthly", 1, None, None, 2, None, "2026-09-01", None + ), + # MONTHLY_WEEKDAY whose weekday_set is empty + ScheduleRuleStorage("monthly", 1, "", None, 2, None, "2026-09-01", None), + # MONTHLY_WEEKDAY whose weekday_set is multi-element + ScheduleRuleStorage( + "monthly", 1, "1,3", None, 2, None, "2026-09-01", None + ), + # unknown storage rule_type + ScheduleRuleStorage( + "sometimes", 1, None, None, None, None, "2026-09-01", None + ), + ], +) +def test_schedule_rule_from_storage_rejects_ambiguous_or_invalid( + storage, +) -> None: + with pytest.raises(RuleValidationError): + schedule_rule_from_storage(storage) + + +def test_schedule_rule_from_storage_rejects_malformed_weekday_csv() -> None: + with pytest.raises(RuleValidationError): + schedule_rule_from_storage( + ScheduleRuleStorage( + "weekly", 1, "0,2,x", None, None, None, "2026-09-01", None + ) + ) + with pytest.raises(RuleValidationError): + schedule_rule_from_storage( + ScheduleRuleStorage( + "weekly", 1, "7", None, None, None, "2026-09-01", None + ) + ) + + +@pytest.mark.parametrize( + "storage", + [ + # DAILY carrying shape columns it must not hold. + ScheduleRuleStorage( + "daily", 1, "0", None, None, None, "2026-09-01", None + ), + ScheduleRuleStorage( + "daily", 1, None, 15, None, None, "2026-09-01", None + ), + ScheduleRuleStorage( + "daily", 1, None, None, 2, None, "2026-09-01", None + ), + ScheduleRuleStorage( + "daily", 1, None, None, None, 6, "2026-09-01", None + ), + # WEEKLY carrying shape columns it must not hold. + ScheduleRuleStorage( + "weekly", 1, "0,2", 15, None, None, "2026-09-01", None + ), + ScheduleRuleStorage( + "weekly", 1, "0,2", None, None, 6, "2026-09-01", None + ), + # CUSTOM_DAYS carrying shape columns it must not hold. + ScheduleRuleStorage( + "custom", 1, "0,2", 15, None, None, "2026-09-01", None + ), + ScheduleRuleStorage( + "custom", 1, "0,2", None, None, 6, "2026-09-01", None + ), + # MONTHLY_DAY carrying weekday_set (folded-only column) or month. + ScheduleRuleStorage( + "monthly", 1, "1", 15, None, None, "2026-09-01", None + ), + ScheduleRuleStorage( + "monthly", 1, None, 15, None, 6, "2026-09-01", None + ), + # MONTHLY_WEEKDAY carrying a forbidden month. + ScheduleRuleStorage( + "monthly", 1, "1", None, 2, 6, "2026-09-01", None + ), + # YEARLY carrying weekday_set or nth_weekday. + ScheduleRuleStorage( + "yearly", 1, "0", None, None, 6, "2026-09-01", None + ), + ScheduleRuleStorage( + "yearly", 1, None, None, 2, 6, "2026-09-01", None + ), + ], +) +def test_schedule_rule_from_storage_rejects_forbidden_fields(storage) -> None: + with pytest.raises(RuleValidationError): + schedule_rule_from_storage(storage) + + +def test_schedule_rule_from_storage_rejects_duplicate_weekday_token() -> None: + """A MONTHLY_WEEKDAY with a duplicate CSV token ("1,1") must raise, + never silently canonicalize to a single weekday on re-storage.""" + with pytest.raises(RuleValidationError): + schedule_rule_from_storage( + ScheduleRuleStorage( + "monthly", 1, "1,1", None, 2, None, "2026-09-01", None + ) + ) + + # --------------------------------------------------------------------------- # quest_definitions: create / get # --------------------------------------------------------------------------- diff --git a/tests/test_quest_definitions.py b/tests/test_quest_definitions.py new file mode 100644 index 0000000..758a01a --- /dev/null +++ b/tests/test_quest_definitions.py @@ -0,0 +1,1946 @@ +"""Tests for quest_definitions.py: the Feature 06 create business layer.""" +from __future__ import annotations + +import asyncio +import datetime +import re + +import pytest + +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, + schedule_rule_to_storage, +) +from custom_components.nestquest.db import NestQuestDatabase +from custom_components.nestquest.migrations import apply_migrations +from custom_components.nestquest.quest_definitions import ( + CreatedQuestDefinition, + assign_child, + create_quest_definition, + edit_quest_definition, + list_active_definitions, + list_definitions_firing_on, + list_definitions_for_child, + set_quest_definition_active, + unassign_child, +) +from custom_components.nestquest.recurrence import RuleType, ScheduleRule + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +def _make_hass_mock(): + from unittest.mock import AsyncMock, MagicMock + + hass = MagicMock() + hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) + return hass + + +NOW = "2026-09-14T12:00:00+00:00" + + +async def _prepare(path) -> tuple: + database = NestQuestDatabase(_make_hass_mock()) + await database.open(path) + await apply_migrations(database) + children = ChildrenDao(database) + rules = ScheduleRulesDao(database) + definitions = QuestDefinitionsDao(database) + child = await children.create("Ada", NOW) + return database, children, rules, definitions, child + + +def _with_db(tmp_path, name): + """Run an async body against a prepared database, closing it after.""" + + def _run_test(body): + async def _main(): + database, children, rules, definitions, child = await _prepare( + tmp_path / name + ) + try: + return await body(database, children, rules, definitions, child) + finally: + await database.close() + + return _run(_main()) + + return _run_test + + +def _daily_rule() -> ScheduleRule: + return ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-14") + + +def _today_iso() -> str: + """Today's date in ISO form, computed at call time (never cached). + + ``QuestInstancesDao.upsert`` refuses past due dates, so seeded + instances use a date that is always today-or-later at test time. + """ + return datetime.date.today().isoformat() + + +# --------------------------------------------------------------------------- +# create_quest_definition: successful creates +# --------------------------------------------------------------------------- + + +def test_create_one_child_one_window(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + rule = _daily_rule() + result = await create_quest_definition( + database, + " Brush teeth ", + rule, + [child.id], + ["morning"], + description=" Twice daily ", + icon=" mdi:tooth ", + ) + assert isinstance(result, CreatedQuestDefinition) + definition = result.definition + assert definition.title == "Brush teeth" + assert definition.description == "Twice daily" + assert definition.icon == "mdi:tooth" + assert definition.is_active is True + # D-008: the definition-level due_time column is never set here. + assert definition.due_time is None + # Timestamp is stamped UTC at second precision by the business layer. + assert re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00", + definition.created_at, + ) + # The rule round-trips through the storage mapping. + assert result.rule == rule + assert definition.schedule_rule_id is not None + rule_row = await rules.get(definition.schedule_rule_id) + assert rule_row is not None + assert rule_row.rule_type == "daily" + # Assignees and windows are read back through the DAO. + assert [c.id for c in result.assignees] == [child.id] + assert [w.window for w in result.windows] == ["morning"] + assert result.windows[0].due_time is None + return result + + _with_db(tmp_path, "create-one.db")(_body) + + +def test_create_several_children_several_windows_with_due_times( + tmp_path, +) -> None: + async def _body(database, children, rules, definitions, child): + bo = await children.create("Bo", NOW) + cleo = await children.create("Cleo", NOW) + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={0, 2, 4}, + start_date="2026-09-14", + ) + result = await create_quest_definition( + database, + "Chore", + rule, + [child.id, bo.id, cleo.id], + [("morning", "09:00"), ("evening", "19:30")], + ) + assert sorted(c.id for c in result.assignees) == sorted( + [child.id, bo.id, cleo.id] + ) + by_name = {w.window: w.due_time for w in result.windows} + assert by_name == {"morning": "09:00", "evening": "19:30"} + assert result.rule == rule + stored = await definitions.get(result.definition.id) + assert stored is not None + assert stored.due_time is None + return result + + _with_db(tmp_path, "create-many.db")(_body) + + +# --------------------------------------------------------------------------- +# create_quest_definition: argument rejections +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("bad", ["", " ", "\t\n", None]) +def test_create_rejects_empty_title(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises(ValueError, match="title is required"): + await create_quest_definition( + database, bad, _daily_rule(), [child.id], ["morning"] + ) + return None + + _with_db(tmp_path, "create-bad-title.db")(_body) + + +def test_create_rejects_non_string_title(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises(ValueError, match="title must be a string"): + await create_quest_definition( + database, 42, _daily_rule(), [child.id], ["morning"] + ) + return None + + _with_db(tmp_path, "create-bad-title-type.db")(_body) + + +@pytest.mark.parametrize("bad", [None, "daily", 42, {"rule_type": "daily"}]) +def test_create_rejects_non_schedule_rule(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises(ValueError, match="rule must be a ScheduleRule"): + await create_quest_definition( + database, "Brush teeth", bad, [child.id], ["morning"] + ) + return None + + _with_db(tmp_path, "create-bad-rule.db")(_body) + + +@pytest.mark.parametrize("bad", [[]]) +def test_create_rejects_empty_assignees(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="assignee_child_ids must not be empty" + ): + await create_quest_definition( + database, "Brush teeth", _daily_rule(), bad, ["morning"] + ) + return None + + _with_db(tmp_path, "create-empty-assignees.db")(_body) + + +@pytest.mark.parametrize("bad", [(1,), (1, 2), "1", {1}]) +def test_create_rejects_non_list_assignees(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="assignee_child_ids must be a list" + ): + await create_quest_definition( + database, "Brush teeth", _daily_rule(), bad, ["morning"] + ) + return None + + _with_db(tmp_path, "create-non-list-assignees.db")(_body) + + +@pytest.mark.parametrize("bad", [True, False, 1.5, "1", None]) +def test_create_rejects_non_int_assignee(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="assignee_child_ids must contain integer" + ): + await create_quest_definition( + database, "Brush teeth", _daily_rule(), [bad], ["morning"] + ) + return None + + _with_db(tmp_path, "create-bad-assignee.db")(_body) + + +def test_create_rejects_duplicate_assignees(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="assignee_child_ids must not contain duplicate" + ): + await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id, child.id], + ["morning"], + ) + return None + + _with_db(tmp_path, "create-duplicate-assignees.db")(_body) + + +def test_create_rejects_inactive_child(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + inactive = await children.create("Bo", NOW) + await children.set_active(inactive.id, False) + with pytest.raises( + ValueError, match="assignee_child_ids.*inactive" + ): + await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [inactive.id], + ["morning"], + ) + return None + + _with_db(tmp_path, "create-inactive-child.db")(_body) + + +def test_create_rejects_unknown_child(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="assignee_child_ids.*does not exist" + ): + await create_quest_definition( + database, "Brush teeth", _daily_rule(), [999], ["morning"] + ) + return None + + _with_db(tmp_path, "create-unknown-child.db")(_body) + + +@pytest.mark.parametrize("bad", [[]]) +def test_create_rejects_empty_windows(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises(ValueError, match="windows must not be empty"): + await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], bad + ) + return None + + _with_db(tmp_path, "create-empty-windows.db")(_body) + + +@pytest.mark.parametrize( + "bad", [("morning",), ("morning", "afternoon"), "morning", {"morning"}] +) +def test_create_rejects_non_list_windows(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises(ValueError, match="windows must be a list"): + await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], bad + ) + return None + + _with_db(tmp_path, "create-non-list-windows.db")(_body) + + +@pytest.mark.parametrize("bad", [["noon"], [("MORNING", None)], [("", None)]]) +def test_create_rejects_invalid_window(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises(ValueError, match="window must be one of"): + await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], bad + ) + return None + + _with_db(tmp_path, "create-bad-window.db")(_body) + + +def test_create_rejects_duplicate_windows(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="windows must not contain duplicate" + ): + await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id], + ["morning", "morning"], + ) + with pytest.raises( + ValueError, match="windows must not contain duplicate" + ): + await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id], + [("morning", "09:00"), ("morning", "10:00")], + ) + return None + + _with_db(tmp_path, "create-duplicate-windows.db")(_body) + + +@pytest.mark.parametrize("bad", ["9:30", "25:00", "09:60", "0900"]) +def test_create_rejects_malformed_due_time(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises(ValueError, match="HH:MM"): + await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id], + [("morning", bad)], + ) + return None + + _with_db(tmp_path, "create-bad-due-time.db")(_body) + + +# --------------------------------------------------------------------------- +# create_quest_definition: a rejected create persists nothing +# --------------------------------------------------------------------------- + + +async def _assert_only_baseline_rows( + rules, definitions, child, baseline_definition +) -> None: + """Assert the baseline rule/definition/assignee/window rows survive + unchanged and no second row of any of them appeared. + + A rejected create must leave each table's row count exactly as it + was: still one rule, one definition, one assignee, one window — + checked through the DAO (id 2 must be absent everywhere, id 1 + intact), since the leak guard forbids raw SQL in this test file. + """ + assert await rules.get(1) is not None + assert await rules.get(2) is None + assert await definitions.get(1) == baseline_definition + assert await definitions.get(2) is None + assert [c.id for c in await definitions.list_assignees(1)] == [child.id] + assert await definitions.list_assignees(2) == [] + assert [w.window for w in await definitions.list_windows(1)] == [ + "morning" + ] + assert await definitions.list_windows(2) == [] + + +def test_rejected_create_writes_nothing(tmp_path) -> None: + """A rejected create leaves every quest row count unchanged. + + Two rejection paths are forced: an in-transaction rejection (an + inactive assignee fails validation inside the transaction and must + roll the rule/definition back together) and a pre-write rejection + (an invalid window is caught in the business layer before any write). + Both must leave the baseline single-rule/single-definition state + untouched, with no second row in any of the four tables. + """ + async def _body(database, children, rules, definitions, child): + baseline_rule = await rules.create("daily", "2026-09-14") + baseline_definition = await definitions.create( + "Baseline", baseline_rule.id, NOW, assignee_child_ids=[child.id] + ) + await definitions.upsert_window(baseline_definition.id, "morning") + inactive = await children.create("Bo", NOW) + await children.set_active(inactive.id, False) + rule = _daily_rule() + + # In-transaction rejection: the inactive child fails validation + # inside the transaction, rolling the rule and definition back. + with pytest.raises(ValueError, match="inactive"): + await create_quest_definition( + database, "Brush teeth", rule, [inactive.id], ["morning"] + ) + await _assert_only_baseline_rows( + rules, definitions, child, baseline_definition + ) + + # Pre-write rejection: an invalid window is caught up front, so + # no transaction ever opens. + with pytest.raises(ValueError, match="window must be one of"): + await create_quest_definition( + database, "Brush teeth", rule, [child.id], [("noon", None)] + ) + await _assert_only_baseline_rows( + rules, definitions, child, baseline_definition + ) + return None + + _with_db(tmp_path, "create-rejected-nothing.db")(_body) + + +def test_post_write_failure_rolls_back_everything(tmp_path) -> None: + """A failure after some writes rolls all four tables back together. + + Deterministic post-write trigger: the DAO method inserts the rule, + the definition and the assignee rows, THEN validates the window name + and raises ValueError for a window outside const.QUEST_WINDOWS. + That failure lands after three writes inside the transaction, so the + rule, definition and assignee rows must all roll back — no partial + rows survive in any of the four tables. + """ + async def _body(database, children, rules, definitions, child): + storage = schedule_rule_to_storage(_daily_rule()) + dao = QuestDefinitionsDao(database) + with pytest.raises(ValueError, match="window must be one of"): + await dao.create_with_rule_and_windows( + "Brush teeth", + storage, + NOW, + [child.id], + [("noon", None)], + ) + # All four tables are empty: the earlier rule, definition and + # assignee writes were rolled back, and no window row was added. + assert await rules.get(1) is None + assert await definitions.get(1) is None + assert await definitions.list_active() == [] + assert await definitions.list_assignees(1) == [] + assert await definitions.list_windows(1) == [] + return None + + _with_db(tmp_path, "post-write-rollback.db")(_body) + + +# --------------------------------------------------------------------------- +# edit_quest_definition: successful edits +# --------------------------------------------------------------------------- + + +def test_edit_title_description_icon(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id], + ["morning"], + description="Old description", + icon="mdi:old", + ) + result = await edit_quest_definition( + database, + created.definition.id, + title=" Floss ", + description=" New description ", + icon=" mdi:new ", + ) + assert result.definition.title == "Floss" + assert result.definition.description == "New description" + assert result.definition.icon == "mdi:new" + # Untouched fields survive the edit unchanged. + assert result.definition.id == created.definition.id + assert result.definition.schedule_rule_id == created.definition.schedule_rule_id + assert result.definition.due_time is None + assert result.definition.is_active is True + assert result.definition.created_at == created.definition.created_at + # Rule, assignees and windows are read back through the DAO. + assert result.rule == _daily_rule() + assert [c.id for c in result.assignees] == [child.id] + assert [w.window for w in result.windows] == ["morning"] + stored = await definitions.get(created.definition.id) + assert stored is not None + assert stored.title == "Floss" + assert stored.description == "New description" + assert stored.icon == "mdi:new" + return None + + _with_db(tmp_path, "edit-metadata.db")(_body) + + +def test_edit_omitted_fields_left_unchanged(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id], + ["morning"], + description="Keep me", + ) + result = await edit_quest_definition( + database, created.definition.id, title="Renamed" + ) + assert result.definition.title == "Renamed" + assert result.definition.description == "Keep me" + assert result.definition.icon is None + return None + + _with_db(tmp_path, "edit-omitted.db")(_body) + + +def test_edit_clears_optional_metadata(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id], + ["morning"], + description="Old", + icon="mdi:old", + ) + result = await edit_quest_definition( + database, + created.definition.id, + description=None, + icon=None, + ) + assert result.definition.description is None + assert result.definition.icon is None + return None + + _with_db(tmp_path, "edit-clear-metadata.db")(_body) + + +def test_edit_rule_updates_existing_row_in_place(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + rule_id = created.definition.schedule_rule_id + assert rule_id == 1 + new_rule = ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={1, 3}, + start_date="2026-09-14", + ) + result = await edit_quest_definition( + database, created.definition.id, rule=new_rule + ) + assert result.rule == new_rule + # The definition keeps the SAME rule row: no new or orphaned row. + assert result.definition.schedule_rule_id == rule_id + rule_row = await rules.get(rule_id) + assert rule_row is not None + assert rule_row.rule_type == "weekly" + assert rule_row.weekday_set == "1,3" + assert await rules.get(rule_id + 1) is None + return None + + _with_db(tmp_path, "edit-rule-in-place.db")(_body) + + +def test_edit_rule_shape_change_clears_stale_columns(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + weekly = ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={2}, + start_date="2026-09-14", + ) + created = await create_quest_definition( + database, "Chore", weekly, [child.id], ["morning"] + ) + rule_id = created.definition.schedule_rule_id + result = await edit_quest_definition( + database, created.definition.id, rule=_daily_rule() + ) + assert result.rule == _daily_rule() + rule_row = await rules.get(rule_id) + assert rule_row is not None + # The old weekly shape's weekday_set must be cleared, not left stale. + assert rule_row.rule_type == "daily" + assert rule_row.weekday_set is None + return None + + _with_db(tmp_path, "edit-rule-shape.db")(_body) + + +def test_edit_replaces_windows(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id], + [("morning", "07:00"), ("evening", "19:00")], + ) + result = await edit_quest_definition( + database, + created.definition.id, + windows=[("morning", "08:00"), ("afternoon", "12:00")], + ) + by_name = {w.window: w.due_time for w in result.windows} + # morning's due time changed, afternoon added, evening removed. + assert by_name == {"morning": "08:00", "afternoon": "12:00"} + persisted = { + w.window: w.due_time + for w in await definitions.list_windows(created.definition.id) + } + assert persisted == {"morning": "08:00", "afternoon": "12:00"} + return None + + _with_db(tmp_path, "edit-replace-windows.db")(_body) + + +def test_edit_preserves_assignees(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + bo = await children.create("Bo", NOW) + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id, bo.id], ["morning"] + ) + result = await edit_quest_definition( + database, + created.definition.id, + title="Renamed", + rule=ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={0}, + start_date="2026-09-14", + ), + windows=[("evening", "20:00")], + ) + assert sorted(c.id for c in result.assignees) == sorted([child.id, bo.id]) + persisted = [ + c.id for c in await definitions.list_assignees(created.definition.id) + ] + assert sorted(persisted) == sorted([child.id, bo.id]) + return None + + _with_db(tmp_path, "edit-preserves-assignees.db")(_body) + + +# --------------------------------------------------------------------------- +# edit_quest_definition: argument rejections +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("bad", ["", " ", None]) +def test_edit_rejects_empty_title(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises(ValueError, match="title is required"): + await edit_quest_definition( + database, created.definition.id, title=bad + ) + return None + + _with_db(tmp_path, "edit-bad-title.db")(_body) + + +@pytest.mark.parametrize("bad", [None, "weekly", 42, {"rule_type": "daily"}]) +def test_edit_rejects_non_schedule_rule(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises(ValueError, match="rule must be a ScheduleRule"): + await edit_quest_definition( + database, created.definition.id, rule=bad + ) + return None + + _with_db(tmp_path, "edit-bad-rule.db")(_body) + + +@pytest.mark.parametrize("bad", [["noon"], [("MORNING", None)], [("", None)]]) +def test_edit_rejects_invalid_window(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises(ValueError, match="window must be one of"): + await edit_quest_definition( + database, created.definition.id, windows=bad + ) + return None + + _with_db(tmp_path, "edit-bad-window.db")(_body) + + +@pytest.mark.parametrize("bad", ["9:30", "25:00", "09:60", "0900"]) +def test_edit_rejects_malformed_due_time(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises(ValueError, match="HH:MM"): + await edit_quest_definition( + database, created.definition.id, windows=[("morning", bad)] + ) + return None + + _with_db(tmp_path, "edit-bad-due-time.db")(_body) + + +@pytest.mark.parametrize("bad", [[]]) +def test_edit_rejects_empty_windows(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises(ValueError, match="windows must not be empty"): + await edit_quest_definition( + database, created.definition.id, windows=bad + ) + return None + + _with_db(tmp_path, "edit-empty-windows.db")(_body) + + +@pytest.mark.parametrize("bad", [("morning",), "morning", {"morning"}]) +def test_edit_rejects_non_list_windows(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises(ValueError, match="windows must be a list"): + await edit_quest_definition( + database, created.definition.id, windows=bad + ) + return None + + _with_db(tmp_path, "edit-non-list-windows.db")(_body) + + +def test_edit_rejects_nonexistent_definition(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises(ValueError, match="does not exist"): + await edit_quest_definition(database, 999, title="X") + return None + + _with_db(tmp_path, "edit-missing-definition.db")(_body) + + +def test_edit_nonexistent_definition_names_field(tmp_path) -> None: + """A missing definition's error names the ``definition_id`` field. + + The DAO raises a bare "quest definition does not exist"; the + business layer must re-raise it prefixed with the field the caller + passed, matching assign/unassign/deactivate. + """ + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, + match="definition_id: quest definition 999 does not exist", + ): + await edit_quest_definition(database, 999, title="X") + return None + + _with_db(tmp_path, "edit-missing-definition-field.db")(_body) + + +@pytest.mark.parametrize("bad", [True, 1.5, "1", None]) +def test_edit_rejects_non_int_definition_id(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="definition_id must be an integer" + ): + await edit_quest_definition(database, bad, title="X") + return None + + _with_db(tmp_path, "edit-bad-definition-id.db")(_body) + + +# --------------------------------------------------------------------------- +# edit_quest_definition: consistent snapshot and rollback (CQ-01, CQ-02) +# --------------------------------------------------------------------------- + + +def test_concurrent_edit_omitted_rule_snapshot_consistent(tmp_path) -> None: + """Two concurrent edits never return a mixed-time snapshot. + + One edit changes windows only (rule omitted); the other changes the + rule only (windows omitted). Because each edit reads its rule, + assignees and windows back inside its own locked transaction, the + windows-only edit reports the rule as it was AT THAT EDIT's + transaction: the ORIGINAL rule when it ran first, the NEW rule when + it ran second. That yields a strict invariant — the windows-only + result's rule is the original exactly when the rule-only result + still saw the original windows — which a post-transaction rule read + would violate. + """ + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + weekly = ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={2}, + start_date="2026-09-14", + ) + windows_result, rule_result = await asyncio.gather( + edit_quest_definition( + database, + created.definition.id, + windows=[("evening", "19:00")], + ), + edit_quest_definition(database, created.definition.id, rule=weekly), + ) + # Each edit reports its own change... + assert [w.window for w in windows_result.windows] == ["evening"] + assert rule_result.rule == weekly + # ...and the pair is self-consistent: the windows-only edit saw + # the original rule iff the rule-only edit saw the original + # windows (they ran in that order). + assert (windows_result.rule == _daily_rule()) == ( + [w.window for w in rule_result.windows] == ["evening"] + ) + return None + + _with_db(tmp_path, "edit-concurrent-snapshot.db")(_body) + + +def test_edit_post_write_failure_rolls_back_everything(tmp_path) -> None: + """A post-write failure during an edit rolls every table back together. + + Deterministic trigger: call the DAO's edit directly with a window + name outside const.QUEST_WINDOWS. The DAO applies the metadata and + rule writes and the window delete, THEN validates the window name + and raises ValueError. That failure lands after several writes + inside the transaction, so the metadata, rule and window changes + must all roll back — the baseline definition, rule and windows + survive unchanged. + """ + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id], + [("morning", "07:00")], + ) + rule_id = created.definition.schedule_rule_id + weekly_storage = schedule_rule_to_storage( + ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={2}, + start_date="2026-09-14", + ) + ) + dao = QuestDefinitionsDao(database) + with pytest.raises(ValueError, match="window must be one of"): + await dao.edit_definition( + created.definition.id, + title="Changed", + rule=weekly_storage, + windows=[("noon", None)], + ) + stored = await definitions.get(created.definition.id) + assert stored is not None + assert stored.title == "Brush teeth" + rule_row = await rules.get(rule_id) + assert rule_row is not None + assert rule_row.rule_type == "daily" + assert rule_row.weekday_set is None + by_name = { + w.window: w.due_time + for w in await definitions.list_windows(created.definition.id) + } + assert by_name == {"morning": "07:00"} + assert [ + c.id for c in await definitions.list_assignees(created.definition.id) + ] == [child.id] + return None + + _with_db(tmp_path, "edit-post-write-rollback.db")(_body) + + +# --------------------------------------------------------------------------- +# assign_child / unassign_child: the assignment business-layer paths +# --------------------------------------------------------------------------- + + +def test_assign_child_multi_assignee_success(tmp_path) -> None: + """Assigning a second child leaves both as assignees (D-008).""" + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + bo = await children.create("Bo", NOW) + assignees = await assign_child(database, created.definition.id, bo.id) + assert sorted(c.id for c in assignees) == sorted([child.id, bo.id]) + return assignees + + _with_db(tmp_path, "assign-multi.db")(_body) + + +def test_assign_child_duplicate_is_noop(tmp_path) -> None: + """Assigning an already-assigned child adds no duplicate row.""" + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + assignees = await assign_child( + database, created.definition.id, child.id + ) + assert [c.id for c in assignees] == [child.id] + again = await assign_child( + database, created.definition.id, child.id + ) + assert [c.id for c in again] == [child.id] + return assignees + + _with_db(tmp_path, "assign-duplicate.db")(_body) + + +def test_assign_child_unknown_child_names_field(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises( + ValueError, match="child_id.*does not exist" + ): + await assign_child(database, created.definition.id, 999) + return None + + _with_db(tmp_path, "assign-unknown-child.db")(_body) + + +def test_assign_child_inactive_child_names_field(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + bo = await children.create("Bo", NOW) + await children.set_active(bo.id, False) + with pytest.raises(ValueError, match="child_id.*inactive"): + await assign_child(database, created.definition.id, bo.id) + # The failed assignment leaves the roster unchanged. + assert [c.id for c in await definitions.list_assignees( + created.definition.id + )] == [child.id] + return None + + _with_db(tmp_path, "assign-inactive-child.db")(_body) + + +def test_assign_child_nonexistent_definition_names_field(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="definition_id.*does not exist" + ): + await assign_child(database, 999, child.id) + return None + + _with_db(tmp_path, "assign-missing-definition.db")(_body) + + +@pytest.mark.parametrize("bad", [True, 1.5, "1", None]) +def test_assign_child_rejects_non_int_definition_id(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="definition_id must be an integer" + ): + await assign_child(database, bad, child.id) + return None + + _with_db(tmp_path, "assign-bad-definition-id.db")(_body) + + +@pytest.mark.parametrize("bad", [True, 1.5, "1", None]) +def test_assign_child_rejects_non_int_child_id(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises(ValueError, match="child_id must be an integer"): + await assign_child(database, created.definition.id, bad) + return None + + _with_db(tmp_path, "assign-bad-child-id.db")(_body) + + +def test_unassign_child_success_returns_true(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + bo = await children.create("Bo", NOW) + created = await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id, bo.id], + ["morning"], + ) + assert await unassign_child( + database, created.definition.id, bo.id + ) is True + assert [c.id for c in await definitions.list_assignees( + created.definition.id + )] == [child.id] + return None + + _with_db(tmp_path, "unassign-success.db")(_body) + + +def test_unassign_child_not_assigned_returns_false(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + assert await unassign_child( + database, created.definition.id, 999 + ) is False + return None + + _with_db(tmp_path, "unassign-not-assigned.db")(_body) + + +def test_unassign_child_nonexistent_definition_names_field(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="definition_id.*does not exist" + ): + await unassign_child(database, 999, child.id) + return None + + _with_db(tmp_path, "unassign-missing-definition.db")(_body) + + +@pytest.mark.parametrize("bad", [True, 1.5, "1", None]) +def test_unassign_child_rejects_non_int_definition_id(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="definition_id must be an integer" + ): + await unassign_child(database, bad, child.id) + return None + + _with_db(tmp_path, "unassign-bad-definition-id.db")(_body) + + +@pytest.mark.parametrize("bad", [True, 1.5, "1", None]) +def test_unassign_child_rejects_non_int_child_id(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises(ValueError, match="child_id must be an integer"): + await unassign_child(database, created.definition.id, bad) + return None + + _with_db(tmp_path, "unassign-bad-child-id.db")(_body) + + +def test_assign_and_unassign_leave_instances_and_history_alone( + tmp_path, +) -> None: + """Assign/unassign write the assignees table only. + + An already-generated instance keeps its generation-time child_id, and + no completion event appears — the assignment paths touch neither + ``quest_instances`` nor ``completion_events``. + """ + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + bo = await children.create("Bo", NOW) + await database.execute( + "INSERT INTO quest_instances (definition_id, child_id, " + "window, due_date, generated_at) VALUES (?, ?, 'morning', ?, ?)", + (created.definition.id, child.id, "2026-09-15", NOW), + ) + await assign_child(database, created.definition.id, bo.id) + await unassign_child(database, created.definition.id, child.id) + row = await database.fetch_one( + "SELECT child_id FROM quest_instances WHERE definition_id = ?", + (created.definition.id,), + ) + assert row == (child.id,) + events = await database.fetch_one( + "SELECT COUNT(*) FROM completion_events" + ) + assert events == (0,) + return None + + _with_db(tmp_path, "assign-unassign-history.db")(_body) + + +def test_concurrent_assign_unassign_returns_consistent_roster( + tmp_path, +) -> None: + """A concurrent unassign cannot strip the just-assigned child from + the roster that ``assign_child`` returns. + + ``assign_child`` returns the roster read inside the same locked + transaction as the INSERT, so the child it assigned is always present + in its result — even when an unassign of that same child races. The + pause hook holds ``list_assignees`` mid-flight so the unassign is + deterministically queued against the assignment's lock. + """ + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + bo = await children.create("Bo", NOW) + + import custom_components.nestquest.dao_rules as dao_rules + + original_list = dao_rules.QuestDefinitionsDao.list_assignees + started = asyncio.Event() + release = asyncio.Event() + + async def _pausing_list(self, definition_id): + if not started.is_set(): + started.set() + await release.wait() + return await original_list(self, definition_id) + + dao_rules.QuestDefinitionsDao.list_assignees = _pausing_list + try: + assign_task = asyncio.ensure_future( + assign_child(database, created.definition.id, bo.id) + ) + await started.wait() + unassign_task = asyncio.ensure_future( + unassign_child(database, created.definition.id, bo.id) + ) + await asyncio.sleep(0) + release.set() + assignees, removed = await asyncio.gather( + assign_task, unassign_task + ) + finally: + dao_rules.QuestDefinitionsDao.list_assignees = original_list + + assert bo.id in [c.id for c in assignees], ( + "assign_child must return a roster containing the child it " + "just assigned, even under a concurrent unassign" + ) + assert removed is True + return assignees + + _with_db(tmp_path, "assign-unassign-concurrent.db")(_body) + + +# --------------------------------------------------------------------------- +# set_quest_definition_active: deactivate / reactivate +# --------------------------------------------------------------------------- + + +def test_set_quest_definition_active_round_trip(tmp_path) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + definition_id = created.definition.id + + deactivated = await set_quest_definition_active( + database, definition_id, False + ) + assert isinstance(deactivated, CreatedQuestDefinition) + assert deactivated.definition.id == definition_id + assert deactivated.definition.is_active is False + # The decoded rule, assignees and windows form a consistent + # snapshot of the (unchanged) definition. + assert deactivated.rule == _daily_rule() + assert [c.id for c in deactivated.assignees] == [child.id] + assert [w.window for w in deactivated.windows] == ["morning"] + assert await definitions.list_active() == [] + + reactivated = await set_quest_definition_active( + database, definition_id, True + ) + assert reactivated.definition.is_active is True + assert [d.id for d in await definitions.list_active()] == [ + definition_id + ] + return reactivated + + _with_db(tmp_path, "set-active-round-trip.db")(_body) + + +@pytest.mark.parametrize("bad", ["1", "0", 1, 0, 1.0, None, "true"]) +def test_set_quest_definition_active_rejects_non_bool(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises( + ValueError, match="is_active must be a real bool" + ): + await set_quest_definition_active( + database, created.definition.id, bad + ) + # The refused transition left the definition active. + stored = await definitions.get(created.definition.id) + assert stored is not None + assert stored.is_active is True + return None + + _with_db(tmp_path, "set-active-non-bool.db")(_body) + + +@pytest.mark.parametrize("bad", [True, False, 1.5, "1", None]) +def test_set_quest_definition_active_rejects_non_int_definition_id( + tmp_path, bad +) -> None: + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises( + ValueError, match="definition_id must be an integer" + ): + await set_quest_definition_active(database, bad, False) + stored = await definitions.get(created.definition.id) + assert stored is not None + assert stored.is_active is True + return None + + _with_db(tmp_path, "set-active-non-int-id.db")(_body) + + +def test_set_quest_definition_active_nonexistent_definition_names_field( + tmp_path, +) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises( + ValueError, match="definition_id.*does not exist" + ): + await set_quest_definition_active(database, 999, False) + return None + + _with_db(tmp_path, "set-active-missing-definition.db")(_body) + + +def test_set_quest_definition_active_never_deletes_or_touches_instances( + tmp_path, +) -> None: + """Deactivation hides the definition but deletes nothing, and + pre-existing instances and completion history survive unchanged.""" + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + definition_id = created.definition.id + rule_id = created.definition.schedule_rule_id + + # Seed a real instance and a real completion event against the + # definition BEFORE deactivation, so the test can detect a + # delete or mutation of pre-existing data (not just an empty + # table staying empty). + instances = QuestInstancesDao(database) + events = CompletionEventsDao(database) + instance = await instances.upsert( + definition_id, child.id, _today_iso(), NOW, window="morning" + ) + event = await events.append( + instance.id, + child.id, + "completed", + "user", + NOW, + True, + actor_user_id="admin-1", + ) + + instance_count = await database.fetch_one( + "SELECT COUNT(*) FROM quest_instances" + ) + event_count = await database.fetch_one( + "SELECT COUNT(*) FROM completion_events" + ) + + await set_quest_definition_active(database, definition_id, False) + + # The definition row survives (deactivated, not deleted). + stored = await definitions.get(definition_id) + assert stored is not None + assert stored.is_active is False + # Its rule, assignee and window rows all survive too. + assert await rules.get(rule_id) is not None + assert [c.id for c in await definitions.list_assignees( + definition_id + )] == [child.id] + assert [w.window for w in await definitions.list_windows( + definition_id + )] == ["morning"] + # The pre-existing instance and completion event survive with + # unchanged ids and contents, and no new rows appeared. + assert await instances.get_by_id(instance.id) == instance + assert await events.list_by_instance(instance.id) == [event] + assert await database.fetch_one( + "SELECT COUNT(*) FROM quest_instances" + ) == instance_count + assert await database.fetch_one( + "SELECT COUNT(*) FROM completion_events" + ) == event_count + + await set_quest_definition_active(database, definition_id, True) + assert (await definitions.get(definition_id)).is_active is True + assert await instances.get_by_id(instance.id) == instance + assert await events.list_by_instance(instance.id) == [event] + assert await database.fetch_one( + "SELECT COUNT(*) FROM quest_instances" + ) == instance_count + assert await database.fetch_one( + "SELECT COUNT(*) FROM completion_events" + ) == event_count + return None + + _with_db(tmp_path, "set-active-never-deletes.db")(_body) + + +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 UPDATE of whichever ``set_quest_definition_active`` + 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_set_quest_definition_active_concurrent_opposite_transitions_return_own( + tmp_path, +) -> None: + """Racing deactivate/reactivate calls each report their own request. + + Deterministic gate on task A's UPDATE: task B queues behind the + connection lock and lands the opposite transition after A. Without + the lock, A's readback could see B's write and report B's value. + """ + 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) + children = ChildrenDao(database) + child = await children.create("Ada", NOW) + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + + armed["active"] = True + task_a = asyncio.ensure_future( + set_quest_definition_active( + database, created.definition.id, False + ) + ) + await a_started.wait() + armed["active"] = False + task_b = asyncio.ensure_future( + set_quest_definition_active( + database, created.definition.id, True + ) + ) + await asyncio.sleep(0) + gate_open.set() + record_a, record_b = await asyncio.gather(task_a, task_b) + + # Each caller gets back exactly the state it requested. + assert record_a.definition.is_active is False + assert record_b.definition.is_active is True + # Both snapshots stay coherent: the decoded rule and the + # assignees are those of the definition they were read from. + assert record_a.rule == _daily_rule() + assert record_b.rule == _daily_rule() + assert [c.id for c in record_a.assignees] == [child.id] + assert [c.id for c in record_b.assignees] == [child.id] + # B landed last: the stored row is B's (read via the DAO — + # the four-table leak guard forbids raw probes here). + stored = await QuestDefinitionsDao(database).get( + created.definition.id + ) + assert stored is not None + assert stored.is_active is True + finally: + await database.close() + + _run(_main()) + + +# --------------------------------------------------------------------------- +# list query helpers: list_active_definitions / list_definitions_for_child / +# list_definitions_firing_on +# --------------------------------------------------------------------------- + + +def test_list_active_definitions_returns_only_active_in_id_order(tmp_path) -> None: + """Only active definitions come back, in rising id order, each rich. + + Two definitions are created (ids 1 and 2), then definition 1 is + deactivated. ``list_active_definitions`` must return only definition + 2, and every bundle must carry the decoded rule, assignees and + windows of its own definition. + """ + async def _body(database, children, rules, definitions, child): + bo = await children.create("Bo", NOW) + first = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + weekly = ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={0, 2}, + start_date="2026-09-14", + ) + second = await create_quest_definition( + database, + "Tidy room", + weekly, + [child.id, bo.id], + [("morning", "08:00"), ("evening", "19:00")], + ) + assert [d.id for d in await definitions.list_active()] == [ + first.definition.id, + second.definition.id, + ] + + await set_quest_definition_active(database, first.definition.id, False) + + result = await list_active_definitions(database) + assert [bundle.definition.id for bundle in result] == [second.definition.id] + assert isinstance(result[0], CreatedQuestDefinition) + assert result[0].rule == weekly + assert sorted(c.id for c in result[0].assignees) == sorted( + [child.id, bo.id] + ) + by_name = {w.window: w.due_time for w in result[0].windows} + assert by_name == {"morning": "08:00", "evening": "19:00"} + return result + + _with_db(tmp_path, "list-active.db")(_body) + + +def test_list_definitions_for_child_returns_only_that_child(tmp_path) -> None: + """Only the given child's definitions come back, deterministically. + + Definitions are created assigned to Ada (child), Bo, or both; the + helper must return exactly the definitions assigned to Ada, excluding + a Bo-only definition. Inactive definitions are still assigned, so + they too come back (the helper filters by child, not by activity). + """ + async def _body(database, children, rules, definitions, child): + bo = await children.create("Bo", NOW) + ada_only = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + both = await create_quest_definition( + database, + "Tidy room", + ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={1}, + start_date="2026-09-14", + ), + [child.id, bo.id], + ["evening"], + ) + bo_only = await create_quest_definition( + database, "Walk dog", _daily_rule(), [bo.id], ["afternoon"] + ) + + # The helper returns the given child's definitions in ascending + # id order, consistent with list_active_definitions. + result = await list_definitions_for_child(database, child.id) + assert [bundle.definition.id for bundle in result] == [ + ada_only.definition.id, + both.definition.id, + ] + assert result[0].rule == _daily_rule() + assert [c.id for c in result[0].assignees] == [child.id] + assert [w.window for w in result[0].windows] == ["morning"] + + bo_result = await list_definitions_for_child(database, bo.id) + assert [bundle.definition.id for bundle in bo_result] == [ + both.definition.id, + bo_only.definition.id, + ] + return result + + _with_db(tmp_path, "list-for-child.db")(_body) + + +@pytest.mark.parametrize("bad", [True, False, 1.5, "1", None]) +def test_list_definitions_for_child_rejects_non_int_child_id(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + with pytest.raises(ValueError, match="child_id must be an integer"): + await list_definitions_for_child(database, bad) + return None + + _with_db(tmp_path, "list-for-child-bad-id.db")(_body) + + +def test_list_definitions_firing_on_mixed_rules(tmp_path) -> None: + """A mixed daily + weekly + monthly set fires on the right dates. + + Daily fires every day from its start; weekly fires on its weekdays; + monthly fires on its day-of-month. Deactivated definitions never + fire, and the returned bundles carry the decoded rule. + """ + async def _body(database, children, rules, definitions, child): + daily = await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-14"), + [child.id], + ["morning"], + ) + weekly = await create_quest_definition( + database, + "Weekly chore", + ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={0, 2}, # Monday, Wednesday + start_date="2026-09-14", + ), + [child.id], + ["evening"], + ) + monthly = await create_quest_definition( + database, + "Monthly chore", + ScheduleRule( + rule_type=RuleType.MONTHLY_DAY, + day_of_month=15, + start_date="2026-09-14", + ), + [child.id], + ["afternoon"], + ) + + # 2026-09-14 is a Monday (weekday 0): daily + weekly fire; + # monthly (the 15th) does not. + hits = await list_definitions_firing_on(database, "2026-09-14") + assert [b.definition.id for b in hits] == [ + daily.definition.id, + weekly.definition.id, + ] + # 2026-09-15 is a Tuesday (weekday 1, day 15): daily + monthly. + hits = await list_definitions_firing_on(database, "2026-09-15") + assert [b.definition.id for b in hits] == [ + daily.definition.id, + monthly.definition.id, + ] + # 2026-09-16 is a Wednesday (weekday 2, day 16): daily + weekly. + hits = await list_definitions_firing_on(database, "2026-09-16") + assert [b.definition.id for b in hits] == [ + daily.definition.id, + weekly.definition.id, + ] + + # Deactivating a definition removes it from every future result. + await set_quest_definition_active(database, weekly.definition.id, False) + hits = await list_definitions_firing_on(database, "2026-09-14") + assert [b.definition.id for b in hits] == [daily.definition.id] + # The returned bundle carries the decoded rule, not the raw row. + assert hits[0].rule == ScheduleRule( + rule_type=RuleType.DAILY, start_date="2026-09-14" + ) + return hits + + _with_db(tmp_path, "list-firing-on.db")(_body) + + +@pytest.mark.parametrize( + "bad", + ["2026-9-4", "2026-13-01", "2026-02-30", "not-a-date", None, 20260914], +) +def test_list_definitions_firing_on_rejects_malformed_date(tmp_path, bad) -> None: + async def _body(database, children, rules, definitions, child): + await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + with pytest.raises(ValueError, match="date must be"): + await list_definitions_firing_on(database, bad) + return None + + _with_db(tmp_path, "list-firing-on-bad-date.db")(_body) + + +def test_list_query_snapshot_coherent_under_racing_edit(tmp_path) -> None: + """A racing edit cannot mix states into a query helper's snapshot. + + The query helper pauses mid-snapshot (inside its locked transaction) + while an edit that changes BOTH the rule and the windows is queued + behind the connection lock. Because the helper reads definitions, + rules and windows in one locked transaction, its returned bundle is + either the whole old state or the whole new state — never the old + rule paired with the new windows (the exact interleaving the unlocked + reads used to allow). The pause hook holds + ``list_windows`` mid-flight so the edit is deterministically queued + against the helper's lock. + """ + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + weekly = ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={2}, + start_date="2026-09-14", + ) + + import custom_components.nestquest.dao_rules as dao_rules + + original_list = dao_rules.QuestDefinitionsDao.list_windows + started = asyncio.Event() + release = asyncio.Event() + + async def _pausing_list(self, definition_id): + if not started.is_set(): + started.set() + await release.wait() + return await original_list(self, definition_id) + + dao_rules.QuestDefinitionsDao.list_windows = _pausing_list + try: + read_task = asyncio.ensure_future( + list_definitions_firing_on(database, "2026-09-16") + ) + await started.wait() + edit_task = asyncio.ensure_future( + edit_quest_definition( + database, + created.definition.id, + rule=weekly, + windows=["evening"], + ) + ) + await asyncio.sleep(0) + release.set() + result, edited = await asyncio.gather(read_task, edit_task) + finally: + dao_rules.QuestDefinitionsDao.list_windows = original_list + + # The helper finished before the queued edit, so its snapshot is + # the whole OLD state: the daily rule with its morning window. + assert [b.definition.id for b in result] == [created.definition.id] + assert result[0].rule == _daily_rule() + assert [w.window for w in result[0].windows] == ["morning"] + # The edit then reported the whole NEW state, not a mix either. + assert edited.rule == weekly + assert [w.window for w in edited.windows] == ["evening"] + return result + + _with_db(tmp_path, "list-snapshot-concurrent.db")(_body) + + +def test_list_definitions_for_child_snapshot_coherent_under_racing_unassign( + tmp_path, +) -> None: + """A racing unassign cannot strip the child from the child-scoped + snapshot that ``list_definitions_for_child`` returns. + + The helper pauses mid-snapshot (inside its locked transaction) while + an unassign of the very child it is listing is queued behind the + connection lock. Because the definition set and its assignees are + read in one locked transaction, the returned bundle still shows the + child assigned — the coherent pre-unassign snapshot — rather than the + mixed state (definition returned "for child" but with the child + already gone from its roster) that the unlocked reads used to allow. + The pause hook holds ``list_assignees`` mid-flight so the unassign is + deterministically queued against the helper's lock. + """ + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + + import custom_components.nestquest.dao_rules as dao_rules + + original_list = dao_rules.QuestDefinitionsDao.list_assignees + started = asyncio.Event() + release = asyncio.Event() + + async def _pausing_list(self, definition_id): + if not started.is_set(): + started.set() + await release.wait() + return await original_list(self, definition_id) + + dao_rules.QuestDefinitionsDao.list_assignees = _pausing_list + try: + read_task = asyncio.ensure_future( + list_definitions_for_child(database, child.id) + ) + await started.wait() + unassign_task = asyncio.ensure_future( + unassign_child(database, created.definition.id, child.id) + ) + for _ in range(5): + await asyncio.sleep(0) + release.set() + result, removed = await asyncio.gather(read_task, unassign_task) + finally: + dao_rules.QuestDefinitionsDao.list_assignees = original_list + + # The helper finished before the queued unassign, so its snapshot + # is the whole OLD state: the child still assigned to the + # definition it returned for that child. + assert [b.definition.id for b in result] == [created.definition.id] + assert [c.id for c in result[0].assignees] == [child.id] + assert removed is True + return result + + _with_db(tmp_path, "list-for-child-snapshot-concurrent.db")(_body) + + +def test_list_active_definitions_snapshot_coherent_under_racing_edit( + tmp_path, +) -> None: + """A racing edit cannot mix states into ``list_active_definitions``. + + The helper pauses mid-snapshot (inside its locked transaction) while + an edit that changes BOTH the rule and the windows is queued behind + the connection lock. Because definitions, rules and windows are read + in one locked transaction, the returned bundle is the whole old state + — the daily rule with its morning window — never the old rule paired + with the new windows. The pause hook holds ``list_windows`` + mid-flight so the edit is deterministically queued against the + helper's lock. + """ + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, "Brush teeth", _daily_rule(), [child.id], ["morning"] + ) + weekly = ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={2}, + start_date="2026-09-14", + ) + + import custom_components.nestquest.dao_rules as dao_rules + + original_list = dao_rules.QuestDefinitionsDao.list_windows + started = asyncio.Event() + release = asyncio.Event() + + async def _pausing_list(self, definition_id): + if not started.is_set(): + started.set() + await release.wait() + return await original_list(self, definition_id) + + dao_rules.QuestDefinitionsDao.list_windows = _pausing_list + try: + read_task = asyncio.ensure_future(list_active_definitions(database)) + await started.wait() + edit_task = asyncio.ensure_future( + edit_quest_definition( + database, + created.definition.id, + rule=weekly, + windows=["evening"], + ) + ) + await asyncio.sleep(0) + release.set() + result, edited = await asyncio.gather(read_task, edit_task) + finally: + dao_rules.QuestDefinitionsDao.list_windows = original_list + + # The helper finished before the queued edit, so its snapshot is + # the whole OLD state: the daily rule with its morning window. + assert [b.definition.id for b in result] == [created.definition.id] + assert result[0].rule == _daily_rule() + assert [w.window for w in result[0].windows] == ["morning"] + # The edit then reported the whole NEW state, not a mix either. + assert edited.rule == weekly + assert [w.window for w in edited.windows] == ["evening"] + return result + + _with_db(tmp_path, "list-active-snapshot-concurrent.db")(_body) + + +# --------------------------------------------------------------------------- +# full-lifecycle integration: one definition through every operation +# --------------------------------------------------------------------------- + + +def test_full_lifecycle_never_touches_instances_or_history(tmp_path) -> None: + """End-to-end lifecycle on one definition writes only the four + definition tables; ``quest_instances`` and ``completion_events`` are + left exactly as they were at every stage. + + A real instance and completion event are seeded before the lifecycle + begins, so each stage is checked against pre-existing data being + mutated (not an empty table staying empty): create -> edit + rule/windows -> assign a second child -> unassign the original child + -> deactivate -> reactivate. + """ + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, + "Brush teeth", + _daily_rule(), + [child.id], + [("morning", "07:00")], + ) + definition_id = created.definition.id + + instances = QuestInstancesDao(database) + events = CompletionEventsDao(database) + instance = await instances.upsert( + definition_id, child.id, _today_iso(), NOW, window="morning" + ) + event = await events.append( + instance.id, + child.id, + "completed", + "user", + NOW, + True, + actor_user_id="admin-1", + ) + instance_count = await database.fetch_one( + "SELECT COUNT(*) FROM quest_instances" + ) + event_count = await database.fetch_one( + "SELECT COUNT(*) FROM completion_events" + ) + + async def _assert_history_untouched(): + assert await instances.get_by_id(instance.id) == instance + assert await events.list_by_instance(instance.id) == [event] + assert await database.fetch_one( + "SELECT COUNT(*) FROM quest_instances" + ) == instance_count + assert await database.fetch_one( + "SELECT COUNT(*) FROM completion_events" + ) == event_count + + await _assert_history_untouched() + + # Edit rule and windows together; the definition's rule row is + # rewritten in place and its window set replaced. + edited = await edit_quest_definition( + database, + definition_id, + rule=ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={1, 3}, + start_date="2026-09-14", + ), + windows=[("morning", "08:00"), ("evening", "19:30")], + ) + assert edited.rule == ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={1, 3}, + start_date="2026-09-14", + ) + by_name = {w.window: w.due_time for w in edited.windows} + assert by_name == {"morning": "08:00", "evening": "19:30"} + await _assert_history_untouched() + + # Assign a second child: multi-assignee, both present. + bo = await children.create("Bo", NOW) + roster = await assign_child(database, definition_id, bo.id) + assert sorted(c.id for c in roster) == sorted([child.id, bo.id]) + await _assert_history_untouched() + + # Unassign the original child: only the link drops. + assert await unassign_child(database, definition_id, child.id) is True + assert [ + c.id for c in await definitions.list_assignees(definition_id) + ] == [bo.id] + await _assert_history_untouched() + + # Deactivate, then reactivate: the definition survives both. + deactivated = await set_quest_definition_active( + database, definition_id, False + ) + assert deactivated.definition.is_active is False + await _assert_history_untouched() + reactivated = await set_quest_definition_active( + database, definition_id, True + ) + assert reactivated.definition.is_active is True + await _assert_history_untouched() + + return reactivated + + _with_db(tmp_path, "full-lifecycle.db")(_body)