Add admin rule occurrence-preview route

POST /api/v1/admin/quest-definitions/occurrences-preview returns the
next N dates a supplied schedule rule fires on, computed by core
recurrence.occurrences_between over a bounded 366-day window from a
start_date (default the host's today via one _local_now read). The
rule goes through the same AdminRuleRequest + ScheduleRule.from_dict
path as create/edit; a rejected rule, non-strict start_date or count
outside 1..50 is 422. Auth is the router's single require_admin.

Task: 5f843564-8399-4afc-b5f6-023dff146a8d

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 21:30:41 -04:00
co-authored by Claude Opus 5.5
parent 4b39af4b67
commit 900af61012
2 changed files with 567 additions and 0 deletions
+136
View File
@@ -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",
+431
View File
@@ -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