From 456e2ec79804a095ab6eb08e0118bc1d69a546ba Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 21:37:45 -0400 Subject: [PATCH] Task 5f843564: Admin rule occurrence-preview route (#199) --- api/routes_admin.py | 136 +++++++++ tests/test_api_admin_occurrences.py | 431 ++++++++++++++++++++++++++++ 2 files changed, 567 insertions(+) create mode 100644 tests/test_api_admin_occurrences.py diff --git a/api/routes_admin.py b/api/routes_admin.py index 1c4de6b..614b47f 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -172,6 +172,33 @@ integration's rollover listener shares: read-only-over-the-domain-tables guarantee all live in the core; the response reports how many transitions were published. +The occurrence-preview route (task 5f843564) lets the admin PWA's +Definitions edit sheet show the next dates a rule fires on WITHOUT +reimplementing the recurrence rules client-side: + +- ``POST /quest-definitions/occurrences-preview`` takes the SAME + ``rule`` object create/edit take (:class:`AdminRuleRequest`, built + through the SAME :func:`_rule_from_request` / core ``from_dict`` + validation, so a rejected rule is 422 exactly as on create), an + optional ``start_date`` (default: the API host's local ``today``, + ONE :func:`_local_now` clock read) and an optional ``count`` (1..50, + default 10). The dates come from ONE + :func:`nestquest_core.recurrence.occurrences_between` call — the + same engine the materializer walks — so the preview can only agree + with what would be generated. The scan is BOUNDED server-side: the + window is ``[start_date, start_date + 366 days]`` inclusive, + whatever the caller sends, so every rule shape (a yearly rule + included) shows at least its next occurrence, and a caller can never + ask for an unbounded walk. Any strict ``start_date`` is accepted + rather than clamped or range-limited — the fixed window already + bounds the work — EXCEPT one whose window would reach 9999-12-31, + which is 422 (the core's day walk cannot step past the calendar's + last date). ``start_date`` strictness + (``YYYY-MM-DD``) is the core's own date validator; a non-strict or + unparseable value is 422. A ``count`` outside 1..50 is the body + model's 422 before any handler code runs. Nothing is read or + written: the route touches no database. + Error mapping (every children, quest-definition and presence route, deliberately narrow): @@ -452,6 +479,45 @@ class AdminRuleRequest(BaseModel): end_date: str | None = None +#: Upper bound on ``count`` for the occurrence-preview route. +_PREVIEW_MAX_COUNT = 50 + +#: Days after ``start_date`` the occurrence-preview window spans +#: (inclusive), so a yearly rule always shows its next occurrence. +_PREVIEW_WINDOW_DAYS = 366 + + +class AdminOccurrencesPreviewRequest(BaseModel): + """The body of ``POST /api/v1/admin/quest-definitions/occurrences-preview``. + + ``rule`` is the SAME object create/edit take. ``start_date`` is an + optional strict ``YYYY-MM-DD`` (validated by the core; default the + API host's today). ``count`` is a strict JSON integer in + 1..``_PREVIEW_MAX_COUNT`` — anything else is 422 before any handler + code runs. + """ + + rule: AdminRuleRequest + start_date: str | None = None + count: StrictInt = 10 + + @field_validator("count") + @classmethod + def _count_in_range(cls, value: int) -> int: + """Reject a count outside 1.._PREVIEW_MAX_COUNT.""" + if not 1 <= value <= _PREVIEW_MAX_COUNT: + raise ValueError( + f"count must be between 1 and {_PREVIEW_MAX_COUNT}" + ) + return value + + +class AdminOccurrencesPreviewResponse(BaseModel): + """The next occurrence dates (``YYYY-MM-DD``, ascending).""" + + dates: list[str] + + class AdminQuestDefinitionCreateRequest(BaseModel): """The body of ``POST /api/v1/admin/quest-definitions``. @@ -938,6 +1004,40 @@ def _raise_sweep_error(error: ValueError) -> NoReturn: raise HTTPException(status_code=422, detail=str(error)) from error +def _raise_preview_error(error: ValueError) -> NoReturn: + """Map a core recurrence ``ValueError`` from the preview route to 422. + + The preview route has no path ids, so it has no 404 case: a + rejected rule (:class:`~nestquest_core.recurrence.RuleValidationError`) + or a non-strict ``start_date`` is 422 with the core's message as + the detail. Only ``ValueError`` is caught, so an unexpected + failure re-raises rather than becoming a 4xx. + """ + raise HTTPException(status_code=422, detail=str(error)) from error + + +def _preview_window_end(start_date: str) -> str: + """Return the preview window's inclusive end for ``start_date``. + + ``start_date`` + :data:`_PREVIEW_WINDOW_DAYS`. Parsing here only + locates the window; the STRICT ``YYYY-MM-DD`` check stays the + core's — :func:`~nestquest_core.recurrence.occurrences_between` + re-validates the same string and rejects any lenient form this + parse accepted. Raises ``ValueError`` for an unparseable value and + for a start whose window would reach the calendar's last date (the + core's day walk steps one day PAST its end, which overflows there). + """ + start = datetime.date.fromisoformat(start_date) + if datetime.date.max - start <= datetime.timedelta( + days=_PREVIEW_WINDOW_DAYS + ): + raise ValueError( + f"start_date {start_date!r} is too close to the end of the " + "calendar for a preview window" + ) + return (start + datetime.timedelta(days=_PREVIEW_WINDOW_DAYS)).isoformat() + + def _local_now() -> datetime.datetime: """Return the API host's local time as ONE timezone-aware clock read. @@ -1259,6 +1359,42 @@ async def admin_create_quest_definition( return _quest_definition_response(bundle) +@router.post( + "/quest-definitions/occurrences-preview", + summary="Preview the next occurrence dates of a schedule rule", + response_model=AdminOccurrencesPreviewResponse, +) +async def admin_preview_occurrences( + body: AdminOccurrencesPreviewRequest, +) -> AdminOccurrencesPreviewResponse: + """Return the first ``count`` dates the supplied rule fires on. + + 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 ``rule`` + becomes ONE :class:`~nestquest_core.recurrence.ScheduleRule` + (:func:`_rule_from_request`, the create/edit path), and the dates + come from ONE :func:`nestquest_core.recurrence.occurrences_between` + call over the bounded window ``[start_date, start_date + 366 + days]`` — ``start_date`` defaulting to ONE :func:`_local_now` clock + read — truncated to ``count``. Every core ``ValueError`` is 422 + (:func:`_raise_preview_error`). No database access. + """ + start_date = ( + body.start_date + if body.start_date is not None + else _local_now().date().isoformat() + ) + try: + rule = _rule_from_request(body.rule) + dates = core_recurrence.occurrences_between( + rule, start_date, _preview_window_end(start_date) + ) + except ValueError as error: + _raise_preview_error(error) + return AdminOccurrencesPreviewResponse(dates=dates[: body.count]) + + @router.patch( "/quest-definitions/{definition_id}", summary="Edit a quest definition", diff --git a/tests/test_api_admin_occurrences.py b/tests/test_api_admin_occurrences.py new file mode 100644 index 0000000..58cef8b --- /dev/null +++ b/tests/test_api_admin_occurrences.py @@ -0,0 +1,431 @@ +"""Admin-plane occurrence-preview route tests (task 5f843564). + +These tests exercise ``POST /api/v1/admin/quest-definitions/occurrences-preview`` +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 with NO network access. + +- Authentication runs through the router's ONE ``require_admin`` + dependency: no credential is 401, the panel service token is 403 + (refused as a credential type), a JWT without the admin group is + 403, an admin JWT is 200. +- The returned dates are asserted EXACTLY for a weekly rule with an + interval and a start_date, a daily rule with an interval, a rule + with an end_date (the list stops at it), a month-end-clamped monthly + rule, and the default start (the pinned host clock's today); one + case also asserts parity with the core's own ``occurrences_between`` + over the documented window. +- A rejected rule (the same core ``from_dict`` validation create/edit + use), a non-strict ``start_date`` and a ``count`` outside 1..50 are + 422. +""" +from __future__ import annotations + +import datetime +import importlib +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +import pytest + +from api import routes_admin +from tests.admin_jwt_harness import ( + ADMIN_KEY, + KID, + AdminRunner, + jwks_for, + make_token, +) + +# The recurrence engine through the SAME core copy the app uses +# (nestquest_core.*, NOT the conftest-stubbed custom_components copy). +_recurrence = importlib.import_module("nestquest_core.recurrence") + +PREVIEW_URL = "/api/v1/admin/quest-definitions/occurrences-preview" + +#: The panel service token the harness app is configured with. +HARNESS_PANEL_TOKEN = "admin-test-panel-token" + +#: The 403 detail for the panel service token on an admin route. +PANEL_TOKEN_REJECTION_DETAIL = ( + "This credential type is not accepted for admin access" +) + +#: The 403 detail for a valid JWT without the admin group. +FORBIDDEN_DETAIL = ( + "Admin access requires membership in the nestquest-admins group" +) + +#: The pinned host instant: Wednesday 2026-09-23, noon UTC. +PINNED_NOW = datetime.datetime(2026, 9, 23, 12, 0, 0, tzinfo=ZoneInfo("UTC")) + + +def _admin_headers() -> dict[str, str]: + """The Authorization header of a valid nestquest-admins JWT.""" + return {"Authorization": f"Bearer {make_token()}"} + + +def _weekly_rule() -> dict[str, object]: + """Every OTHER week's Tuesday and Friday, anchored on Tue 2026-03-03.""" + return { + "rule_type": "weekly", + "interval": 2, + "weekday_set": [1, 4], + "start_date": "2026-03-03", + } + + +@pytest.fixture +async def preview(temp_db_path: str, monkeypatch) -> SimpleNamespace: + """An admin-plane app client with the host clock pinned (and counted).""" + clock_reads: list[datetime.datetime] = [] + + def _admin_now() -> datetime.datetime: + clock_reads.append(PINNED_NOW) + return PINNED_NOW + + monkeypatch.setattr(routes_admin, "_local_now", _admin_now) + runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) + async with runner as client: + yield SimpleNamespace(client=client, clock_reads=clock_reads) + + +async def _post(client: object, body: dict[str, object]) -> object: + """POST a preview body with an admin JWT.""" + return await client.post(PREVIEW_URL, json=body, headers=_admin_headers()) + + +# --- authentication: the ONE require_admin dependency ---------------------- + + +async def test_preview_without_credential_is_401( + preview: SimpleNamespace, +) -> None: + """No Authorization header is the uniform 401.""" + response = await preview.client.post( + PREVIEW_URL, json={"rule": _weekly_rule()} + ) + assert response.status_code == 401 + + +async def test_preview_refuses_panel_service_token( + preview: SimpleNamespace, +) -> None: + """The panel service token is refused as a credential type (403).""" + response = await preview.client.post( + PREVIEW_URL, + json={"rule": _weekly_rule()}, + headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL + + +async def test_preview_refuses_non_admin_jwt( + preview: SimpleNamespace, +) -> None: + """A valid JWT without the nestquest-admins group is 403.""" + non_admin = make_token(groups=("some-other-group",)) + response = await preview.client.post( + PREVIEW_URL, + json={"rule": _weekly_rule()}, + headers={"Authorization": f"Bearer {non_admin}"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == FORBIDDEN_DETAIL + + +async def test_preview_admin_jwt_is_200(preview: SimpleNamespace) -> None: + """An admin JWT is 200 with the documented ``dates`` shape.""" + response = await _post( + preview.client, {"rule": _weekly_rule(), "start_date": "2026-03-01"} + ) + assert response.status_code == 200 + assert set(response.json()) == {"dates"} + + +# --- the returned dates ---------------------------------------------------- + + +async def test_weekly_interval_rule_from_before_its_start( + preview: SimpleNamespace, +) -> None: + """Every other week's Tue/Fri, starting at the rule's own start_date. + + The preview starts on Sun 2026-03-01, before the rule's anchor, so + the first date is the anchor itself; the off weeks (Mar 10-16, + Mar 24-30) are skipped. + """ + response = await _post( + preview.client, + {"rule": _weekly_rule(), "start_date": "2026-03-01", "count": 6}, + ) + assert response.status_code == 200 + assert response.json()["dates"] == [ + "2026-03-03", + "2026-03-06", + "2026-03-17", + "2026-03-20", + "2026-03-31", + "2026-04-03", + ] + + +async def test_weekly_interval_rule_from_mid_cadence( + preview: SimpleNamespace, +) -> None: + """A start inside an on-week keeps the rule's anchor cadence.""" + response = await _post( + preview.client, + {"rule": _weekly_rule(), "start_date": "2026-03-18", "count": 4}, + ) + assert response.status_code == 200 + assert response.json()["dates"] == [ + "2026-03-20", + "2026-03-31", + "2026-04-03", + "2026-04-14", + ] + + +async def test_daily_interval_rule(preview: SimpleNamespace) -> None: + """Every third day from 2026-01-01, previewed from 2026-01-05.""" + response = await _post( + preview.client, + { + "rule": { + "rule_type": "daily", + "interval": 3, + "start_date": "2026-01-01", + }, + "start_date": "2026-01-05", + "count": 4, + }, + ) + assert response.status_code == 200 + assert response.json()["dates"] == [ + "2026-01-07", + "2026-01-10", + "2026-01-13", + "2026-01-16", + ] + + +async def test_rule_with_end_date_stops_at_it( + preview: SimpleNamespace, +) -> None: + """A daily rule ending 2026-05-04 yields only its four dates.""" + response = await _post( + preview.client, + { + "rule": { + "rule_type": "daily", + "start_date": "2026-05-01", + "end_date": "2026-05-04", + }, + "start_date": "2026-04-28", + "count": 10, + }, + ) + assert response.status_code == 200 + assert response.json()["dates"] == [ + "2026-05-01", + "2026-05-02", + "2026-05-03", + "2026-05-04", + ] + + +async def test_monthly_day_rule_clamps_to_month_end( + preview: SimpleNamespace, +) -> None: + """The core's month-end policy: the 31st fires on Feb 28th.""" + response = await _post( + preview.client, + { + "rule": { + "rule_type": "monthly_day", + "day_of_month": 31, + "start_date": "2026-01-31", + }, + "start_date": "2026-01-01", + "count": 3, + }, + ) + assert response.status_code == 200 + assert response.json()["dates"] == [ + "2026-01-31", + "2026-02-28", + "2026-03-31", + ] + + +async def test_default_start_is_host_today_with_default_count( + preview: SimpleNamespace, +) -> None: + """No start_date: ONE clock read's date; no count: ten dates.""" + response = await _post(preview.client, {"rule": {"rule_type": "daily"}}) + assert response.status_code == 200 + expected = [ + (datetime.date(2026, 9, 23) + datetime.timedelta(days=n)).isoformat() + for n in range(10) + ] + assert response.json()["dates"] == expected + assert preview.clock_reads == [PINNED_NOW] + + +async def test_supplied_start_date_reads_no_clock( + preview: SimpleNamespace, +) -> None: + """A supplied start_date is used as-is: the host clock is not read.""" + response = await _post( + preview.client, {"rule": _weekly_rule(), "start_date": "2026-03-01"} + ) + assert response.status_code == 200 + assert preview.clock_reads == [] + + +async def test_dates_match_core_occurrences_between_over_window( + preview: SimpleNamespace, +) -> None: + """The route's dates ARE the core's walk over the 366-day window.""" + rule_body = { + "rule_type": "monthly_weekday", + "nth_weekday": -1, + "nth_weekday_weekday": 4, + "start_date": "2026-01-01", + } + response = await _post( + preview.client, + {"rule": rule_body, "start_date": "2026-02-10", "count": 50}, + ) + assert response.status_code == 200 + core_dates = _recurrence.occurrences_between( + _recurrence.ScheduleRule.from_dict(rule_body), + "2026-02-10", + "2027-02-11", + ) + assert response.json()["dates"] == core_dates + assert len(core_dates) == 12 + + +async def test_yearly_rule_shows_next_occurrence_within_window( + preview: SimpleNamespace, +) -> None: + """The 366-day window always reaches a yearly rule's next date.""" + response = await _post( + preview.client, + { + "rule": { + "rule_type": "yearly", + "month": 9, + "day_of_month": 22, + "start_date": "2020-09-22", + }, + "start_date": "2026-09-23", + }, + ) + assert response.status_code == 200 + assert response.json()["dates"] == ["2027-09-22"] + + +async def test_start_near_calendar_end_is_422( + preview: SimpleNamespace, +) -> None: + """A window that would reach 9999-12-31 is rejected, not clamped.""" + response = await _post( + preview.client, + {"rule": {"rule_type": "daily"}, "start_date": "9999-12-30"}, + ) + assert response.status_code == 422 + assert "too close to the end of the calendar" in response.json()["detail"] + + +async def test_latest_accepted_start_date(preview: SimpleNamespace) -> None: + """The last start whose window ends before 9999-12-31 is accepted.""" + response = await _post( + preview.client, + { + "rule": {"rule_type": "daily"}, + "start_date": "9998-12-29", + "count": 1, + }, + ) + assert response.status_code == 200 + assert response.json()["dates"] == ["9998-12-29"] + + +# --- rejections: 422 ------------------------------------------------------- + + +@pytest.mark.parametrize( + "rule", + [ + {"rule_type": "weekly"}, # weekly without weekday_set + {"rule_type": "fortnightly"}, # unknown rule_type + {"rule_type": "daily", "interval": 0}, + {"rule_type": "daily", "weekday_set": [1]}, # forbidden field + {"rule_type": "daily", "start_date": "2026-3-1"}, + { + "rule_type": "daily", + "start_date": "2026-05-04", + "end_date": "2026-05-01", + }, + ], +) +async def test_malformed_rule_is_422( + preview: SimpleNamespace, rule: dict[str, object] +) -> None: + """A rule the core's from_dict rejects is 422, as on create/edit.""" + response = await _post( + preview.client, {"rule": rule, "start_date": "2026-03-01"} + ) + assert response.status_code == 422 + + +async def test_missing_rule_is_422(preview: SimpleNamespace) -> None: + """The rule is required: the body model's 422.""" + response = await _post(preview.client, {"start_date": "2026-03-01"}) + assert response.status_code == 422 + + +@pytest.mark.parametrize( + "start_date", ["2026-3-1", "20260301", "tomorrow", "2026-02-30", ""] +) +async def test_non_strict_start_date_is_422( + preview: SimpleNamespace, start_date: str +) -> None: + """start_date must be a strict YYYY-MM-DD date (the core's check).""" + response = await _post( + preview.client, {"rule": _weekly_rule(), "start_date": start_date} + ) + assert response.status_code == 422 + + +@pytest.mark.parametrize("count", [0, -1, 51, 1000, True, "5", 2.5]) +async def test_count_out_of_range_is_422( + preview: SimpleNamespace, count: object +) -> None: + """count must be a strict JSON integer in 1..50.""" + response = await _post( + preview.client, + {"rule": _weekly_rule(), "start_date": "2026-03-01", "count": count}, + ) + assert response.status_code == 422 + + +@pytest.mark.parametrize("count", [1, 50]) +async def test_count_bounds_are_inclusive( + preview: SimpleNamespace, count: int +) -> None: + """count 1 and 50 are accepted and truncate the list exactly.""" + response = await _post( + preview.client, + { + "rule": {"rule_type": "daily"}, + "start_date": "2026-01-01", + "count": count, + }, + ) + assert response.status_code == 200 + assert len(response.json()["dates"]) == count