Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
465 lines
16 KiB
Python
465 lines
16 KiB
Python
"""Admin-plane children route tests (task e4845baa).
|
|
|
|
These tests exercise the admin plane's children routes 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 — signature, issuer, audience, expiry and the
|
|
nestquest-admins group — with NO network access.
|
|
|
|
Every done-condition case is proven against the DATABASE, read through
|
|
the SAME core copy the app uses (``nestquest_core.*``; see the two-copy
|
|
caveat in api/nestquest_core.py):
|
|
|
|
- Authentication runs through the router's ONE ``require_admin``
|
|
dependency: an admin JWT passes, a JWT without the group is 403, an
|
|
absent credential is 401 — and no row is written.
|
|
- ``POST /api/v1/admin/children``: the created child EXISTS with the
|
|
given display name (and the optional colour/avatar_ref/sort_order).
|
|
- ``PATCH /api/v1/admin/children/{id}``: an edit PERSISTS the supplied
|
|
fields and leaves omitted fields untouched (exclude_unset semantics —
|
|
an omitted field is never passed to the core).
|
|
- ``PATCH /api/v1/admin/children/{id}/active`` with ``is_active: false``
|
|
sets the active flag FALSE and the row STILL EXISTS (children are
|
|
never hard-deleted).
|
|
- ``POST /api/v1/admin/children/reorder``: the posted order becomes the
|
|
rows' sort_order; a list that is not a complete permutation is 422
|
|
and leaves the stored order untouched.
|
|
|
|
Error mapping (documented in api/routes_admin.py): a core ValueError
|
|
for a non-existent child is 404; every other core ValueError (an empty
|
|
display name, an explicit null colour, a bad permutation) is 422 — the
|
|
same failure class FastAPI reports for malformed bodies.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from tests.admin_jwt_harness import (
|
|
ADMIN_KEY,
|
|
KID,
|
|
AdminRunner,
|
|
jwks_for,
|
|
make_token,
|
|
)
|
|
|
|
# The children business layer through the SAME core copy the app uses
|
|
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy) —
|
|
# used for the tests' database assertions only; the routes reach it
|
|
# through api.nestquest_core.
|
|
_children = importlib.import_module("nestquest_core.children")
|
|
|
|
|
|
def _admin_headers() -> dict[str, str]:
|
|
"""The Authorization header of a valid nestquest-admins JWT."""
|
|
return {"Authorization": f"Bearer {make_token()}"}
|
|
|
|
|
|
async def _children_by_id(database: object) -> dict[int, object]:
|
|
"""Every child row through the app's core copy, keyed by id."""
|
|
records = await _children.list_children(database)
|
|
return {record.id: record for record in records}
|
|
|
|
|
|
@pytest.fixture
|
|
async def admin_children(temp_db_path: str) -> SimpleNamespace:
|
|
"""An admin-plane app client plus its live database.
|
|
|
|
The same runner the auth tests use (stubbed JWKS, no network); the
|
|
fixture also exposes the app's ONE database connection so tests
|
|
assert the routes' effects on the stored rows themselves.
|
|
"""
|
|
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,
|
|
)
|
|
|
|
|
|
# --- authentication: the ONE require_admin dependency guards every route ---
|
|
|
|
|
|
async def test_children_routes_refuse_non_admin_and_absent_credentials(
|
|
admin_children: SimpleNamespace,
|
|
) -> None:
|
|
"""A non-admin JWT is 403, an absent credential is 401: nothing written."""
|
|
client = admin_children.client
|
|
body = {"display_name": "Ada"}
|
|
non_admin = make_token(groups=("some-other-group",))
|
|
|
|
forbidden = await client.post(
|
|
"/api/v1/admin/children",
|
|
json=body,
|
|
headers={"Authorization": f"Bearer {non_admin}"},
|
|
)
|
|
assert forbidden.status_code == 403
|
|
assert forbidden.json()["detail"] == (
|
|
"Admin access requires membership in the nestquest-admins group"
|
|
)
|
|
|
|
unauthenticated = await client.post("/api/v1/admin/children", json=body)
|
|
assert unauthenticated.status_code == 401
|
|
|
|
unauthenticated_list = await client.get("/api/v1/admin/children")
|
|
assert unauthenticated_list.status_code == 401
|
|
|
|
# No route ran its handler: the household is still empty.
|
|
assert await _children_by_id(admin_children.database) == {}
|
|
|
|
|
|
# --- create -----------------------------------------------------------------
|
|
|
|
|
|
async def test_create_child_persists_the_given_name(
|
|
admin_children: SimpleNamespace,
|
|
) -> None:
|
|
"""POST /children creates the row; the child exists with that name."""
|
|
response = await admin_children.client.post(
|
|
"/api/v1/admin/children",
|
|
json={
|
|
"display_name": "Ada",
|
|
"colour": "#ff8800",
|
|
"avatar_ref": "avatar:bee",
|
|
"sort_order": 2,
|
|
},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert response.status_code == 201
|
|
body = response.json()
|
|
assert body["display_name"] == "Ada"
|
|
assert body["colour"] == "#ff8800"
|
|
assert body["avatar_ref"] == "avatar:bee"
|
|
assert body["sort_order"] == 2
|
|
assert body["is_active"] is True
|
|
|
|
stored = (await _children_by_id(admin_children.database))[body["id"]]
|
|
assert stored.display_name == "Ada"
|
|
assert stored.colour == "#ff8800"
|
|
assert stored.avatar_ref == "avatar:bee"
|
|
assert stored.sort_order == 2
|
|
assert stored.is_active is True
|
|
|
|
|
|
async def test_create_child_with_only_a_name_uses_core_defaults(
|
|
admin_children: SimpleNamespace,
|
|
) -> None:
|
|
"""Omitted optional fields default through the core layer."""
|
|
response = await admin_children.client.post(
|
|
"/api/v1/admin/children",
|
|
json={"display_name": "Bo"},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert response.status_code == 201
|
|
stored = (await _children_by_id(admin_children.database))[
|
|
response.json()["id"]
|
|
]
|
|
assert stored.display_name == "Bo"
|
|
assert stored.colour is None
|
|
assert stored.avatar_ref is None
|
|
assert stored.sort_order == 0
|
|
assert stored.is_active is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"body",
|
|
[
|
|
{}, # display_name missing
|
|
{"display_name": 5}, # wrong type
|
|
{"display_name": ""}, # empty: the core rejects it
|
|
{"display_name": " "}, # whitespace-only: empty to a parent
|
|
{"display_name": "Ada", "sort_order": "first"}, # wrong type
|
|
],
|
|
)
|
|
async def test_create_child_malformed_input_is_422(
|
|
admin_children: SimpleNamespace, body: dict[str, object]
|
|
) -> None:
|
|
"""Malformed bodies are 422 and never create a row."""
|
|
response = await admin_children.client.post(
|
|
"/api/v1/admin/children", json=body, headers=_admin_headers()
|
|
)
|
|
assert response.status_code == 422
|
|
assert await _children_by_id(admin_children.database) == {}
|
|
|
|
|
|
# --- edit -------------------------------------------------------------------
|
|
|
|
|
|
async def test_edit_child_persists_supplied_fields_and_skips_omitted_ones(
|
|
admin_children: SimpleNamespace,
|
|
) -> None:
|
|
"""PATCH persists exactly the supplied fields (exclude_unset)."""
|
|
client = admin_children.client
|
|
created = await client.post(
|
|
"/api/v1/admin/children",
|
|
json={
|
|
"display_name": "Ada",
|
|
"colour": "#ff8800",
|
|
"avatar_ref": "avatar:bee",
|
|
},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert created.status_code == 201
|
|
child_id = created.json()["id"]
|
|
|
|
# Only display_name and sort_order: colour/avatar_ref must survive.
|
|
edited = await client.patch(
|
|
f"/api/v1/admin/children/{child_id}",
|
|
json={"display_name": "Ada Lovelace", "sort_order": 4},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert edited.status_code == 200
|
|
assert edited.json()["display_name"] == "Ada Lovelace"
|
|
assert edited.json()["sort_order"] == 4
|
|
stored = (await _children_by_id(admin_children.database))[child_id]
|
|
assert stored.display_name == "Ada Lovelace"
|
|
assert stored.sort_order == 4
|
|
# The omitted fields were never passed to the core: unchanged.
|
|
assert stored.colour == "#ff8800"
|
|
assert stored.avatar_ref == "avatar:bee"
|
|
|
|
# A second edit touching only colour leaves the name alone.
|
|
recoloured = await client.patch(
|
|
f"/api/v1/admin/children/{child_id}",
|
|
json={"colour": "#00aa55"},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert recoloured.status_code == 200
|
|
stored = (await _children_by_id(admin_children.database))[child_id]
|
|
assert stored.colour == "#00aa55"
|
|
assert stored.display_name == "Ada Lovelace"
|
|
|
|
|
|
async def test_edit_unknown_child_is_404(
|
|
admin_children: SimpleNamespace,
|
|
) -> None:
|
|
"""An edit naming a child that does not exist is 404."""
|
|
response = await admin_children.client.patch(
|
|
"/api/v1/admin/children/999",
|
|
json={"display_name": "Nobody"},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"] == "Child not found"
|
|
|
|
|
|
async def test_edit_explicit_null_colour_is_422_and_changes_nothing(
|
|
admin_children: SimpleNamespace,
|
|
) -> None:
|
|
"""An explicit null (clearing) is passed through and rejected by the core.
|
|
|
|
Only the fields the request SUPPLIES are forwarded: a null colour is
|
|
a supplied None, which the core refuses (the DAO cannot express
|
|
NULL) — mapped to 422, with the stored colour left untouched.
|
|
"""
|
|
created = await admin_children.client.post(
|
|
"/api/v1/admin/children",
|
|
json={"display_name": "Ada", "colour": "#ff8800"},
|
|
headers=_admin_headers(),
|
|
)
|
|
child_id = created.json()["id"]
|
|
response = await admin_children.client.patch(
|
|
f"/api/v1/admin/children/{child_id}",
|
|
json={"colour": None},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert response.status_code == 422
|
|
stored = (await _children_by_id(admin_children.database))[child_id]
|
|
assert stored.colour == "#ff8800"
|
|
|
|
|
|
# --- set_active -------------------------------------------------------------
|
|
|
|
|
|
async def test_deactivation_sets_the_flag_false_and_never_deletes(
|
|
admin_children: SimpleNamespace,
|
|
) -> None:
|
|
"""is_active=false deactivates the row; the row STILL exists."""
|
|
client = admin_children.client
|
|
created = await client.post(
|
|
"/api/v1/admin/children",
|
|
json={"display_name": "Ada"},
|
|
headers=_admin_headers(),
|
|
)
|
|
child_id = created.json()["id"]
|
|
|
|
deactivated = await client.patch(
|
|
f"/api/v1/admin/children/{child_id}/active",
|
|
json={"is_active": False},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert deactivated.status_code == 200
|
|
assert deactivated.json()["is_active"] is False
|
|
|
|
# Database proof: the flag is False and the row was NOT hard-deleted.
|
|
rows = await _children_by_id(admin_children.database)
|
|
stored = rows[child_id]
|
|
assert stored.is_active is False
|
|
assert stored.display_name == "Ada"
|
|
|
|
# Reactivation goes through the same route: the flag is set, not
|
|
# one-way.
|
|
reactivated = await client.patch(
|
|
f"/api/v1/admin/children/{child_id}/active",
|
|
json={"is_active": True},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert reactivated.status_code == 200
|
|
assert reactivated.json()["is_active"] is True
|
|
rows = await _children_by_id(admin_children.database)
|
|
assert rows[child_id].is_active is True
|
|
|
|
|
|
async def test_set_active_unknown_child_is_404(
|
|
admin_children: SimpleNamespace,
|
|
) -> None:
|
|
"""A set_active naming a child that does not exist is 404."""
|
|
response = await admin_children.client.patch(
|
|
"/api/v1/admin/children/999/active",
|
|
json={"is_active": False},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"] == "Child not found"
|
|
|
|
|
|
async def test_set_active_malformed_body_is_422(
|
|
admin_children: SimpleNamespace,
|
|
) -> None:
|
|
"""A non-bool is_active is rejected by the body model (422)."""
|
|
response = await admin_children.client.patch(
|
|
"/api/v1/admin/children/1/active",
|
|
json={"is_active": "maybe"},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
# --- reorder ----------------------------------------------------------------
|
|
|
|
|
|
async def test_reorder_writes_the_posted_order_to_sort_order(
|
|
admin_children: SimpleNamespace,
|
|
) -> None:
|
|
"""The posted id order becomes the rows' sort_order."""
|
|
client = admin_children.client
|
|
ids = []
|
|
for name in ("Ada", "Bo", "Cyd"):
|
|
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"])
|
|
|
|
response = await client.post(
|
|
"/api/v1/admin/children/reorder",
|
|
json={"ordered_ids": [ids[2], ids[0], ids[1]]},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "ok"}
|
|
|
|
# Database proof: each posted position became that child's sort_order.
|
|
rows = await _children_by_id(admin_children.database)
|
|
assert rows[ids[2]].sort_order == 0
|
|
assert rows[ids[0]].sort_order == 1
|
|
assert rows[ids[1]].sort_order == 2
|
|
|
|
|
|
async def test_reorder_rejects_non_permutations_and_writes_nothing(
|
|
admin_children: SimpleNamespace,
|
|
) -> None:
|
|
"""A list that is not a complete permutation is 422, order intact.
|
|
|
|
The core rejects the list BEFORE any write, so the stored sort_order
|
|
values survive every rejected call unchanged.
|
|
"""
|
|
client = admin_children.client
|
|
ids = []
|
|
for name in ("Ada", "Bo"):
|
|
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"])
|
|
|
|
bad_lists = [
|
|
ids[:1], # partial: missing an existing child
|
|
[ids[0], ids[0], ids[1]], # duplicate id
|
|
[*ids, ids[1] + 999], # unknown id
|
|
]
|
|
for ordered_ids in bad_lists:
|
|
response = await client.post(
|
|
"/api/v1/admin/children/reorder",
|
|
json={"ordered_ids": ordered_ids},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert response.status_code == 422, ordered_ids
|
|
|
|
# A non-integer id never reaches the core either (body model).
|
|
malformed = await client.post(
|
|
"/api/v1/admin/children/reorder",
|
|
json={"ordered_ids": ["first", "second"]},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert malformed.status_code == 422
|
|
|
|
# Database proof: no write happened for any rejected call.
|
|
rows = await _children_by_id(admin_children.database)
|
|
assert [rows[child_id].sort_order for child_id in ids] == [0, 0]
|
|
|
|
|
|
# --- list (the read model the admin PWA re-renders from) ---------------------
|
|
|
|
|
|
async def test_list_children_returns_all_in_display_order(
|
|
admin_children: SimpleNamespace,
|
|
) -> None:
|
|
"""GET /children returns every child (inactive included) in order."""
|
|
client = admin_children.client
|
|
ada = await client.post(
|
|
"/api/v1/admin/children",
|
|
json={"display_name": "Ada", "sort_order": 5},
|
|
headers=_admin_headers(),
|
|
)
|
|
bo = await client.post(
|
|
"/api/v1/admin/children",
|
|
json={"display_name": "Bo", "sort_order": 1},
|
|
headers=_admin_headers(),
|
|
)
|
|
deactivated = await client.patch(
|
|
f"/api/v1/admin/children/{ada.json()['id']}/active",
|
|
json={"is_active": False},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert deactivated.status_code == 200
|
|
|
|
response = await client.get(
|
|
"/api/v1/admin/children", headers=_admin_headers()
|
|
)
|
|
assert response.status_code == 200
|
|
children = response.json()["children"]
|
|
assert [child["id"] for child in children] == [
|
|
bo.json()["id"],
|
|
ada.json()["id"],
|
|
]
|
|
by_id = {child["id"]: child for child in children}
|
|
assert by_id[ada.json()["id"]]["is_active"] is False
|
|
assert by_id[bo.json()["id"]]["is_active"] is True
|
|
# The serialized shape is the documented child payload.
|
|
assert set(by_id[bo.json()["id"]]) == {
|
|
"id",
|
|
"display_name",
|
|
"colour",
|
|
"avatar_ref",
|
|
"sort_order",
|
|
"is_active",
|
|
}
|