Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97e3965859 | ||
|
|
456e2ec798 |
@@ -58,6 +58,17 @@ layer the HA services share, so the two planes cannot drift:
|
||||
(children are never hard-deleted; overrides are deletable by
|
||||
design) and regenerates that override's child. It answers
|
||||
``{"status": "ok"}``.
|
||||
- ``GET /children/{child_id}/presence-schedule`` reads the child's
|
||||
schedule through ONE ``get_presence_schedule`` call, wrapped as
|
||||
``{"schedule": ...}`` in the PUT response's shape — ``null`` when the
|
||||
child has none (present every day), never a 404; an unknown child is
|
||||
404.
|
||||
- ``GET /presence-overrides`` lists the household's overrides through
|
||||
ONE ``list_presence_overrides`` call, wrapped as
|
||||
``{"overrides": [...]}`` in the POST response's shape, ordered by
|
||||
start date, child id, id; the optional ``child_id``, ``start`` and
|
||||
``end`` query filters (strict dates, ``end >= start``) are the
|
||||
core's to validate.
|
||||
|
||||
The uncomplete and regenerate routes (task da0226b3) follow the same
|
||||
pattern over :mod:`nestquest_core.completion` and
|
||||
@@ -172,6 +183,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 +490,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``.
|
||||
|
||||
@@ -573,6 +650,22 @@ class AdminPresenceOverrideResponse(BaseModel):
|
||||
note: str | None
|
||||
|
||||
|
||||
class AdminPresenceScheduleReadResponse(BaseModel):
|
||||
"""The ``GET /children/{child_id}/presence-schedule`` payload.
|
||||
|
||||
``schedule`` is ``None`` when the child has no schedule — the child
|
||||
is present every day (Feature 05); absence is state, not an error.
|
||||
"""
|
||||
|
||||
schedule: AdminPresenceScheduleResponse | None
|
||||
|
||||
|
||||
class AdminPresenceOverrideListResponse(BaseModel):
|
||||
"""The ``GET /presence-overrides`` payload: the matching overrides."""
|
||||
|
||||
overrides: list[AdminPresenceOverrideResponse]
|
||||
|
||||
|
||||
class AdminUncompleteResponse(BaseModel):
|
||||
"""The result of ``POST /api/v1/admin/instances/{id}/uncomplete``.
|
||||
|
||||
@@ -938,6 +1031,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 +1386,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",
|
||||
@@ -1445,6 +1608,75 @@ async def admin_delete_presence_override(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/children/{child_id}/presence-schedule",
|
||||
summary="Read a child's repeating presence schedule",
|
||||
response_model=AdminPresenceScheduleReadResponse,
|
||||
)
|
||||
async def admin_get_presence_schedule(
|
||||
child_id: int, request: Request
|
||||
) -> AdminPresenceScheduleReadResponse:
|
||||
"""Return the child's presence schedule, or ``schedule: null``.
|
||||
|
||||
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: one
|
||||
:func:`nestquest_core.presence_management.get_presence_schedule`
|
||||
call; a stored schedule is serialized exactly as the PUT response
|
||||
is, and a child without one answers ``{"schedule": null}`` (present
|
||||
every day). Errors are mapped by :func:`_raise_child_error`: an
|
||||
unknown child is 404.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
try:
|
||||
schedule = await core_presence_management.get_presence_schedule(
|
||||
state.database, child_id
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_child_error(error)
|
||||
return AdminPresenceScheduleReadResponse(
|
||||
schedule=(
|
||||
None if schedule is None else _presence_schedule_response(schedule)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/presence-overrides",
|
||||
summary="List presence overrides",
|
||||
response_model=AdminPresenceOverrideListResponse,
|
||||
)
|
||||
async def admin_list_presence_overrides(
|
||||
request: Request,
|
||||
child_id: int | None = None,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
) -> AdminPresenceOverrideListResponse:
|
||||
"""Return the household's presence overrides, optionally filtered.
|
||||
|
||||
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 optional
|
||||
``child_id``, ``start`` and ``end`` query parameters go straight to
|
||||
:func:`nestquest_core.presence_management.list_presence_overrides`
|
||||
— the date policy (strict ``YYYY-MM-DD``, ``end >= start``) and the
|
||||
overlap rule live in the core — and each record is serialized
|
||||
exactly as the POST response is, ordered by start date, child id,
|
||||
id. Errors are mapped by :func:`_raise_child_error`: an unknown
|
||||
``child_id`` is 404, a rejected date or range is 422.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
try:
|
||||
records = await core_presence_management.list_presence_overrides(
|
||||
state.database, child_id=child_id, start=start, end=end
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_child_error(error)
|
||||
return AdminPresenceOverrideListResponse(
|
||||
overrides=[_presence_override_response(record) for record in records]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/uncomplete",
|
||||
summary="Reverse one completion (append a reversal event)",
|
||||
|
||||
@@ -270,6 +270,57 @@ class PresenceOverridesDao:
|
||||
)
|
||||
return [_override_from_row(row) for row in rows]
|
||||
|
||||
async def list_filtered(
|
||||
self,
|
||||
*,
|
||||
child_id: int | None = None,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
) -> list[PresenceOverrideRecord]:
|
||||
"""Return the household's overrides, optionally narrowed.
|
||||
|
||||
Every filter is optional and they combine: ``child_id`` keeps
|
||||
that child's overrides only (an unknown child raises ValueError
|
||||
rather than reading as "no overrides"); ``start`` keeps
|
||||
overrides ending on or after it and ``end`` keeps overrides
|
||||
starting on or before it, so both together select the overrides
|
||||
overlapping the closed range ``[start, end]`` (the
|
||||
:meth:`list_by_child_and_range` boundary rule). Dates are
|
||||
strict YYYY-MM-DD and ``end`` must not precede ``start``.
|
||||
Ordered stably by start date, then child id, then id.
|
||||
"""
|
||||
if start is not None:
|
||||
_validate_date(start, "start")
|
||||
if end is not None:
|
||||
_validate_date(end, "end")
|
||||
if start is not None and end is not None and end < start:
|
||||
raise ValueError(
|
||||
f"end must be on or after start, got {end!r} < {start!r}"
|
||||
)
|
||||
clauses: list[str] = []
|
||||
params: list[object] = []
|
||||
if child_id is not None:
|
||||
child = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
||||
)
|
||||
if child is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
clauses.append("child_id = ?")
|
||||
params.append(child_id)
|
||||
if start is not None:
|
||||
clauses.append("end_date >= ?")
|
||||
params.append(start)
|
||||
if end is not None:
|
||||
clauses.append("start_date <= ?")
|
||||
params.append(end)
|
||||
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides{where} "
|
||||
"ORDER BY start_date, child_id, id",
|
||||
tuple(params),
|
||||
)
|
||||
return [_override_from_row(row) for row in rows]
|
||||
|
||||
async def get(self, override_id: int) -> PresenceOverrideRecord | None:
|
||||
"""Return one override by id, or None when it does not exist."""
|
||||
row = await self._database.fetch_one(
|
||||
|
||||
@@ -22,6 +22,10 @@ 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:``.
|
||||
|
||||
The admin plane's presence READS go through here too
|
||||
(:func:`get_presence_schedule`, :func:`list_presence_overrides`), so a
|
||||
reader never touches the DAO directly either.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -130,6 +134,42 @@ async def create_presence_override(
|
||||
return record
|
||||
|
||||
|
||||
async def get_presence_schedule(
|
||||
database: NestQuestDatabase, child_id: int
|
||||
) -> PresenceSchedule | None:
|
||||
"""Return the child's presence schedule decoded, or None when unset.
|
||||
|
||||
None means the child exists and has no schedule row — present
|
||||
every day (Feature 05). An unknown child raises ValueError
|
||||
(``child N does not exist``) rather than reading as present.
|
||||
"""
|
||||
record = await PresenceSchedulesDao(database).get_by_child(child_id)
|
||||
if record is None:
|
||||
return None
|
||||
return PresenceSchedule.decode(
|
||||
record.child_id, record.anchor_date, record.pattern
|
||||
)
|
||||
|
||||
|
||||
async def list_presence_overrides(
|
||||
database: NestQuestDatabase,
|
||||
*,
|
||||
child_id: int | None = None,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
) -> list[PresenceOverrideRecord]:
|
||||
"""Return the household's presence overrides, optionally filtered.
|
||||
|
||||
``child_id`` narrows to one child (unknown: ``child N does not
|
||||
exist``); ``start``/``end`` keep the overrides overlapping that
|
||||
closed range (strict YYYY-MM-DD, ``end >= start``). Ordered by
|
||||
start date, then child id, then id.
|
||||
"""
|
||||
return await PresenceOverridesDao(database).list_filtered(
|
||||
child_id=child_id, start=start, end=end
|
||||
)
|
||||
|
||||
|
||||
async def delete_presence_override(
|
||||
database: NestQuestDatabase,
|
||||
override_id: int,
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Admin-plane presence READ route tests (task 07fde892).
|
||||
|
||||
``GET /api/v1/admin/children/{child_id}/presence-schedule`` and
|
||||
``GET /api/v1/admin/presence-overrides`` against the ASGI app with
|
||||
httpx, through the shared stubbed-JWKS harness
|
||||
(:mod:`tests.admin_jwt_harness`) — no network access.
|
||||
|
||||
- The schedule read answers ``{"schedule": {...}}`` in the PUT
|
||||
response's shape for a seeded schedule, ``{"schedule": null}`` for a
|
||||
child with none (present every day — NOT a 404), and 404 for an
|
||||
unknown child.
|
||||
- The overrides read answers ``{"overrides": [...]}`` in the POST
|
||||
response's shape, ordered by start date, child id, id; ``child_id``
|
||||
and the ``start``/``end`` range each narrow the list; a malformed or
|
||||
inverted range is 422 and an unknown ``child_id`` is 404.
|
||||
- Both routes inherit the router's ONE ``require_admin``: no
|
||||
credential 401, the panel service token 403, a non-admin JWT 403, an
|
||||
admin JWT 200.
|
||||
|
||||
Seeding goes through the admin write routes, so the reads are proven
|
||||
against rows the real write path stored.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for, make_token
|
||||
|
||||
OVERRIDES_URL = "/api/v1/admin/presence-overrides"
|
||||
|
||||
#: 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"
|
||||
)
|
||||
|
||||
|
||||
def _schedule_url(child_id: int) -> str:
|
||||
return f"/api/v1/admin/children/{child_id}/presence-schedule"
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
async def _create_child(client: object, name: str) -> 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"]
|
||||
|
||||
|
||||
async def _create_override(
|
||||
client: object,
|
||||
child_id: int,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
is_present: bool = False,
|
||||
note: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Create one override through the admin write route; return it."""
|
||||
created = await client.post(
|
||||
OVERRIDES_URL,
|
||||
json={
|
||||
"child_id": child_id,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"is_present": is_present,
|
||||
"note": note,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
return created.json()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def reads(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane client over a fresh database."""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(client=client)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seeded(reads: SimpleNamespace) -> SimpleNamespace:
|
||||
"""Two children with interleaved overrides (Ada has a schedule)."""
|
||||
client = reads.client
|
||||
ada = await _create_child(client, "Ada")
|
||||
ben = await _create_child(client, "Ben")
|
||||
put = await client.put(
|
||||
_schedule_url(ada),
|
||||
json={
|
||||
"cycle_length_weeks": 2,
|
||||
"anchor_date": "2026-01-05",
|
||||
"pattern": {"0": [4, 0, 2], "1": []},
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert put.status_code == 200
|
||||
ada_march = await _create_override(
|
||||
client, ada, "2026-03-01", "2026-03-07", note="grandparents"
|
||||
)
|
||||
ben_january = await _create_override(
|
||||
client, ben, "2026-01-10", "2026-01-12", is_present=True
|
||||
)
|
||||
ben_march = await _create_override(client, ben, "2026-03-01", "2026-03-01")
|
||||
ada_may = await _create_override(client, ada, "2026-05-20", "2026-05-20")
|
||||
return SimpleNamespace(
|
||||
client=client,
|
||||
ada=ada,
|
||||
ben=ben,
|
||||
ada_march=ada_march,
|
||||
ben_january=ben_january,
|
||||
ben_march=ben_march,
|
||||
ada_may=ada_may,
|
||||
)
|
||||
|
||||
|
||||
# --- presence schedule read ------------------------------------------------
|
||||
|
||||
|
||||
async def test_schedule_read_returns_the_stored_schedule(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A seeded schedule reads back in the PUT response's shape."""
|
||||
response = await seeded.client.get(
|
||||
_schedule_url(seeded.ada), headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"schedule": {
|
||||
"child_id": seeded.ada,
|
||||
"cycle_length_weeks": 2,
|
||||
"anchor_date": "2026-01-05",
|
||||
"pattern": {"0": [0, 2, 4], "1": []},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def test_schedule_read_without_schedule_is_null_not_404(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A child with no schedule (present every day) is schedule: null."""
|
||||
response = await seeded.client.get(
|
||||
_schedule_url(seeded.ben), headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"schedule": None}
|
||||
|
||||
|
||||
async def test_schedule_read_unknown_child_is_404(
|
||||
reads: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An unknown child is 404 with the children routes' detail."""
|
||||
response = await reads.client.get(
|
||||
_schedule_url(999), headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Child not found"}
|
||||
|
||||
|
||||
# --- presence overrides read -----------------------------------------------
|
||||
|
||||
|
||||
async def test_overrides_read_lists_all_in_stable_order(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Unfiltered: every override, by start date, then child id, then id."""
|
||||
response = await seeded.client.get(
|
||||
OVERRIDES_URL, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"overrides": [
|
||||
seeded.ben_january,
|
||||
seeded.ada_march,
|
||||
seeded.ben_march,
|
||||
seeded.ada_may,
|
||||
]
|
||||
}
|
||||
assert seeded.ada_march == {
|
||||
"id": seeded.ada_march["id"],
|
||||
"child_id": seeded.ada,
|
||||
"start_date": "2026-03-01",
|
||||
"end_date": "2026-03-07",
|
||||
"is_present": False,
|
||||
"note": "grandparents",
|
||||
}
|
||||
|
||||
|
||||
async def test_overrides_read_empty_household(reads: SimpleNamespace) -> None:
|
||||
"""No overrides at all is an empty list."""
|
||||
response = await reads.client.get(OVERRIDES_URL, headers=_admin_headers())
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"overrides": []}
|
||||
|
||||
|
||||
async def test_overrides_read_filters_by_child(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""child_id keeps only that child's overrides."""
|
||||
response = await seeded.client.get(
|
||||
OVERRIDES_URL,
|
||||
params={"child_id": seeded.ada},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"overrides": [seeded.ada_march, seeded.ada_may]
|
||||
}
|
||||
|
||||
|
||||
async def test_overrides_read_filters_by_overlapping_range(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""start/end keep overrides overlapping the closed range (inclusive)."""
|
||||
# 2026-03-07 is Ada's March override's LAST day: the boundary counts.
|
||||
response = await seeded.client.get(
|
||||
OVERRIDES_URL,
|
||||
params={"start": "2026-03-07", "end": "2026-05-20"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"overrides": [seeded.ada_march, seeded.ada_may]
|
||||
}
|
||||
|
||||
|
||||
async def test_overrides_read_open_ended_range_filters(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""start alone keeps later-ending, end alone keeps earlier-starting."""
|
||||
from_april = await seeded.client.get(
|
||||
OVERRIDES_URL, params={"start": "2026-04-01"}, headers=_admin_headers()
|
||||
)
|
||||
assert from_april.json() == {"overrides": [seeded.ada_may]}
|
||||
until_january = await seeded.client.get(
|
||||
OVERRIDES_URL, params={"end": "2026-01-31"}, headers=_admin_headers()
|
||||
)
|
||||
assert until_january.json() == {"overrides": [seeded.ben_january]}
|
||||
|
||||
|
||||
async def test_overrides_read_combines_child_and_range(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""child_id and the range narrow together."""
|
||||
response = await seeded.client.get(
|
||||
OVERRIDES_URL,
|
||||
params={"child_id": seeded.ben, "start": "2026-02-01", "end": "2026-12-31"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"overrides": [seeded.ben_march]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"params",
|
||||
[
|
||||
{"start": "2026-03-10", "end": "2026-03-01"},
|
||||
{"start": "2026-3-1"},
|
||||
{"end": "not-a-date"},
|
||||
{"start": "2026-02-30"},
|
||||
],
|
||||
)
|
||||
async def test_overrides_read_invalid_range_is_422(
|
||||
seeded: SimpleNamespace, params: dict[str, str]
|
||||
) -> None:
|
||||
"""A malformed date or an inverted range is the core's 422."""
|
||||
response = await seeded.client.get(
|
||||
OVERRIDES_URL, params=params, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_overrides_read_unknown_child_is_404(
|
||||
reads: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An unknown child_id filter is 404, never an empty list."""
|
||||
response = await reads.client.get(
|
||||
OVERRIDES_URL, params={"child_id": 999}, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Child not found"}
|
||||
|
||||
|
||||
# --- authentication: the router's ONE require_admin --------------------------
|
||||
|
||||
|
||||
@pytest.fixture(params=["schedule", "overrides"])
|
||||
async def read_url(request: pytest.FixtureRequest, reads: SimpleNamespace) -> str:
|
||||
"""Each read route's URL (the schedule route for a real child)."""
|
||||
if request.param == "overrides":
|
||||
return OVERRIDES_URL
|
||||
return _schedule_url(await _create_child(reads.client, "Ada"))
|
||||
|
||||
|
||||
async def test_read_without_credential_is_401(
|
||||
reads: SimpleNamespace, read_url: str
|
||||
) -> None:
|
||||
response = await reads.client.get(read_url)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_read_refuses_panel_service_token(
|
||||
reads: SimpleNamespace, read_url: str
|
||||
) -> None:
|
||||
response = await reads.client.get(
|
||||
read_url, headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
||||
|
||||
|
||||
async def test_read_refuses_non_admin_jwt(
|
||||
reads: SimpleNamespace, read_url: str
|
||||
) -> None:
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
response = await reads.client.get(
|
||||
read_url, headers={"Authorization": f"Bearer {non_admin}"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
||||
|
||||
|
||||
async def test_read_admin_jwt_is_200(
|
||||
reads: SimpleNamespace, read_url: str
|
||||
) -> None:
|
||||
response = await reads.client.get(read_url, headers=_admin_headers())
|
||||
assert response.status_code == 200
|
||||
Reference in New Issue
Block a user