Task 07fde892: Admin presence schedule and override reads (#200)
This commit was merged in pull request #200.
This commit is contained in:
@@ -58,6 +58,17 @@ layer the HA services share, so the two planes cannot drift:
|
|||||||
(children are never hard-deleted; overrides are deletable by
|
(children are never hard-deleted; overrides are deletable by
|
||||||
design) and regenerates that override's child. It answers
|
design) and regenerates that override's child. It answers
|
||||||
``{"status": "ok"}``.
|
``{"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
|
The uncomplete and regenerate routes (task da0226b3) follow the same
|
||||||
pattern over :mod:`nestquest_core.completion` and
|
pattern over :mod:`nestquest_core.completion` and
|
||||||
@@ -639,6 +650,22 @@ class AdminPresenceOverrideResponse(BaseModel):
|
|||||||
note: str | None
|
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):
|
class AdminUncompleteResponse(BaseModel):
|
||||||
"""The result of ``POST /api/v1/admin/instances/{id}/uncomplete``.
|
"""The result of ``POST /api/v1/admin/instances/{id}/uncomplete``.
|
||||||
|
|
||||||
@@ -1581,6 +1608,75 @@ async def admin_delete_presence_override(
|
|||||||
return {"status": "ok"}
|
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(
|
@router.post(
|
||||||
"/instances/{instance_id}/uncomplete",
|
"/instances/{instance_id}/uncomplete",
|
||||||
summary="Reverse one completion (append a reversal event)",
|
summary="Reverse one completion (append a reversal event)",
|
||||||
|
|||||||
@@ -270,6 +270,57 @@ class PresenceOverridesDao:
|
|||||||
)
|
)
|
||||||
return [_override_from_row(row) for row in rows]
|
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:
|
async def get(self, override_id: int) -> PresenceOverrideRecord | None:
|
||||||
"""Return one override by id, or None when it does not exist."""
|
"""Return one override by id, or None when it does not exist."""
|
||||||
row = await self._database.fetch_one(
|
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
|
override raises ValueError prefixed ``override_id:`` so a caller can
|
||||||
distinguish "unknown id" from a rejected argument the same way the
|
distinguish "unknown id" from a rejected argument the same way the
|
||||||
quest-definitions layer prefixes ``definition_id:``.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -130,6 +134,42 @@ async def create_presence_override(
|
|||||||
return record
|
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(
|
async def delete_presence_override(
|
||||||
database: NestQuestDatabase,
|
database: NestQuestDatabase,
|
||||||
override_id: int,
|
override_id: int,
|
||||||
|
|||||||
@@ -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