From 22797cbc7eea5238a2ed58a10c3504b7875a89f2 Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 18 Sep 2026 06:31:01 -0400 Subject: [PATCH] Completion business layer wrapping append-only DAO (Feature 08, task 1) (#88) --- custom_components/nestquest/completion.py | 165 +++++++++++ tests/test_completion.py | 339 ++++++++++++++++++++++ 2 files changed, 504 insertions(+) create mode 100644 custom_components/nestquest/completion.py create mode 100644 tests/test_completion.py diff --git a/custom_components/nestquest/completion.py b/custom_components/nestquest/completion.py new file mode 100644 index 0000000..4b512b4 --- /dev/null +++ b/custom_components/nestquest/completion.py @@ -0,0 +1,165 @@ +"""Business layer for the completion event log (Feature 08). + +Sits between the Feature 09 service gate and the typed +:class:`~.dao_instances.CompletionEventsDao`: callers get actor-shape +validation and the UTC occurred-at stamp here, and never talk to the +DAO or SQL directly. This module is the only Feature-08 caller of +:meth:`~.dao_instances.CompletionEventsDao.append`. Completing and +un-completing an instance (no-op-if-already-done, derived state) arrive +in later tasks; this module is the append wrapper only. + +Actor policy matches the schema CHECKs (D-008): ``actor_source`` is +``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'`` +requires ``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` +requires ``actor_child_id`` (the tapped profile) and forbids +``actor_user_id``. Inverted shapes raise a field-naming ValueError +before any write. + +``occurred_at`` is stamped UTC ISO-8601 at second precision with an +explicit ``+00:00`` offset, matching the DAO's one accepted shape so +lexicographic range queries stay exact. ``was_on_time`` is required +(True/False) for completed events; un-completions may pass None. There +is no permission check here (that is Feature 09). +""" +from __future__ import annotations + +import datetime + +from .dao_instances import ( + EVENT_COMPLETED, + EVENT_UNCOMPLETED, + CompletionEventRecord, + CompletionEventsDao, +) +from .db import NestQuestDatabase + +#: Timestamp policy for completion_events.occurred_at: strict UTC +#: ISO-8601 with explicit '+00:00' offset (module timestamp policy). +_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00" + + +def _now_stamp() -> str: + """Return the module's strict UTC second-precision stamp.""" + return datetime.datetime.now(datetime.timezone.utc).strftime( + _UTC_TIMESTAMP_FORMAT + ) + + +def _stamp_occurred_at(now: datetime.datetime | None) -> str: + """Return a DAO-accepted UTC stamp for ``now``, or the current time.""" + if now is None: + return _now_stamp() + if not isinstance(now, datetime.datetime): + raise ValueError(f"now must be a datetime, got {now!r}") + if now.tzinfo is None: + raise ValueError("now must be timezone-aware") + return now.astimezone(datetime.timezone.utc).strftime( + _UTC_TIMESTAMP_FORMAT + ) + + +def _validate_int_id(value: object, field: str) -> int: + """Reject non-int ids (bools included) before any lookup.""" + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{field} must be an integer, got {value!r}") + return value + + +def _validate_event_type(value: object) -> str: + """Return a schema-legal event type, or raise naming the field.""" + if value not in (EVENT_COMPLETED, EVENT_UNCOMPLETED): + raise ValueError( + f"event_type must be '{EVENT_COMPLETED}' or " + f"'{EVENT_UNCOMPLETED}', got {value!r}" + ) + return value + + +def _validate_actor( + actor_source: object, + actor_user_id: object, + actor_child_id: object, +) -> tuple[str, str | None, int | None]: + """Return a schema-legal actor triple, or raise naming the field. + + ``'user'`` requires a non-empty ``actor_user_id`` string and forbids + ``actor_child_id``. ``'panel'`` requires an integer + ``actor_child_id`` and forbids ``actor_user_id``. Any other + ``actor_source`` (including ``'service'``) is rejected. + """ + if actor_source not in ("user", "panel"): + raise ValueError( + f"actor_source must be 'user' or 'panel', got {actor_source!r}" + ) + if actor_source == "user": + if not isinstance(actor_user_id, str) or not actor_user_id.strip(): + raise ValueError("actor_source 'user' requires actor_user_id") + if actor_child_id is not None: + raise ValueError( + "actor_source 'user' must not carry actor_child_id" + ) + return "user", actor_user_id.strip(), None + if actor_user_id is not None: + raise ValueError("actor_source 'panel' must not carry actor_user_id") + if actor_child_id is None: + raise ValueError( + "actor_source 'panel' requires actor_child_id" + ) + return ( + "panel", + None, + _validate_int_id(actor_child_id, "actor_child_id"), + ) + + +def _validate_was_on_time( + event_type: str, was_on_time: object +) -> bool | None: + """Return a real bool or None; completed events require True/False.""" + if was_on_time is not None and type(was_on_time) is not bool: + raise ValueError( + f"was_on_time must be True, False or None, got {was_on_time!r}" + ) + if event_type == EVENT_COMPLETED and was_on_time is None: + raise ValueError( + "a completed event requires was_on_time (True/False)" + ) + return was_on_time + + +async def append_event( + database: NestQuestDatabase, + instance_id: int, + child_id: int, + event_type: str, + *, + actor_source: str, + actor_user_id: str | None = None, + actor_child_id: int | None = None, + was_on_time: bool | None = None, + now: datetime.datetime | None = None, +) -> CompletionEventRecord: + """Append one completion or un-completion event and return the row. + + Validates actor shapes and the completed-event on-time requirement + before calling :meth:`CompletionEventsDao.append`. ``occurred_at`` + is stamped here (UTC ``+00:00``); pass ``now`` to pin the clock. + """ + instance_id = _validate_int_id(instance_id, "instance_id") + child_id = _validate_int_id(child_id, "child_id") + event_type = _validate_event_type(event_type) + actor_source, actor_user_id, actor_child_id = _validate_actor( + actor_source, actor_user_id, actor_child_id + ) + was_on_time = _validate_was_on_time(event_type, was_on_time) + occurred_at = _stamp_occurred_at(now) + return await CompletionEventsDao(database).append( + instance_id, + child_id, + event_type, + actor_source, + occurred_at, + was_on_time, + actor_user_id=actor_user_id, + actor_child_id=actor_child_id, + ) diff --git a/tests/test_completion.py b/tests/test_completion.py new file mode 100644 index 0000000..5b4dc19 --- /dev/null +++ b/tests/test_completion.py @@ -0,0 +1,339 @@ +"""Tests for completion.py: the Feature 08 append-only business layer.""" +from __future__ import annotations + +import asyncio +import datetime +import inspect +import re + +import pytest + +from custom_components.nestquest.completion import append_event +from custom_components.nestquest.dao_children import ChildrenDao +from custom_components.nestquest.dao_instances import ( + EVENT_COMPLETED, + EVENT_UNCOMPLETED, + CompletionEventRecord, + QuestInstancesDao, +) +from custom_components.nestquest.dao_rules import ( + QuestDefinitionsDao, + ScheduleRulesDao, +) +from custom_components.nestquest.db import NestQuestDatabase +from custom_components.nestquest.migrations import apply_migrations + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +def _make_hass_mock(): + from unittest.mock import AsyncMock, MagicMock + + hass = MagicMock() + hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) + return hass + + +def _today_iso() -> str: + return datetime.date.today().isoformat() + + +def _now_stamp() -> str: + return f"{_today_iso()}T08:00:00+00:00" + + +def _future_day(offset: int) -> str: + return ( + datetime.date.today() + datetime.timedelta(days=offset) + ).isoformat() + + +D1 = _future_day(1) + + +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) + instances = QuestInstancesDao(database) + child = await children.create("Ada", _now_stamp()) + rule = await rules.create("daily", D1) + definition = await definitions.create( + "Brush teeth", rule.id, _now_stamp(), assignee_child_ids=[child.id] + ) + instance = await instances.upsert( + definition.id, child.id, D1, _now_stamp(), window="morning" + ) + return database, child, instance + + +def _with_db(tmp_path, name): + def _run_test(body): + async def _main(): + prepared = await _prepare(tmp_path / name) + try: + return await body(*prepared) + finally: + await prepared[0].close() + + return _run(_main()) + + return _run_test + + +# --------------------------------------------------------------------------- +# inverted actor shapes: field-naming ValueErrors, no write +# --------------------------------------------------------------------------- + + +def test_user_without_actor_user_id_rejected() -> None: + async def _body(): + with pytest.raises(ValueError, match="actor_user_id"): + await append_event( + object(), + 1, + 1, + EVENT_COMPLETED, + actor_source="user", + was_on_time=True, + ) + with pytest.raises(ValueError, match="actor_user_id"): + await append_event( + object(), + 1, + 1, + EVENT_COMPLETED, + actor_source="user", + actor_user_id=None, + was_on_time=True, + ) + with pytest.raises(ValueError, match="actor_user_id"): + await append_event( + object(), + 1, + 1, + EVENT_COMPLETED, + actor_source="user", + actor_user_id="", + was_on_time=True, + ) + + _run(_body()) + + +def test_user_must_not_carry_actor_child_id() -> None: + async def _body(): + with pytest.raises(ValueError, match="actor_child_id"): + await append_event( + object(), + 1, + 1, + EVENT_COMPLETED, + actor_source="user", + actor_user_id="user-1", + actor_child_id=1, + was_on_time=True, + ) + + _run(_body()) + + +def test_panel_must_not_carry_actor_user_id() -> None: + async def _body(): + with pytest.raises(ValueError, match="actor_user_id"): + await append_event( + object(), + 1, + 1, + EVENT_COMPLETED, + actor_source="panel", + actor_user_id="user-1", + actor_child_id=1, + was_on_time=True, + ) + + _run(_body()) + + +def test_panel_requires_actor_child_id() -> None: + async def _body(): + with pytest.raises(ValueError, match="actor_child_id"): + await append_event( + object(), + 1, + 1, + EVENT_COMPLETED, + actor_source="panel", + was_on_time=True, + ) + + _run(_body()) + + +def test_actor_source_service_rejected() -> None: + async def _body(): + with pytest.raises(ValueError, match="actor_source"): + await append_event( + object(), + 1, + 1, + EVENT_COMPLETED, + actor_source="service", + actor_user_id="user-1", + was_on_time=True, + ) + + _run(_body()) + + +def test_completed_requires_was_on_time() -> None: + async def _body(): + with pytest.raises(ValueError, match="was_on_time"): + await append_event( + object(), + 1, + 1, + EVENT_COMPLETED, + actor_source="user", + actor_user_id="user-1", + ) + + _run(_body()) + + +# --------------------------------------------------------------------------- +# happy paths +# --------------------------------------------------------------------------- + + +def test_user_append_round_trips(tmp_path) -> None: + async def _body(database, child, instance): + event = await append_event( + database, + instance.id, + child.id, + EVENT_COMPLETED, + actor_source="user", + actor_user_id="user-1", + was_on_time=True, + ) + assert isinstance(event, CompletionEventRecord) + assert event.event_type == EVENT_COMPLETED + assert event.actor_source == "user" + assert event.actor_user_id == "user-1" + assert event.actor_child_id is None + assert event.was_on_time is True + assert re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00", event.occurred_at + ) + return event + + _with_db(tmp_path, "user-append.db")(_body) + + +def test_panel_append_round_trips(tmp_path) -> None: + async def _body(database, child, instance): + event = await append_event( + database, + instance.id, + child.id, + EVENT_COMPLETED, + actor_source="panel", + actor_child_id=child.id, + was_on_time=False, + ) + assert event.actor_source == "panel" + assert event.actor_user_id is None + assert event.actor_child_id == child.id + assert event.was_on_time is False + assert re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00", event.occurred_at + ) + return event + + _with_db(tmp_path, "panel-append.db")(_body) + + +def test_uncompleted_allows_null_was_on_time(tmp_path) -> None: + async def _body(database, child, instance): + event = await append_event( + database, + instance.id, + child.id, + EVENT_UNCOMPLETED, + actor_source="user", + actor_user_id="user-1", + was_on_time=None, + ) + assert event.event_type == EVENT_UNCOMPLETED + assert event.was_on_time is None + return event + + _with_db(tmp_path, "uncompleted-null.db")(_body) + + +def test_now_pins_occurred_at_utc(tmp_path) -> None: + async def _body(database, child, instance): + pinned = datetime.datetime( + 2026, 9, 18, 8, 0, 0, tzinfo=datetime.timezone.utc + ) + event = await append_event( + database, + instance.id, + child.id, + EVENT_COMPLETED, + actor_source="user", + actor_user_id="user-1", + was_on_time=True, + now=pinned, + ) + assert event.occurred_at == "2026-09-18T08:00:00+00:00" + return event + + _with_db(tmp_path, "pinned-now.db")(_body) + + +# --------------------------------------------------------------------------- +# append-only: the module never exposes update/delete +# --------------------------------------------------------------------------- + + +def test_module_never_exposes_update_or_delete() -> None: + import custom_components.nestquest.completion as completion + + public = { + name + for name, _ in inspect.getmembers(completion, inspect.isfunction) + if not name.startswith("_") + } + forbidden = { + name + for name in public + if name.startswith( + ( + "update", + "delete", + "set", + "clear", + "remove", + "modify", + "edit", + "upsert", + ) + ) + } + assert forbidden == set(), ( + f"completion.py exposed mutation methods: {forbidden}" + ) + assert "append_event" in public + assert "complete_instance" not in public + assert "uncomplete_instance" not in public + source = inspect.getsource(completion) + assert "UPDATE" not in source + assert "DELETE" not in source + assert "INSERT" not in source