Compare commits

..
Author SHA1 Message Date
overseerandClaude Opus 5.5 900af61012 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>
2026-09-23 21:30:41 -04:00
dev_agent 4b39af4b67 Feature 22 follow-up: Editable definition assignees (#196) (#197)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 21:10:39 -04:00
dev_agent 42ea5710d7 Feature 22: Admin Definitions Read and skip_on_away (#193, #194) (#195)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 20:52:06 -04:00
6 changed files with 1103 additions and 34 deletions
+167 -17
View File
@@ -30,10 +30,10 @@ hand-rolled here), and the call goes to
``set_quest_definition_active``. The title policy, the window-name
and due-time policy, the assignee checks, the whole-set window
replacement and the never-hard-delete policy (deactivation is the only
removal path) all live in the core. Assignment is NOT settable
through the API: the core's edit path takes no assignees. The edit
route forwards only SUPPLIED fields (``exclude_unset``), like the
children edit.
removal path) all live in the core. The edit route forwards only
SUPPLIED fields (``exclude_unset``), like the children edit; a supplied
``assignee_child_ids`` list REPLACES the whole assignee set inside the
core edit's one transaction (and its one regeneration).
The presence routes follow the same pattern over
:mod:`nestquest_core.presence_management` — the ONE presence write
@@ -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):
@@ -182,8 +209,9 @@ deliberately narrow):
set_active on an unknown id) is mapped to 404.
- A core ``ValueError`` reporting a NON-EXISTENT DEFINITION (edit or
set_active on an unknown id — the core prefixes that error with
``definition_id:``) is mapped to 404. Create's rejected-assignee
error names a child from the BODY, not a path id, so it maps to 422.
``definition_id:``) is mapped to 404. Create's and edit's
rejected-assignee error names a child from the BODY, not a path id,
so it maps to 422.
- Every OTHER core ``ValueError`` — an empty display name, an explicit
``null`` colour/avatar_ref (clearing is not supported), a reorder
list that is not a complete permutation of the children (missing,
@@ -251,7 +279,13 @@ import datetime
from typing import Annotated, Literal, NoReturn
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, StrictBool, field_validator, model_validator
from pydantic import (
BaseModel,
StrictBool,
StrictInt,
field_validator,
model_validator,
)
from api import transitions as api_transitions
from api.auth import require_admin
@@ -445,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``.
@@ -456,11 +529,13 @@ class AdminQuestDefinitionCreateRequest(BaseModel):
date the child is absent). ``windows`` entries are a window name (``morning``) or a two-item
``[window, due_time]`` pair whose due time is a strict 24-hour
HH:MM (or ``null`` for none) — both forms the core normalizes.
Assignee ids are strict JSON integers: a boolean, float or numeric
string is 422, never coerced to a child id.
"""
title: str
rule: AdminRuleRequest
assignee_child_ids: list[int]
assignee_child_ids: list[StrictInt]
windows: list[str | tuple[str, str | None]]
description: str | None = None
icon: str | None = None
@@ -476,11 +551,13 @@ class AdminQuestDefinitionEditRequest(BaseModel):
field stays unchanged. An explicit ``null`` ``description``/
``icon`` IS passed and CLEARS the stored value (the core supports
clearing here, unlike the children edit); an explicit ``null``
``rule`` or ``windows`` is passed and rejected by the core. A
supplied ``windows`` list REPLACES the whole window set, and
``assignees`` are deliberately absent — assignment is not settable
through this route. An omitted (or ``null``) ``skip_on_away``
keeps the stored value.
``rule``, ``windows`` or ``assignee_child_ids`` is passed and
rejected by the core. A supplied ``windows`` list REPLACES the
whole window set, and a supplied ``assignee_child_ids`` list
REPLACES the whole assignee set (non-empty, every id an existing
active child — create's contract; a rejection changes nothing). An
omitted (or ``null``) ``skip_on_away`` keeps the stored value.
Assignee ids are strict JSON integers, as on create.
"""
title: str | None = None
@@ -489,6 +566,7 @@ class AdminQuestDefinitionEditRequest(BaseModel):
rule: AdminRuleRequest | None = None
windows: list[str | tuple[str, str | None]] | None = None
skip_on_away: StrictBool | None = None
assignee_child_ids: list[StrictInt] | None = None
class AdminQuestDefinitionActiveRequest(BaseModel):
@@ -926,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.
@@ -1247,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",
@@ -1266,10 +1414,12 @@ async def admin_edit_quest_definition(
field is never passed to
:func:`nestquest_core.quest_definitions.edit_quest_definition` and
stays unchanged; a supplied ``windows`` list replaces the WHOLE
window set in the core, and a supplied ``rule`` is rebuilt into a
ScheduleRule (:func:`_rule_from_request`, validated by the model).
An explicit ``null`` ``rule``/``windows`` reaches the core as None
and is rejected there (422), while an explicit ``null``
window set in the core, a supplied ``assignee_child_ids`` list
replaces the WHOLE assignee set in the same core transaction, and a
supplied ``rule`` is rebuilt into a ScheduleRule
(:func:`_rule_from_request`, validated by the model). An explicit
``null`` ``rule``/``windows``/``assignee_child_ids`` reaches the
core as None and is rejected there (422), while an explicit ``null``
``description``/``icon`` clears the stored value — the core edit
path supports clearing, unlike the children edit. An unknown
definition id is mapped to 404 and any other core ``ValueError``
+47 -5
View File
@@ -796,8 +796,9 @@ class QuestDefinitionsDao:
rule: ScheduleRuleStorage | object = _UNSET,
windows: list[tuple[str, str | None]] | object = _UNSET,
skip_on_away: bool | object = _UNSET,
assignee_child_ids: list[int] | object = _UNSET,
) -> QuestDefinitionSnapshot:
"""Edit a definition's metadata, rule and windows atomically.
"""Edit a definition's metadata, rule, windows and assignees atomically.
Mirrors :meth:`create_with_rule_and_windows`: everything runs
inside ONE transaction under the connection lock, and the
@@ -818,11 +819,15 @@ class QuestDefinitionsDao:
are deleted and the given set re-inserted, so missing windows
are added, changed due times updated and removed windows
dropped.
- ``assignee_child_ids`` REPLACES the whole assignee set: every
id is validated as existing-and-active BEFORE any write (the
same check :meth:`add_assignee` applies), then links for
children no longer listed are removed and links for newly
listed children inserted; unchanged links are left alone.
Assignment is deliberately not settable here (multi-assignee is
managed by :meth:`add_assignee`/:meth:`remove_assignee`), and
activation is untouched. Raises ValueError when the definition
does not exist. Edits change future instances only: no
Activation is untouched. Raises ValueError when the definition
does not exist or an assignee is rejected; either way the
transaction rolls back and nothing is written. Edits change future instances only: no
``quest_instances`` or ``completion_events`` row is touched.
"""
if skip_on_away is not _UNSET:
@@ -834,6 +839,9 @@ class QuestDefinitionsDao:
raise ValueError(
f"quest definition {definition_id} does not exist"
)
if assignee_child_ids is not _UNSET:
for child_id in assignee_child_ids:
await self._validate_assignable_child(child_id)
assignments: list[str] = []
parameters: list[object] = []
for column, value in (
@@ -882,6 +890,10 @@ class QuestDefinitionsDao:
"VALUES (?, ?, ?)",
(definition_id, window, due_time),
)
if assignee_child_ids is not _UNSET:
await self._replace_assignees(
definition_id, assignee_child_ids
)
updated = await self.get(definition_id)
assignees = await self.list_assignees(definition_id)
window_records = await self.list_windows(definition_id)
@@ -897,6 +909,36 @@ class QuestDefinitionsDao:
windows=window_records,
)
async def _replace_assignees(
self, definition_id: int, child_ids: list[int]
) -> None:
"""Make the definition's assignee links exactly ``child_ids``.
Removes the links for children not in ``child_ids`` and inserts
the missing ones. MUST be called inside the connection lock and
an open transaction, after the ids were validated as assignable.
"""
rows = await self._database.fetch_all(
"SELECT child_id FROM quest_definition_assignees "
"WHERE definition_id = ?",
(definition_id,),
)
current = {row[0] for row in rows}
wanted = set(child_ids)
for child_id in sorted(current - wanted):
await self._database.execute(
"DELETE FROM quest_definition_assignees "
"WHERE definition_id = ? AND child_id = ?",
(definition_id, child_id),
)
for child_id in child_ids:
if child_id not in current:
await self._database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (?, ?)",
(definition_id, child_id),
)
async def _validate_rule_exists(self, schedule_rule_id: int) -> None:
"""Raise ValueError unless the rule exists.
@@ -4,8 +4,9 @@ Sits between the Feature 09 service gate and the typed
:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the
all-or-nothing create/edit here, and never touch the DAO SQL directly.
:func:`create_quest_definition` is the create path,
:func:`edit_quest_definition` the edit path, :func:`assign_child` and
:func:`unassign_child` the assignment paths, and
:func:`edit_quest_definition` the edit path (which may also replace the
whole assignee set), :func:`assign_child` and
:func:`unassign_child` the single-child assignment paths, and
:func:`set_quest_definition_active` the deactivate/reactivate path.
There is no permission check here (that is Feature 09).
@@ -286,10 +287,11 @@ async def edit_quest_definition(
rule: ScheduleRule | object = _UNSET,
windows: list[str | tuple[str, str | None]] | object = _UNSET,
skip_on_away: bool | None = None,
assignee_child_ids: list[int] | object = _UNSET,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> CreatedQuestDefinition:
"""Edit a quest definition's metadata, rule and windows atomically.
"""Edit a quest definition's metadata, rule, windows and assignees atomically.
Arguments default to the sentinel ``_UNSET`` meaning "leave this
field alone". A provided ``title`` is validated like create's
@@ -302,9 +304,14 @@ async def edit_quest_definition(
a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due
time), matching create's non-empty contract. A provided
``skip_on_away`` (a real bool) replaces the stored flag; ``None``
leaves it unchanged. Assignment is NOT
settable here. Raises ValueError when the definition does not
exist or any argument is rejected.
leaves it unchanged. A provided ``assignee_child_ids`` REPLACES the
whole assignee set under create's contract (a non-empty list of
distinct plain ints, each an existing ACTIVE child — the DAO checks
that inside the edit's transaction): newly listed children are
assigned and unlisted ones unassigned. Raises ValueError when the
definition does not exist or any argument is rejected; a rejection
writes nothing, so the stored assignees (and every other field)
stay unchanged.
Returns the updated definition together with its decoded rule,
assignees and windows — all read back inside the DAO's transaction,
@@ -348,6 +355,10 @@ async def edit_quest_definition(
if windows is not _UNSET:
window_specs = _normalize_windows(windows)
assignee_ids: list[int] | object = _UNSET
if assignee_child_ids is not _UNSET:
assignee_ids = _validate_assignee_ids(assignee_child_ids)
skip_on_away_value: bool | object = _UNSET
if skip_on_away is not None:
skip_on_away_value = _validate_skip_on_away(skip_on_away)
@@ -362,14 +373,17 @@ async def edit_quest_definition(
rule=rule_storage,
windows=window_specs,
skip_on_away=skip_on_away_value,
assignee_child_ids=assignee_ids,
)
except ValueError as error:
# The DAO's missing-definition error names only the id; re-raise
# so the public contract names the field the caller passed (matching
# assign/unassign/deactivate).
# The DAO's missing-definition and assignee errors name only the
# id; re-raise so the public contract names the field the caller
# passed (matching create/assign/unassign/deactivate).
message = str(error)
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
if message.startswith("child "):
raise ValueError(f"assignee_child_ids: {message}") from error
raise
decoded_rule = schedule_rule_from_storage(
+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
+4 -3
View File
@@ -18,8 +18,9 @@ caveat in api/nestquest_core.py):
and windows exactly as posted.
- ``PATCH /api/v1/admin/quest-definitions/{id}``: an edit PERSISTS the
supplied fields and leaves omitted fields untouched (exclude_unset
semantics); a supplied ``windows`` list replaces the whole set;
assignment is not settable through the edit.
semantics); a supplied ``windows`` list replaces the whole set; an
omitted ``assignee_child_ids`` leaves the assignees untouched (the
supplied-list cases live in test_api_admin_questdef_assignees.py).
- ``PATCH /api/v1/admin/quest-definitions/{id}/active`` with
``is_active: false`` sets the flag FALSE and the definition STILL
EXISTS with its rule, assignees and windows (never hard-deleted);
@@ -397,7 +398,7 @@ async def test_edit_persists_supplied_fields_and_leaves_omitted_untouched(
)
assert stored.definition.icon == "toy-box"
assert stored.rule.to_dict() == _weekly_rule_dict()
# Assignment is not settable through the edit route: untouched.
# assignee_child_ids was omitted: the assignees are untouched.
assert sorted(child.id for child in stored.assignees) == [ada, bo]
# A second edit touching only the rule leaves the metadata and the
+431
View File
@@ -0,0 +1,431 @@
"""Admin-plane editable assignees on the definition edit route (task 1c8096f6).
``PATCH /api/v1/admin/quest-definitions/{definition_id}`` accepts an
optional ``assignee_child_ids`` list that REPLACES the definition's
assignee set inside the core edit's ONE transaction
(:func:`nestquest_core.quest_definitions.edit_quest_definition`):
- newly listed children are assigned and unlisted ones unassigned; the
response and the database both carry the resulting set;
- an empty list, a duplicate, an unknown child or an inactive child is
422 and NOTHING is written — the stored assignees (and any other
field supplied in the same body) are unchanged;
- a JSON boolean, float or numeric string id is 422 on create and edit
(never coerced to an int) and nothing is written;
- an omitted ``assignee_child_ids`` leaves the assignees unchanged;
- an assignment change regenerates the definition's future instances
through the existing core path;
- the route stays behind the router's ONE ``require_admin`` dependency
(absent credential 401, panel service token 403, non-admin JWT 403,
admin JWT 200).
Every case is proven against the DATABASE, read through the SAME core
copy the app uses (``nestquest_core.*``; see api/nestquest_core.py).
"""
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,
)
_dao_rules = importlib.import_module("nestquest_core.dao_rules")
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
_materialize = importlib.import_module("nestquest_core.materialize")
_quest_definitions = importlib.import_module(
"nestquest_core.quest_definitions"
)
#: The panel service token the harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
def _edit_path(definition_id: int) -> str:
"""The edit route for one definition."""
return f"/api/v1/admin/quest-definitions/{definition_id}"
@pytest.fixture
async def admin_assignees(temp_db_path: str) -> SimpleNamespace:
"""An admin-plane app client plus its live database."""
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,
)
async def _create_children(
client: object, names: tuple[str, ...]
) -> list[int]:
"""Create ACTIVE children through the admin children route."""
ids = []
for name in names:
created = await client.post(
"/api/v1/admin/children",
json={"display_name": name},
headers=_admin_headers(),
)
assert created.status_code == 201
ids.append(created.json()["id"])
return ids
async def _create_definition(client: object, assignee_ids: list[int]) -> int:
"""POST a daily morning definition for the given assignees."""
created = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": assignee_ids,
"windows": ["morning"],
},
headers=_admin_headers(),
)
assert created.status_code == 201
return created.json()["id"]
async def _stored_assignee_ids(
database: object, definition_id: int
) -> set[int]:
"""The definition's assignee ids as stored."""
dao = _dao_rules.QuestDefinitionsDao(database)
return {child.id for child in await dao.list_assignees(definition_id)}
def _response_assignee_ids(response: object) -> set[int]:
"""The assignee ids a definition payload reports."""
return {assignee["id"] for assignee in response.json()["assignees"]}
# --- add / remove / replace -------------------------------------------------
@pytest.mark.parametrize(
("initial", "requested"),
[
(("Ada",), ("Ada", "Bo")), # add a child
(("Ada", "Bo"), ("Ada",)), # remove a child
(("Ada", "Bo"), ("Cy",)), # replace the whole set
],
)
async def test_edit_replaces_the_assignee_set(
admin_assignees: SimpleNamespace,
initial: tuple[str, ...],
requested: tuple[str, ...],
) -> None:
"""A supplied list becomes exactly the stored and returned set."""
client = admin_assignees.client
names = ("Ada", "Bo", "Cy")
ids = dict(zip(names, await _create_children(client, names)))
definition_id = await _create_definition(
client, [ids[name] for name in initial]
)
wanted = {ids[name] for name in requested}
response = await client.patch(
_edit_path(definition_id),
json={"assignee_child_ids": [ids[name] for name in requested]},
headers=_admin_headers(),
)
assert response.status_code == 200
assert _response_assignee_ids(response) == wanted
assert {a["display_name"] for a in response.json()["assignees"]} == set(
requested
)
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == wanted
# The rest of the definition is untouched by an assignee-only edit.
assert response.json()["title"] == "Brush teeth"
assert [w["window"] for w in response.json()["windows"]] == ["morning"]
async def test_edit_with_assignees_and_other_fields_applies_both(
admin_assignees: SimpleNamespace,
) -> None:
"""Assignees and metadata supplied together land in one edit."""
client = admin_assignees.client
ada, bo = await _create_children(client, ("Ada", "Bo"))
definition_id = await _create_definition(client, [ada])
response = await client.patch(
_edit_path(definition_id),
json={"title": "Floss", "assignee_child_ids": [bo]},
headers=_admin_headers(),
)
assert response.status_code == 200
assert response.json()["title"] == "Floss"
assert _response_assignee_ids(response) == {bo}
dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database)
assert (await dao.get(definition_id)).title == "Floss"
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == {bo}
# --- rejections leave the stored state unchanged ----------------------------
@pytest.mark.parametrize(
("case", "expected_detail"),
[
("empty", "assignee_child_ids must not be empty"),
("unknown", "assignee_child_ids: child 999 does not exist"),
("inactive", "assignee_child_ids: child {cy} is inactive"),
("duplicate", "assignee_child_ids must not contain duplicate"),
("null", "assignee_child_ids must be a list"),
("mixed_unknown", "assignee_child_ids: child 999 does not exist"),
("wrong_type", None), # the body model's own 422
],
)
async def test_rejected_assignee_list_is_422_and_changes_nothing(
admin_assignees: SimpleNamespace,
case: str,
expected_detail: str | None,
) -> None:
"""Empty/unknown/inactive/malformed lists are 422 with no mutation.
The same body also renames the definition, proving the whole edit
is all-or-nothing: neither the assignees nor the title change.
"""
client = admin_assignees.client
ada, bo, cy, di = await _create_children(
client, ("Ada", "Bo", "Cy", "Di")
)
definition_id = await _create_definition(client, [ada, bo])
deactivated = await client.patch(
f"/api/v1/admin/children/{cy}/active",
json={"is_active": False},
headers=_admin_headers(),
)
assert deactivated.status_code == 200
requested: object = {
"empty": [],
"unknown": [999],
"inactive": [ada, cy],
"duplicate": [ada, ada],
"null": None,
# A valid new child alongside an unknown one: di must NOT be
# assigned (all-or-nothing), and ada/bo must NOT be removed.
"mixed_unknown": [di, 999],
"wrong_type": ["Ada"],
}[case]
response = await client.patch(
_edit_path(definition_id),
json={"title": "Renamed", "assignee_child_ids": requested},
headers=_admin_headers(),
)
assert response.status_code == 422, case
if expected_detail is not None:
assert response.json()["detail"].startswith(
expected_detail.format(cy=cy)
), response.json()
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == {ada, bo}
dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database)
assert (await dao.get(definition_id)).title == "Brush teeth"
#: JSON ids the lax ``int`` parser would coerce (``true`` -> 1).
_COERCIBLE_IDS = [True, 1.0, "1"]
@pytest.mark.parametrize("coercible", _COERCIBLE_IDS)
async def test_edit_rejects_coercible_assignee_ids_and_changes_nothing(
admin_assignees: SimpleNamespace, coercible: object
) -> None:
"""A bool, float or numeric-string id edit is 422; nothing changes."""
client = admin_assignees.client
ada, bo = await _create_children(client, ("Ada", "Bo"))
# Assign bo only, so a coerced id 1 (ada) would be a visible change.
definition_id = await _create_definition(client, [bo])
assert ada == 1
response = await client.patch(
_edit_path(definition_id),
json={"title": "Renamed", "assignee_child_ids": [coercible]},
headers=_admin_headers(),
)
assert response.status_code == 422
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == {bo}
dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database)
assert (await dao.get(definition_id)).title == "Brush teeth"
@pytest.mark.parametrize("coercible", _COERCIBLE_IDS)
async def test_create_rejects_coercible_assignee_ids_and_persists_nothing(
admin_assignees: SimpleNamespace, coercible: object
) -> None:
"""A bool, float or numeric-string id create is 422; nothing is written."""
client = admin_assignees.client
ada, bo = await _create_children(client, ("Ada", "Bo"))
assert ada == 1
existing_id = await _create_definition(client, [bo])
response = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Tidy the den",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [coercible],
"windows": ["morning"],
},
headers=_admin_headers(),
)
assert response.status_code == 422
bundles = await _quest_definitions.list_active_definitions(
admin_assignees.database
)
assert [bundle.definition.id for bundle in bundles] == [existing_id]
assert await _stored_assignee_ids(
admin_assignees.database, existing_id
) == {bo}
async def test_rejected_assignees_on_unknown_definition_is_404(
admin_assignees: SimpleNamespace,
) -> None:
"""An unknown definition id is still 404 when assignees are supplied."""
client = admin_assignees.client
(ada,) = await _create_children(client, ("Ada",))
response = await client.patch(
_edit_path(999),
json={"assignee_child_ids": [ada]},
headers=_admin_headers(),
)
assert response.status_code == 404
assert response.json()["detail"] == "Quest definition not found"
# --- omitted keeps ----------------------------------------------------------
async def test_omitted_assignees_are_unchanged(
admin_assignees: SimpleNamespace,
) -> None:
"""An edit without assignee_child_ids never touches the assignees."""
client = admin_assignees.client
ada, bo = await _create_children(client, ("Ada", "Bo"))
definition_id = await _create_definition(client, [ada, bo])
response = await client.patch(
_edit_path(definition_id),
json={"title": "Floss"},
headers=_admin_headers(),
)
assert response.status_code == 200
assert _response_assignee_ids(response) == {ada, bo}
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == {ada, bo}
# --- regeneration -----------------------------------------------------------
async def test_assignment_change_regenerates_future_instances(
admin_assignees: SimpleNamespace,
) -> None:
"""Swapping the assignee moves the future open instances with it.
The regeneration lives in the core edit path the route calls: the
definition's open instances at or after today are deleted and the
horizon re-materialized for the NEW assignee set.
"""
client = admin_assignees.client
database = admin_assignees.database
ada, bo = await _create_children(client, ("Ada", "Bo"))
definition_id = await _create_definition(client, [ada])
today = datetime.date.today()
start = today.isoformat()
end = (today + datetime.timedelta(days=7)).isoformat()
await _materialize.materialize(database, start, end, today=today)
instances = _dao_instances.QuestInstancesDao(database)
assert await instances.list_by_date_range(ada, start, end)
assert await instances.list_by_date_range(bo, start, end) == []
response = await client.patch(
_edit_path(definition_id),
json={"assignee_child_ids": [bo]},
headers=_admin_headers(),
)
assert response.status_code == 200
assert await instances.list_by_date_range(ada, start, end) == []
regenerated = await instances.list_by_date_range(bo, start, end)
assert regenerated
assert {i.definition_id for i in regenerated} == {definition_id}
# --- authentication ---------------------------------------------------------
async def test_assignee_edit_auth_outcomes(
admin_assignees: SimpleNamespace,
) -> None:
"""401 absent, 403 panel token, 403 non-admin, 200 admin."""
client = admin_assignees.client
ada, bo = await _create_children(client, ("Ada", "Bo"))
definition_id = await _create_definition(client, [ada])
body = {"assignee_child_ids": [bo]}
absent = await client.patch(_edit_path(definition_id), json=body)
assert absent.status_code == 401
panel = await client.patch(
_edit_path(definition_id),
json=body,
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
)
assert panel.status_code == 403
non_admin_token = make_token(groups=("some-other-group",))
non_admin = await client.patch(
_edit_path(definition_id),
json=body,
headers={"Authorization": f"Bearer {non_admin_token}"},
)
assert non_admin.status_code == 403
# None of the refused calls reached the handler.
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == {ada}
admin = await client.patch(
_edit_path(definition_id), json=body, headers=_admin_headers()
)
assert admin.status_code == 200
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == {bo}