"""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