diff --git a/api/nestquest_core.py b/api/nestquest_core.py index 1ce326e..af50cd2 100644 --- a/api/nestquest_core.py +++ b/api/nestquest_core.py @@ -142,13 +142,35 @@ core_quest_definitions = importlib.import_module( #: re-implement rule validation. core_recurrence = importlib.import_module(f"{_CORE_MODULE_NAME}.recurrence") +#: The presence model (``core.presence.PresenceEngine``, +#: ``PresenceSchedule``, ``PresenceOverride``) the admin plane's +#: presence routes serialize and the tests resolve presence with. +core_presence = importlib.import_module(f"{_CORE_MODULE_NAME}.presence") + +#: The presence-write business layer +#: (``core.presence_management.set_presence_schedule`` et al.) the +#: admin plane's presence routes call — the ONE implementation the HA +#: services share. +core_presence_management = importlib.import_module( + f"{_CORE_MODULE_NAME}.presence_management" +) + +#: The typed presence DAOs (``core.dao_presence``) the admin-plane tests +#: read stored schedules and overrides back through. +core_dao_presence = importlib.import_module( + f"{_CORE_MODULE_NAME}.dao_presence" +) + __all__ = [ "core", "core_children", "core_completion", + "core_dao_presence", "core_db", "core_events", "core_migrations", + "core_presence", + "core_presence_management", "core_quest_definitions", "core_recurrence", "core_snapshot", diff --git a/api/routes_admin.py b/api/routes_admin.py index 3ed4a71..babc316 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -35,8 +35,32 @@ through the API: the core's edit path takes no assignees. The edit route forwards only SUPPLIED fields (``exclude_unset``), like the children edit. -Error mapping (every children and quest-definition route, deliberately -narrow): +The presence routes follow the same pattern over +:mod:`nestquest_core.presence_management` — the ONE presence write +layer the HA services share, so the two planes cannot drift: + +- ``PUT /children/{child_id}/presence-schedule`` sets (UPSERTS) the + child's repeating schedule: the body's ``cycle_length_weeks``, + ``anchor_date`` and ``pattern`` go straight into ONE + :class:`~nestquest_core.presence.PresenceSchedule` (the model + validates cycle length, the strict anchor date and the pattern's + week coverage at construction), the core upserts by child (setting + again REPLACES the one schedule a child can have) and regenerates + the child's future instances; the response is the STORED schedule + decoded back through the model. +- ``POST /presence-overrides`` creates one date-range override: the + body becomes ONE :class:`~nestquest_core.presence.PresenceOverride` + (the model validates the dates, ``end >= start``, the real bool and + the note), the core creates it — rejecting a range that overlaps the + same child's existing override — and regenerates; the response is + the stored record with its ``id``. +- ``DELETE /presence-overrides/{override_id}`` deletes one override + (children are never hard-deleted; overrides are deletable by + design) and regenerates that override's child. It answers + ``{"status": "ok"}``. + +Error mapping (every children, quest-definition and presence route, +deliberately narrow): - Malformed input the body model or the path parser rejects (a missing or wrong-typed field, a non-integer child or definition id) is @@ -55,10 +79,21 @@ narrow): ``rule_type``, a shape-missing field such as a weekly rule without weekdays, a malformed date, a forbidden field), a bad window name or due time, a rejected window or assignee list (empty, duplicate, - wrong-typed) — is mapped to 422: the body was well-formed JSON but + wrong-typed); a rejected presence schedule or override (bad cycle + length, a pattern not covering the cycle's weeks, a malformed or + inverted date range, an override overlapping the same child's + existing one) — is mapped to 422: the body was well-formed JSON but semantically invalid, the same failure class Pydantic reports. The core rejects these BEFORE any write, so a 422 never leaves a half-applied change behind. +- A non-existent CHILD on a presence route (setting a schedule or + creating an override for an unknown id) is mapped to 404 through the + SAME :func:`_raise_child_error` the children routes use — the + message reads ``child N does not exist``. A non-existent OVERRIDE + on the delete route is mapped to 404 through + :func:`_raise_presence_override_error`, keyed on the + ``override_id:`` prefix the core's unknown-id error carries (the + same convention as the quest-definition 404). PATCH edit semantics: the body model's fields are optional and only SUPPLIED fields are forwarded (``model_dump(exclude_unset=True)``), so @@ -78,6 +113,8 @@ from api.auth import require_admin from api.database import DatabaseState from api.nestquest_core import ( core_children, + core_presence, + core_presence_management, core_quest_definitions, core_recurrence, ) @@ -294,6 +331,70 @@ class AdminQuestDefinitionActiveRequest(BaseModel): is_active: bool +class AdminPresenceScheduleRequest(BaseModel): + """The body of ``PUT /api/v1/admin/children/{child_id}/presence-schedule``. + + The shape of :class:`~nestquest_core.presence.PresenceSchedule` — + nothing more. ``pattern`` maps each cycle week index (JSON object + keys, coerced to ints by the model) to that week's present weekdays + (Monday=0); every week of the cycle must be covered, which the + PRESENCE MODEL enforces at construction, not this shape. A week + with an empty list means absent every day of that week. + """ + + cycle_length_weeks: int + anchor_date: str + pattern: dict[int, list[int]] + + +class AdminPresenceScheduleResponse(BaseModel): + """A child's repeating presence schedule as the admin plane serializes it. + + The stored schedule decoded back through + :meth:`nestquest_core.presence.PresenceSchedule.decode`: each + pattern week is the sorted list of present weekdays, and the child + has exactly ONE schedule (setting again replaces it). + """ + + child_id: int + cycle_length_weeks: int + anchor_date: str + pattern: dict[int, list[int]] + + +class AdminPresenceOverrideCreateRequest(BaseModel): + """The body of ``POST /api/v1/admin/presence-overrides``. + + The shape of :class:`~nestquest_core.presence.PresenceOverride` — + the strict ISO dates, the ``end_date >= start_date`` order, the + real ``is_present`` bool and the optional note are ALL enforced by + the model's constructor, which the route builds directly from this + body; this model only guards the SHAPE. + """ + + child_id: int + start_date: str + end_date: str + is_present: bool + note: str | None = None + + +class AdminPresenceOverrideResponse(BaseModel): + """One presence override as the admin plane serializes it. + + Mirrors + :class:`~nestquest_core.dao_presence.PresenceOverrideRecord` — the + ``id`` is the handle the DELETE route takes. + """ + + id: int + child_id: int + start_date: str + end_date: str + is_present: bool + note: str | None + + #: 404 detail for a child id the core layer reports as non-existent. _CHILD_NOT_FOUND_DETAIL = "Child not found" @@ -355,6 +456,28 @@ def _raise_quest_definition_error(error: ValueError) -> NoReturn: raise HTTPException(status_code=422, detail=str(error)) from error +#: 404 detail for an override id the core layer reports as non-existent. +_PRESENCE_OVERRIDE_NOT_FOUND_DETAIL = "Presence override not found" + + +def _raise_presence_override_error(error: ValueError) -> NoReturn: + """Map a core presence ``ValueError`` from the DELETE route onto 404/422. + + The mapping mirrors :func:`_raise_quest_definition_error`: the core + names a missing override by prefixing its error with + ``override_id:`` (an unknown id), and that one case is this route's + 404. Every other ``ValueError`` — a non-integer id the path + parser's ``int`` already makes unreachable — becomes 422. The + schedule and override-CREATE routes do NOT use this mapper: their + only 404 is an unknown CHILD, mapped by :func:`_raise_child_error`. + """ + if str(error).startswith("override_id:"): + raise HTTPException( + status_code=404, detail=_PRESENCE_OVERRIDE_NOT_FOUND_DETAIL + ) from error + raise HTTPException(status_code=422, detail=str(error)) from error + + def _rule_from_request(rule: AdminRuleRequest) -> core_recurrence.ScheduleRule: """Build the core ScheduleRule from the request's ``rule`` object. @@ -398,6 +521,34 @@ def _quest_definition_response( ) +def _presence_schedule_response( + schedule: core_presence.PresenceSchedule, +) -> AdminPresenceScheduleResponse: + """Serialize one decoded core PresenceSchedule into the payload.""" + return AdminPresenceScheduleResponse( + child_id=schedule.child_id, + cycle_length_weeks=schedule.cycle_length_weeks, + anchor_date=schedule.anchor_date.isoformat(), + pattern={ + week: sorted(days) for week, days in schedule.pattern.items() + }, + ) + + +def _presence_override_response( + record: core_presence_management.PresenceOverrideRecord, +) -> AdminPresenceOverrideResponse: + """Serialize one core PresenceOverrideRecord into the payload.""" + return AdminPresenceOverrideResponse( + id=record.id, + child_id=record.child_id, + start_date=record.start_date, + end_date=record.end_date, + is_present=record.is_present, + note=record.note, + ) + + @router.get("/ping", summary="Admin plane probe") async def admin_ping( claims: Annotated[dict[str, object], Depends(require_admin)], @@ -681,4 +832,114 @@ async def admin_set_quest_definition_active( return _quest_definition_response(bundle) +@router.put( + "/children/{child_id}/presence-schedule", + summary="Set a child's repeating presence schedule", + response_model=AdminPresenceScheduleResponse, +) +async def admin_set_presence_schedule( + child_id: int, body: AdminPresenceScheduleRequest, request: Request +) -> AdminPresenceScheduleResponse: + """Set (upsert) a child's repeating presence schedule. + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: the body model + carries the shape, and the cycle-length, anchor-date and + pattern-coverage policies all live in the + :class:`~nestquest_core.presence.PresenceSchedule` constructor the + core builds the schedule with — setting again REPLACES the child's + one schedule, and the core regenerates the child's future instances + so they track the new presence. Errors are mapped by + :func:`_raise_child_error`: an unknown child is 404, a rejected + schedule 422. + """ + state: DatabaseState = request.app.state.db + try: + schedule = await core_presence_management.set_presence_schedule( + state.database, + child_id, + body.cycle_length_weeks, + body.anchor_date, + body.pattern, + ) + except ValueError as error: + _raise_child_error(error) + return _presence_schedule_response(schedule) + + +@router.post( + "/presence-overrides", + summary="Create a date-range presence override", + status_code=201, + response_model=AdminPresenceOverrideResponse, +) +async def admin_create_presence_override( + body: AdminPresenceOverrideCreateRequest, request: Request +) -> AdminPresenceOverrideResponse: + """Create one date-range presence override and return it with its id. + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: the body model + carries the shape, and the date/order/bool/note policies live in + the :class:`~nestquest_core.presence.PresenceOverride` constructor + while the same-child no-overlap policy lives in the core's create + path — which then regenerates the child's future instances so an + absent range removes them and a present range restores them. The + response carries the stored ``id``, the handle + ``DELETE /presence-overrides/{id}`` takes. + + Errors are mapped by :func:`_raise_child_error`: an unknown child + is 404; a rejected override (malformed or inverted dates, an + overlapping range) is 422. + """ + state: DatabaseState = request.app.state.db + try: + record = await core_presence_management.create_presence_override( + state.database, + body.child_id, + body.start_date, + body.end_date, + body.is_present, + note=body.note, + ) + except ValueError as error: + _raise_child_error(error) + return _presence_override_response(record) + + +@router.delete( + "/presence-overrides/{override_id}", + summary="Delete a presence override", +) +async def admin_delete_presence_override( + override_id: int, request: Request +) -> dict[str, str]: + """Delete one presence override; children are never hard-deleted. + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: the id goes + straight to + :func:`nestquest_core.presence_management.delete_presence_override`, + which looks the override up first (an unknown id is refused BEFORE + any delete), removes it and regenerates ITS child's future + instances — the id, never a caller-supplied child, decides which + child's presence is rebuilt. On success the route answers + ``{"status": "ok"}``. + + Error mapping (:func:`_raise_presence_override_error`): an unknown + override id is 404; every other core ``ValueError`` is 422. + """ + state: DatabaseState = request.app.state.db + try: + await core_presence_management.delete_presence_override( + state.database, override_id + ) + except ValueError as error: + _raise_presence_override_error(error) + return {"status": "ok"} + + __all__ = ["router"] diff --git a/custom_components/nestquest/core/presence_management.py b/custom_components/nestquest/core/presence_management.py new file mode 100644 index 0000000..2b48edb --- /dev/null +++ b/custom_components/nestquest/core/presence_management.py @@ -0,0 +1,161 @@ +"""Business layer for presence writes (Feature 09, API admin plane). + +Sits between the write callers (the Feature 09 HA service handlers and +the API admin plane's presence routes) and the typed presence DAOs plus +the pure presence model: callers get the model validation, the upsert / +create / delete operations and the regeneration side effect here, and +never touch the DAO or SQL directly. This is the ONE implementation of +these business rules — every caller constructs the same +:class:`~.presence.PresenceSchedule` / :class:`~.presence.PresenceOverride` +models (whose constructors are total, so no re-validation happens in a +handler) and gets the same post-write +:func:`~.materialize.regenerate_for_child`, so a child's future quest +instances track its changed presence no matter which plane issued the +write. Like the model it is Home-Assistant-free: ``today`` is a plain +calendar date threaded through to the regeneration, defaulting to the +host clock there. + +Upsert policy (Feature 05, unchanged): a child has AT MOST ONE presence +schedule — setting again REPLACES the existing row rather than +duplicating it. Overrides are deletable by design (children are not — +the children layer owns that guardrail). Deleting a nonexistent +override raises ValueError prefixed ``override_id:`` so a caller can +distinguish "unknown id" from a rejected argument the same way the +quest-definitions layer prefixes ``definition_id:``. +""" +from __future__ import annotations + +import datetime + +from .dao_presence import ( + PresenceOverrideRecord, + PresenceOverridesDao, + PresenceSchedulesDao, +) +from .db import NestQuestDatabase +from .materialize import regenerate_for_child +from .presence import PresenceOverride, PresenceSchedule + + +def _validate_override_id(value: object) -> int: + """Reject non-int override ids (bools would silently address id 1).""" + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"override_id must be an integer, got {value!r}") + return value + + +async def set_presence_schedule( + database: NestQuestDatabase, + child_id: int, + cycle_length_weeks: int, + anchor_date: object, + pattern: dict[int, object], + *, + today: datetime.date | None = None, + horizon_days: int | None = None, +) -> PresenceSchedule: + """Set (upsert) a child's repeating presence schedule. + + The :class:`~.presence.PresenceSchedule` model validates at + construction — cycle length 1..4, a strict calendar anchor date, + and a pattern covering week indices 0..n-1 with weekday sets — so + no invalid schedule reaches the DAO. The encoded pattern is + UPSERTed by child (a second set REPLACES the child's existing + schedule; the schema's UNIQUE(child_id) makes duplication + unrepresentable), then :func:`~.materialize.regenerate_for_child` + re-materializes the child's future instances against the new + presence. ``today``/``horizon_days`` thread through to that + regeneration; raising ValueError names what to fix (an unknown + child reads ``child N does not exist``). + + Returns the STORED schedule decoded back through the model — the + round-trip proves the encoded pattern the database now holds is + exactly the one the caller asked for. + """ + schedule = PresenceSchedule( + child_id, cycle_length_weeks, anchor_date, pattern + ) + record = await PresenceSchedulesDao(database).upsert_by_child( + schedule.child_id, + schedule.cycle_length_weeks, + schedule.anchor_date.isoformat(), + schedule.encode(), + ) + await regenerate_for_child( + database, schedule.child_id, today=today, horizon_days=horizon_days + ) + return PresenceSchedule.decode( + record.child_id, record.anchor_date, record.pattern + ) + + +async def create_presence_override( + database: NestQuestDatabase, + child_id: int, + start_date: object, + end_date: object, + is_present: bool, + note: str | None = None, + *, + today: datetime.date | None = None, + horizon_days: int | None = None, +) -> PresenceOverrideRecord: + """Create one date-range presence override and return it as stored. + + The :class:`~.presence.PresenceOverride` model validates at + construction — strict calendar dates, ``end_date`` on or after + ``start_date``, a real ``is_present`` bool, a string-or-None note — + then the DAO insert applies the same-child no-overlap policy (a + conflicting range raises ValueError naming the existing override) + and :func:`~.materialize.regenerate_for_child` re-materializes the + child's future instances against the override's presence answer. + An unknown child reads ``child N does not exist``. + + Returns the stored record WITH its ``id`` — the handle a later + :func:`delete_presence_override` takes. + """ + override = PresenceOverride( + child_id, start_date, end_date, is_present, note=note + ) + record = await PresenceOverridesDao(database).create( + override.child_id, + override.start_date.isoformat(), + override.end_date.isoformat(), + override.is_present, + note=override.note, + ) + await regenerate_for_child( + database, override.child_id, today=today, horizon_days=horizon_days + ) + return record + + +async def delete_presence_override( + database: NestQuestDatabase, + override_id: int, + *, + today: datetime.date | None = None, + horizon_days: int | None = None, +) -> PresenceOverrideRecord: + """Delete one presence override by id and return it as it was stored. + + The override is looked up FIRST and an unknown id raises ValueError + prefixed ``override_id:`` (naming the id) BEFORE any delete, so a + mistyped id can never be silently swallowed by a no-op. The + deletion is followed by :func:`~.materialize.regenerate_for_child` + for the override's own child — the id decides the child, never a + caller-supplied one. ``today``/``horizon_days`` thread through to + that regeneration. + """ + _validate_override_id(override_id) + dao = PresenceOverridesDao(database) + override = await dao.get(override_id) + if override is None: + raise ValueError( + f"override_id: presence override {override_id} does not exist" + ) + await dao.delete(override_id) + await regenerate_for_child( + database, override.child_id, today=today, horizon_days=horizon_days + ) + return override diff --git a/custom_components/nestquest/presence_management.py b/custom_components/nestquest/presence_management.py new file mode 100644 index 0000000..92f89be --- /dev/null +++ b/custom_components/nestquest/presence_management.py @@ -0,0 +1,10 @@ +"""Re-export of :mod:`custom_components.nestquest.core.presence_management` for the integration package. + +The presence-write business layer lives in +:mod:`custom_components.nestquest.core.presence_management` (added for +task e4a9d31b); this module re-exports it so the integration's service +handlers call the ONE implementation the API admin plane shares. +""" +from __future__ import annotations + +from .core.presence_management import * # noqa: F401,F403 diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py index 2ce49dd..d5606c7 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -4,9 +4,11 @@ Registration is domain-scoped and happens once (see :func:`async_register_services`); handlers resolve a live database at call time through the injected ``find_runtime`` callable. Permission checks are Feature 09 later tasks — this module only validates payload -shape and forwards to the Feature 03/06/08 business layers (and the -presence DAOs). ``export_history_csv`` is registered with a schema but -raises until Feature 13. ``regenerate`` is supplied by the caller so +shape and forwards to the Feature 03/06/08 business layers (the +presence writes included, through :mod:`.presence_management` — the +ONE implementation the API admin plane shares). +``export_history_csv`` is registered with a schema but raises until +Feature 13. ``regenerate`` is supplied by the caller so the Feature 07 handler stays the one in ``__init__``. """ from __future__ import annotations @@ -22,6 +24,7 @@ from homeassistant.exceptions import HomeAssistantError from . import children as children_layer from . import completion as completion_layer +from . import presence_management as presence_management_layer from . import quest_definitions as quest_definitions_layer from .const import ( DOMAIN, @@ -39,11 +42,8 @@ from .const import ( SERVICE_UNCOMPLETE_QUEST, SERVICE_UPDATE_QUEST_DEFINITION, ) -from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao from .events import fire_quest_completed, fire_quest_uncompleted -from .materialize import regenerate_for_child from .permissions import permission_gate -from .presence import PresenceSchedule from .recurrence import ScheduleRule from .service_policy import SERVICE_POLICY @@ -405,21 +405,12 @@ def _set_presence_pattern( async def _handler(call: Any) -> None: runtime = _require_runtime(hass, find_runtime) data = call.data - schedule = PresenceSchedule( + await presence_management_layer.set_presence_schedule( + runtime.database, data["child_id"], data["cycle_length_weeks"], data["anchor_date"], data["pattern"], - ) - await PresenceSchedulesDao(runtime.database).upsert_by_child( - schedule.child_id, - schedule.cycle_length_weeks, - schedule.anchor_date.isoformat(), - schedule.encode(), - ) - await regenerate_for_child( - runtime.database, - schedule.child_id, **_generation_kwargs(hass, runtime), ) @@ -432,16 +423,13 @@ def _create_presence_override( async def _handler(call: Any) -> None: runtime = _require_runtime(hass, find_runtime) data = call.data - await PresenceOverridesDao(runtime.database).create( + await presence_management_layer.create_presence_override( + runtime.database, data["child_id"], data["start_date"], data["end_date"], data["is_present"], note=data.get("note"), - ) - await regenerate_for_child( - runtime.database, - data["child_id"], **_generation_kwargs(hass, runtime), ) @@ -453,17 +441,9 @@ def _delete_presence_override( ) -> ServiceHandler: async def _handler(call: Any) -> None: runtime = _require_runtime(hass, find_runtime) - dao = PresenceOverridesDao(runtime.database) - override_id = call.data["override_id"] - override = await dao.get(override_id) - if override is None: - raise HomeAssistantError( - f"override_id: presence override {override_id} does not exist" - ) - await dao.delete(override_id) - await regenerate_for_child( + await presence_management_layer.delete_presence_override( runtime.database, - override.child_id, + call.data["override_id"], **_generation_kwargs(hass, runtime), ) diff --git a/tests/test_api_admin_presence.py b/tests/test_api_admin_presence.py new file mode 100644 index 0000000..e86b878 --- /dev/null +++ b/tests/test_api_admin_presence.py @@ -0,0 +1,571 @@ +"""Admin-plane presence route tests (task e4a9d31b). + +These tests exercise the admin plane's presence routes against the ASGI +app with httpx, reusing the shared local-RSA-key/stubbed-JWKS harness +(:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely +authenticates — signature, issuer, audience, expiry and the +nestquest-admins group — with NO network access. + +Every done-condition case is proven against the DATABASE and the DOMAIN, +read through the SAME core copy the app uses (``nestquest_core.*``; see +the two-copy caveat in api/nestquest_core.py): + +- Authentication runs through the router's ONE ``require_admin`` + dependency: an admin JWT passes, a JWT without the group is 403, an + absent credential is 401 — and nothing is written. +- ``PUT /api/v1/admin/children/{id}/presence-schedule`` UPSERTS: the + first set persists the schedule, setting again REPLACES it — one + schedule row per child, never a duplicate. +- ``POST /api/v1/admin/presence-overrides`` creates the override (its + ``id`` comes back) and ``DELETE /api/v1/admin/presence-overrides/{id}`` + removes it again. +- The child's RESOLVED presence changes accordingly: the pure + :class:`~nestquest_core.presence.PresenceEngine` built from the + stored rows answers differently before/after each write — a schedule + restricts the child's present weekdays, an override beats the pattern + for its range, and deleting the override restores the pattern's + answer. +- The regeneration side effect lives in the core layer the route + calls: setting an all-absent schedule through the route deletes the + child's future open quest instances and they are NOT rebuilt while + the child is absent every day — the same proof the HA service test + makes, through the API instead. + +Error mapping (documented in api/routes_admin.py): a core ValueError +for a non-existent child (schedule or override create) or override +(delete) is 404; every other core ValueError — a rejected schedule +(cycle length, pattern coverage, anchor date) or override (malformed or +inverted dates, an overlapping range) — is 422, the same failure class +FastAPI reports for malformed bodies. +""" +from __future__ import annotations + +import datetime +import importlib +from types import SimpleNamespace + +import pytest + +from tests.admin_jwt_harness import ( + ADMIN_KEY, + KID, + AdminRunner, + jwks_for, + make_token, +) + +# The presence model, DAOs, business layer and materializer through the +# SAME core copy the app uses (nestquest_core.*, NOT the conftest-stubbed +# custom_components copy) — used for the tests' database and resolved- +# presence assertions only; the routes reach them through api.nestquest_core. +_children = importlib.import_module("nestquest_core.children") +_dao_presence = importlib.import_module("nestquest_core.dao_presence") +_dao_instances = importlib.import_module("nestquest_core.dao_instances") +_materialize = importlib.import_module("nestquest_core.materialize") +_presence = importlib.import_module("nestquest_core.presence") + +#: A Monday anchoring every schedule: cycle week 0 starts here. +ANCHOR_MONDAY = "2026-01-05" +#: The Tuesday of that week — absent under the test schedule. +TUESDAY = "2026-01-06" + + +def _admin_headers() -> dict[str, str]: + """The Authorization header of a valid nestquest-admins JWT.""" + return {"Authorization": f"Bearer {make_token()}"} + + +def _schedule_body( + cycle_length_weeks: int = 1, pattern: dict[int, list[int]] | None = None +) -> dict[str, object]: + """A valid schedule body anchored to the test Monday.""" + return { + "cycle_length_weeks": cycle_length_weeks, + "anchor_date": ANCHOR_MONDAY, + "pattern": {"0": [0, 2, 4]} if pattern is None else pattern, + } + + +def _override_body( + child_id: int, + *, + start_date: str = TUESDAY, + end_date: str = TUESDAY, + is_present: bool = True, +) -> dict[str, object]: + """A valid single-day override body for the test child.""" + return { + "child_id": child_id, + "start_date": start_date, + "end_date": end_date, + "is_present": is_present, + "note": "grandmother's week", + } + + +async def _create_child(client: object, name: str = "Ada") -> int: + """Create one child through the admin children route; return the id.""" + created = await client.post( + "/api/v1/admin/children", + json={"display_name": name}, + headers=_admin_headers(), + ) + assert created.status_code == 201 + return created.json()["id"] + + +def _engine( + database: object, + child_id: int, + schedule_record: object | None, + override_records: list[object], +) -> object: + """Build the pure PresenceEngine from stored rows (the snapshot shape). + + Mirrors what the core snapshot builder assembles: the child's + decoded schedule (absent → present every day) plus its overrides, + so :meth:`PresenceEngine.is_present` answers from the SAME rows the + routes produced. + """ + schedules: dict[int, object] = {} + if schedule_record is not None: + schedules[child_id] = _presence.PresenceSchedule.decode( + schedule_record.child_id, + schedule_record.anchor_date, + schedule_record.pattern, + ) + overrides = { + child_id: [ + _presence.PresenceOverride( + record.child_id, + record.start_date, + record.end_date, + record.is_present, + note=record.note, + ) + for record in override_records + ] + } + return _presence.PresenceEngine(schedules, overrides) + + +async def _child_state( + database: object, child_id: int +) -> tuple[object | None, list[object], object]: + """The child's stored schedule record, overrides and PresenceEngine.""" + schedule_record = await _dao_presence.PresenceSchedulesDao( + database + ).get_by_child(child_id) + override_records = await _dao_presence.PresenceOverridesDao( + database + ).list_by_child_and_range(child_id, "2020-01-01", "2030-01-01") + engine = _engine(database, child_id, schedule_record, override_records) + return schedule_record, override_records, engine + + +@pytest.fixture +async def admin_presence(temp_db_path: str) -> SimpleNamespace: + """An admin-plane app client plus its live database. + + The same runner the auth, children and quest-definition tests use + (stubbed JWKS, no network); the fixture also exposes the app's ONE + database connection so tests assert the routes' effects on the + stored rows and the resolved presence built from them. + """ + runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) + async with runner as client: + yield SimpleNamespace( + client=client, + database=runner.app.state.db.database, + ) + + +# --- authentication: the ONE require_admin dependency guards every route --- + + +async def test_presence_routes_refuse_non_admin_and_absent_credentials( + admin_presence: SimpleNamespace, +) -> None: + """A non-admin JWT is 403, an absent credential is 401: nothing written.""" + client = admin_presence.client + child_id = await _create_child(client) + non_admin = make_token(groups=("some-other-group",)) + non_admin_headers = {"Authorization": f"Bearer {non_admin}"} + + forbidden_schedule = await client.put( + f"/api/v1/admin/children/{child_id}/presence-schedule", + json=_schedule_body(), + headers=non_admin_headers, + ) + assert forbidden_schedule.status_code == 403 + assert forbidden_schedule.json()["detail"] == ( + "Admin access requires membership in the nestquest-admins group" + ) + + forbidden_override = await client.post( + "/api/v1/admin/presence-overrides", + json=_override_body(child_id), + headers=non_admin_headers, + ) + assert forbidden_override.status_code == 403 + + forbidden_delete = await client.delete( + "/api/v1/admin/presence-overrides/1", headers=non_admin_headers + ) + assert forbidden_delete.status_code == 403 + + unauthenticated_schedule = await client.put( + f"/api/v1/admin/children/{child_id}/presence-schedule", + json=_schedule_body(), + ) + assert unauthenticated_schedule.status_code == 401 + + unauthenticated_override = await client.post( + "/api/v1/admin/presence-overrides", json=_override_body(child_id) + ) + assert unauthenticated_override.status_code == 401 + + unauthenticated_delete = await client.delete( + "/api/v1/admin/presence-overrides/1" + ) + assert unauthenticated_delete.status_code == 401 + + # No route ran its handler: the child still has no schedule and no + # overrides (and is present every day, the no-schedule default). + _schedule, _overrides, engine = await _child_state( + admin_presence.database, child_id + ) + assert _schedule is None + assert _overrides == [] + assert engine.is_present(child_id, ANCHOR_MONDAY) is True + + +# --- schedule upsert -------------------------------------------------------- + + +async def test_schedule_upserts_and_replaces_one_row_per_child( + admin_presence: SimpleNamespace, +) -> None: + """PUT /presence-schedule persists, and setting again REPLACES it.""" + client = admin_presence.client + child_id = await _create_child(client) + + first = await client.put( + f"/api/v1/admin/children/{child_id}/presence-schedule", + json=_schedule_body(), + headers=_admin_headers(), + ) + assert first.status_code == 200 + assert first.json() == { + "child_id": child_id, + "cycle_length_weeks": 1, + "anchor_date": ANCHOR_MONDAY, + "pattern": {"0": [0, 2, 4]}, + } + # Database proof: exactly ONE schedule row (the schema's + # UNIQUE(child_id) makes a second row unrepresentable), stored as + # encoded CSV. + schedule, _overrides, _engine = await _child_state( + admin_presence.database, child_id + ) + assert schedule is not None + row_id = schedule.id + assert schedule.cycle_length_weeks == 1 + assert schedule.anchor_date == ANCHOR_MONDAY + assert schedule.pattern == "0,2,4" + + # Setting again UPSERTS: the SAME row is updated in place — new + # values, unchanged id — never a duplicate or a stale first set. + second = await client.put( + f"/api/v1/admin/children/{child_id}/presence-schedule", + json=_schedule_body( + cycle_length_weeks=2, pattern={"0": [], "1": [1, 3]} + ), + headers=_admin_headers(), + ) + assert second.status_code == 200 + assert second.json() == { + "child_id": child_id, + "cycle_length_weeks": 2, + "anchor_date": ANCHOR_MONDAY, + "pattern": {"0": [], "1": [1, 3]}, + } + schedule, _overrides, _engine = await _child_state( + admin_presence.database, child_id + ) + assert schedule is not None + assert schedule.id == row_id + assert schedule.cycle_length_weeks == 2 + assert schedule.pattern == "|1,3" + + +async def test_schedule_changes_the_childs_resolved_presence( + admin_presence: SimpleNamespace, +) -> None: + """The resolved presence (PresenceEngine) follows the stored schedule.""" + client = admin_presence.client + child_id = await _create_child(client) + + # Before any schedule the child is present every day. + _schedule, _overrides, engine = await _child_state( + admin_presence.database, child_id + ) + assert engine.is_present(child_id, ANCHOR_MONDAY) is True + assert engine.is_present(child_id, TUESDAY) is True + + # Mon/Wed/Fri only, week 0 of a one-week cycle. + response = await client.put( + f"/api/v1/admin/children/{child_id}/presence-schedule", + json=_schedule_body(), + headers=_admin_headers(), + ) + assert response.status_code == 200 + _schedule, _overrides, engine = await _child_state( + admin_presence.database, child_id + ) + assert engine.is_present(child_id, ANCHOR_MONDAY) is True + assert engine.is_present(child_id, TUESDAY) is False + + +async def test_schedule_upsert_regenerates_future_instances( + admin_presence: SimpleNamespace, +) -> None: + """Setting an all-absent schedule deletes the child's future instances. + + The regeneration side effect lives in the core layer the route + calls: the child's open instances at or after today are deleted + across ALL its definitions and NOT rebuilt while the child is + absent every day — the same proof the HA service test makes, + exercised here through the API route instead. + """ + client = admin_presence.client + database = admin_presence.database + child_id = await _create_child(client) + created = await client.post( + "/api/v1/admin/quest-definitions", + json={ + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [child_id], + "windows": ["morning"], + }, + headers=_admin_headers(), + ) + assert created.status_code == 201 + + today = datetime.date.today() + start = today.isoformat() + end = (today + datetime.timedelta(days=14)).isoformat() + await _materialize.materialize(database, start, end, today=today) + instances = _dao_instances.QuestInstancesDao(database) + assert await instances.list_by_date_range(child_id, start, end) + + # The route's write must regenerate: absent every day removes every + # open future instance. + response = await client.put( + f"/api/v1/admin/children/{child_id}/presence-schedule", + json=_schedule_body(pattern={"0": []}), + headers=_admin_headers(), + ) + assert response.status_code == 200 + assert await instances.list_by_date_range(child_id, start, end) == [] + + +# --- override create + delete ----------------------------------------------- + + +async def test_override_is_created_then_deleted_and_presence_resolves( + admin_presence: SimpleNamespace, +) -> None: + """POST creates the override, DELETE removes it; presence follows.""" + client = admin_presence.client + database = admin_presence.database + child_id = await _create_child(client) + schedule_response = await client.put( + f"/api/v1/admin/children/{child_id}/presence-schedule", + json=_schedule_body(), + headers=_admin_headers(), + ) + assert schedule_response.status_code == 200 + + # Create: the override comes back with its id and the posted shape. + created = await client.post( + "/api/v1/admin/presence-overrides", + json=_override_body(child_id), + headers=_admin_headers(), + ) + assert created.status_code == 201 + payload = created.json() + assert set(payload) == { + "id", + "child_id", + "start_date", + "end_date", + "is_present", + "note", + } + assert payload["child_id"] == child_id + assert payload["start_date"] == TUESDAY + assert payload["end_date"] == TUESDAY + assert payload["is_present"] is True + assert payload["note"] == "grandmother's week" + + # Database proof: the row exists — and the RESOLVED presence for the + # affected date flips: the override beats the Tuesday-absent pattern. + _schedule, overrides, engine = await _child_state(database, child_id) + assert [record.id for record in overrides] == [payload["id"]] + assert engine.is_present(child_id, TUESDAY) is True + # A date the override does not cover keeps the pattern's answer. + assert engine.is_present(child_id, ANCHOR_MONDAY) is True + + # Delete: the row is gone and the resolved presence follows the + # pattern again. + deleted = await client.delete( + f"/api/v1/admin/presence-overrides/{payload['id']}", + headers=_admin_headers(), + ) + assert deleted.status_code == 200 + assert deleted.json() == {"status": "ok"} + assert ( + await _dao_presence.PresenceOverridesDao(database).get(payload["id"]) + is None + ) + _schedule, overrides, engine = await _child_state(database, child_id) + assert overrides == [] + assert engine.is_present(child_id, TUESDAY) is False + + +# --- error mapping: 404 vs 422 ---------------------------------------------- + + +async def test_schedule_and_override_for_unknown_child_are_404( + admin_presence: SimpleNamespace, +) -> None: + """A presence write naming a child that does not exist is 404.""" + client = admin_presence.client + + schedule = await client.put( + "/api/v1/admin/children/999/presence-schedule", + json=_schedule_body(), + headers=_admin_headers(), + ) + assert schedule.status_code == 404 + assert schedule.json()["detail"] == "Child not found" + + override = await client.post( + "/api/v1/admin/presence-overrides", + json=_override_body(999), + headers=_admin_headers(), + ) + assert override.status_code == 404 + assert override.json()["detail"] == "Child not found" + + +@pytest.mark.parametrize( + "body", + [ + _schedule_body(cycle_length_weeks=0), # below the 1..4 range + _schedule_body(cycle_length_weeks=5), # above the documented cap + # week 1 of the cycle missing: + _schedule_body(cycle_length_weeks=2, pattern={"0": [0]}), + { + "cycle_length_weeks": 1, + "anchor_date": "2026-13-40", + "pattern": {"0": [0]}, + }, + _schedule_body(pattern={"0": [7]}), # weekday out of 0..6 + _schedule_body(pattern={"nope": [0]}), # week index not an integer + _schedule_body(pattern={"0": "0,2,4"}), # weekdays not a list + ], +) +async def test_rejected_schedule_is_422( + admin_presence: SimpleNamespace, body: dict[str, object] +) -> None: + """A rejected schedule is 422 and stores nothing.""" + client = admin_presence.client + child_id = await _create_child(client) + response = await client.put( + f"/api/v1/admin/children/{child_id}/presence-schedule", + json=body, + headers=_admin_headers(), + ) + assert response.status_code == 422 + assert ( + await _dao_presence.PresenceSchedulesDao( + admin_presence.database + ).get_by_child(child_id) + is None + ) + + +@pytest.mark.parametrize( + "body", + [ + _override_body(1, start_date="2026-01-07", end_date="2026-01-06"), + _override_body(1, start_date="2026-02-30", end_date="2026-02-30"), + dict(_override_body(1), note=5), + dict(_override_body(1), child_id="one"), + ], +) +async def test_rejected_override_is_422( + admin_presence: SimpleNamespace, body: dict[str, object] +) -> None: + """A rejected override (inverted/malformed dates, bad note/id) is 422. + + The shape failures (a non-string note, a non-integer child id) are + the body model's; the date failures are the PresenceOverride + model's. A string ``is_present`` like ``"yes"`` is deliberately + absent: Pydantic's lax bool COERCES it to ``True`` (the same design + as the children active route), so it never reaches the model's + real-bool check. + """ + response = await admin_presence.client.post( + "/api/v1/admin/presence-overrides", json=body, headers=_admin_headers() + ) + assert response.status_code == 422 + + +async def test_overlapping_override_is_422_and_first_survives( + admin_presence: SimpleNamespace, +) -> None: + """An override overlapping the child's existing one is 422. + + The conflicting write persists nothing: the child's original + override survives unchanged. + """ + client = admin_presence.client + child_id = await _create_child(client) + created = await client.post( + "/api/v1/admin/presence-overrides", + json=_override_body(child_id), + headers=_admin_headers(), + ) + assert created.status_code == 201 + + response = await client.post( + "/api/v1/admin/presence-overrides", + json=_override_body(child_id, is_present=False), + headers=_admin_headers(), + ) + assert response.status_code == 422 + _schedule, overrides, _engine = await _child_state( + admin_presence.database, child_id + ) + assert [record.id for record in overrides] == [created.json()["id"]] + + +async def test_delete_unknown_override_is_404( + admin_presence: SimpleNamespace, +) -> None: + """Deleting an override id that does not exist is 404.""" + response = await admin_presence.client.delete( + "/api/v1/admin/presence-overrides/999", headers=_admin_headers() + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Presence override not found" + + malformed = await admin_presence.client.delete( + "/api/v1/admin/presence-overrides/not-an-id", + headers=_admin_headers(), + ) + assert malformed.status_code == 422