diff --git a/api/routes_admin.py b/api/routes_admin.py index 4d694b9..9bafe2e 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -58,6 +58,13 @@ 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"}``. +- ``POST /presence-overrides/consequence`` previews an override + BEFORE it is saved: ONE ``preview_presence_override_consequence`` + call answers ``{removed: N}``, the number of the child's open + upcoming instances the materialization walk would stop generating + (instances with a completion event are never counted, D-005). + Nothing is written; an unknown child is 404, a malformed or inverted + range 422. - ``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 @@ -666,6 +673,33 @@ class AdminPresenceOverrideListResponse(BaseModel): overrides: list[AdminPresenceOverrideResponse] +class AdminPresenceOverrideConsequenceRequest(BaseModel): + """The body of ``POST /api/v1/admin/presence-overrides/consequence``. + + The create body's shape minus the note (which cannot change what is + generated): the strict dates, their order and the real + ``is_present`` bool are enforced by the SAME + :class:`~nestquest_core.presence.PresenceOverride` constructor the + create route's core path builds; this model only guards the SHAPE. + """ + + child_id: int + start_date: str + end_date: str + is_present: bool + + +class AdminPresenceOverrideConsequenceResponse(BaseModel): + """The consequence preview: ``removed`` upcoming instances. + + The number of open (no completion event) upcoming quest instances + of the child that saving the override would remove — the "This + removes N upcoming tasks" warning. Always 0 for a present override. + """ + + removed: int + + class AdminUncompleteResponse(BaseModel): """The result of ``POST /api/v1/admin/instances/{id}/uncomplete``. @@ -1608,6 +1642,45 @@ async def admin_delete_presence_override( return {"status": "ok"} +@router.post( + "/presence-overrides/consequence", + summary="Count the upcoming instances a presence override would remove", + response_model=AdminPresenceOverrideConsequenceResponse, +) +async def admin_presence_override_consequence( + body: AdminPresenceOverrideConsequenceRequest, request: Request +) -> AdminPresenceOverrideConsequenceResponse: + """Return how many upcoming instances saving the override removes. + + 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.preview_presence_override_consequence` + call, anchored on ONE :func:`_local_now` clock read, which compares + the materialization walk's output for the child with and without + the proposed override and excludes instances that already have a + completion event (D-005). Nothing is written. + + Errors are mapped by :func:`_raise_child_error`: an unknown child + is 404; a malformed or inverted range is 422. + """ + state: DatabaseState = request.app.state.db + try: + removed = await ( + core_presence_management.preview_presence_override_consequence( + state.database, + body.child_id, + body.start_date, + body.end_date, + body.is_present, + today=_local_now().date(), + ) + ) + except ValueError as error: + _raise_child_error(error) + return AdminPresenceOverrideConsequenceResponse(removed=removed) + + @router.get( "/children/{child_id}/presence-schedule", summary="Read a child's repeating presence schedule", diff --git a/custom_components/nestquest/core/materialize.py b/custom_components/nestquest/core/materialize.py index fee785d..ffeeefa 100644 --- a/custom_components/nestquest/core/materialize.py +++ b/custom_components/nestquest/core/materialize.py @@ -64,9 +64,14 @@ the presence DAO (``dao_presence.py``) must stay free of business rules from __future__ import annotations import datetime +from typing import NamedTuple from .const import DEFAULT_HORIZON_DAYS -from .dao_instances import QuestInstancesDao, _resolve_today +from .dao_instances import ( + CompletionEventsDao, + QuestInstancesDao, + _resolve_today, +) from .dao_rules import ( _validate_date, load_materialization_input, @@ -126,8 +131,15 @@ def _decode_rule(snapshot) -> ScheduleRule: def _build_engine( schedules_records, overrides_records, + *, + extra_overrides: tuple[PresenceOverride, ...] = (), ) -> PresenceEngine: - """Convert raw presence records into an immutable PresenceEngine.""" + """Convert raw presence records into an immutable PresenceEngine. + + ``extra_overrides`` layers already-validated, NOT-yet-stored + overrides on top of the stored ones — the consequence preview's + proposed override (:func:`count_removed_by_override`). + """ schedules: dict[int, PresenceSchedule] = {} for child_id, record in schedules_records.items(): schedules[child_id] = PresenceSchedule.decode( @@ -145,9 +157,75 @@ def _build_engine( ) for record in records ] + for override in extra_overrides: + overrides.setdefault(override.child_id, []).append(override) return PresenceEngine(schedules, overrides) +class GeneratedTuple(NamedTuple): + """One instance the walk WOULD write: its upsert key plus due time.""" + + definition_id: int + child_id: int + due_date: str + window: str + due_time: str | None + + +def _generation_preview( + snapshots, + engine: PresenceEngine, + start: datetime.date, + end: datetime.date, + *, + child_filter: set[int] | None = None, +) -> list[GeneratedTuple]: + """Return the tuples the walk would write over ``[start, end]``. + + The pure, read-only half of :func:`materialize`: for each date, each + definition whose decoded rule :func:`~.recurrence.occurs_on` it, + each assignee in ``child_filter`` (every assignee when None) who is + ``is_present`` on it — or every assignee when the definition's + ``skip_on_away`` is off — and each declared window, one tuple, in + the walk's write order. :func:`materialize` writes exactly these + tuples and :func:`count_removed_by_override` compares two of these + previews, so the write and the consequence preview can never + diverge. No database access. + """ + definitions = [ + (snapshot.definition.id, _decode_rule(snapshot), + snapshot.assignees, snapshot.windows, + snapshot.definition.skip_on_away) + for snapshot in snapshots + ] + generated: list[GeneratedTuple] = [] + cursor = start + while cursor <= end: + iso = cursor.isoformat() + for ( + definition_id, rule, assignees, windows, skip_on_away + ) in definitions: + if not occurs_on(rule, cursor): + continue + for child in assignees: + if child_filter is not None and child.id not in child_filter: + continue + if skip_on_away and not engine.is_present(child.id, cursor): + continue + for window in windows: + generated.append( + GeneratedTuple( + definition_id, + child.id, + iso, + window.window, + window.due_time, + ) + ) + cursor += datetime.timedelta(days=1) + return generated + + async def materialize( database: NestQuestDatabase, start_date: str, @@ -207,13 +285,6 @@ async def materialize( engine = _build_engine(schedules_records, overrides_records) child_filter = None if child_ids is None else set(child_ids) - definitions = [ - (snapshot.definition.id, _decode_rule(snapshot), - snapshot.assignees, snapshot.windows, - snapshot.definition.skip_on_away) - for snapshot in snapshots - ] - generated_at = _now_stamp() instances = QuestInstancesDao(database) @@ -228,32 +299,20 @@ async def materialize( start = max(start, today_date) count = 0 - cursor = start - while cursor <= end: - iso = cursor.isoformat() - for ( - definition_id, rule, assignees, windows, skip_on_away - ) in definitions: - if not occurs_on(rule, cursor): - continue - for child in assignees: - if child_filter is not None and child.id not in child_filter: - continue - if skip_on_away and not engine.is_present(child.id, cursor): - continue - for window in windows: - written = await instances.upsert_if_valid( - definition_id, - child.id, - iso, - generated_at, - window=window.window, - due_time=window.due_time, - today=today, - ) - if written is not None: - count += 1 - cursor += datetime.timedelta(days=1) + for generated in _generation_preview( + snapshots, engine, start, end, child_filter=child_filter + ): + written = await instances.upsert_if_valid( + generated.definition_id, + generated.child_id, + generated.due_date, + generated_at, + window=generated.window, + due_time=generated.due_time, + today=today, + ) + if written is not None: + count += 1 return count @@ -379,3 +438,93 @@ async def regenerate_for_child( return await materialize( database, start.isoformat(), end_date, child_ids=[child_id], today=today ) + + +async def count_removed_by_override( + database: NestQuestDatabase, + override: PresenceOverride, + *, + today: datetime.date | None = None, + horizon_days: int | None = None, +) -> int: + """Count the upcoming instances saving ``override`` would remove. + + Read-only: nothing is written. The window is the part of the + override's range inside the rolling horizon ``[today, today + + horizon_days]`` that :func:`regenerate_for_child` rebuilds after the + override is saved (outside the range the two presence answers agree, + so nothing there can change). The materialization input is read + ONCE (:func:`~.dao_rules.load_materialization_input`) and + :func:`_generation_preview` runs twice over it for the override's + child — with the stored presence, and with ``override`` layered on + top (:func:`_build_engine`'s ``extra_overrides``). The count is the + tuples the first preview generates and the second does not, MINUS + every (definition, date, window) whose stored instance already has a + completion event (D-005: regeneration never deletes those). A + definition with ``skip_on_away`` off generates regardless of + presence, so it contributes nothing; a present override only ever + ADDS tuples, so it counts 0. + + An override overlapping a stored one (which the create path refuses) + resolves through the engine's latest-start-wins rule. ``today`` and + ``horizon_days`` resolve exactly as :func:`regenerate_for_child` + resolves them; a malformed horizon raises ValueError. + """ + horizon = _resolve_horizon_days(horizon_days) + today_date = _resolve_today(today) + start = max(today_date, override.start_date) + end = min( + today_date + datetime.timedelta(days=horizon), override.end_date + ) + if end < start: + return 0 + start_date, end_date = start.isoformat(), end.isoformat() + child_id = override.child_id + + snapshots, schedules_records, overrides_records = await ( + load_materialization_input(database, start_date, end_date) + ) + current = _generation_preview( + snapshots, + _build_engine(schedules_records, overrides_records), + start, + end, + child_filter={child_id}, + ) + proposed = set( + _generation_preview( + snapshots, + _build_engine( + schedules_records, + overrides_records, + extra_overrides=(override,), + ), + start, + end, + child_filter={child_id}, + ) + ) + removed = [generated for generated in current if generated not in proposed] + if not removed: + return 0 + + stored = await QuestInstancesDao(database).list_by_date_range( + child_id, start_date, end_date + ) + touched = { + event.instance_id + for event in await CompletionEventsDao( + database + ).list_by_child_and_date_range(child_id, start_date, end_date) + } + completed = { + (instance.definition_id, instance.due_date, instance.window) + for instance in stored + if instance.id in touched + } + return sum( + 1 + for generated in removed + if (generated.definition_id, generated.due_date, generated.window) + not in completed + ) diff --git a/custom_components/nestquest/core/presence_management.py b/custom_components/nestquest/core/presence_management.py index b1668ab..fea6189 100644 --- a/custom_components/nestquest/core/presence_management.py +++ b/custom_components/nestquest/core/presence_management.py @@ -31,13 +31,14 @@ from __future__ import annotations import datetime +from .dao_children import ChildrenDao from .dao_presence import ( PresenceOverrideRecord, PresenceOverridesDao, PresenceSchedulesDao, ) from .db import NestQuestDatabase -from .materialize import regenerate_for_child +from .materialize import count_removed_by_override, regenerate_for_child from .presence import PresenceOverride, PresenceSchedule @@ -134,6 +135,42 @@ async def create_presence_override( return record +async def preview_presence_override_consequence( + database: NestQuestDatabase, + child_id: int, + start_date: object, + end_date: object, + is_present: bool, + *, + today: datetime.date | None = None, + horizon_days: int | None = None, +) -> int: + """Return how many upcoming instances saving this override removes. + + The admin override editor's live consequence warning, computed + BEFORE anything is saved: the arguments build the SAME + :class:`~.presence.PresenceOverride` :func:`create_presence_override` + builds (so a malformed or inverted range, or a non-bool + ``is_present``, raises the same ValueError), then + :func:`~.materialize.count_removed_by_override` compares what the + materialization walk generates for the child with and without it. + Nothing is written. An unknown child reads ``child N does not + exist``; an INACTIVE child has no instances to remove (the walk's + write path never materializes one), so it counts 0. + ``today``/``horizon_days`` resolve as the post-save regeneration + resolves them. + """ + override = PresenceOverride(child_id, start_date, end_date, is_present) + child = await ChildrenDao(database).get(override.child_id) + if child is None: + raise ValueError(f"child {override.child_id} does not exist") + if not child.is_active: + return 0 + return await count_removed_by_override( + database, override, today=today, horizon_days=horizon_days + ) + + async def get_presence_schedule( database: NestQuestDatabase, child_id: int ) -> PresenceSchedule | None: diff --git a/tests/test_api_admin_presence_consequence.py b/tests/test_api_admin_presence_consequence.py new file mode 100644 index 0000000..4c0ae76 --- /dev/null +++ b/tests/test_api_admin_presence_consequence.py @@ -0,0 +1,412 @@ +"""Admin override consequence-count route tests (task 7616e3eb). + +``POST /api/v1/admin/presence-overrides/consequence`` answers, BEFORE an +override is saved, how many of the child's upcoming open quest +instances saving it would remove (``{"removed": N}``) — the ADMIN-SPEC +§6 "This removes N upcoming tasks" warning. These tests exercise it +against the ASGI app with the shared stubbed-JWKS harness +(:mod:`tests.admin_jwt_harness`): + +- a two-week custody pattern (present the whole first week of the + cycle, absent the whole second) where an absent override over a + known span removes an EXACT number of instances; +- ranges that remove nothing (already-absent days, a present + override) count 0; +- an instance with a completion event inside the range is NOT counted + (D-005 — regeneration never deletes it); +- a ``skip_on_away = false`` definition contributes nothing; +- the count MATCHES a direct materialise comparison: the override is + then actually created (which regenerates the child) and the removed + open future instances are counted from the database; +- the preview writes nothing; +- the four auth outcomes, an unknown child 404 and a malformed range + 422. + +The cycle is anchored on the REAL host "today" (the create route's +regeneration reads the host clock, so the comparison test cannot pin +it): days today..today+6 are cycle week 0 (present every day), days +today+7..today+13 week 1 (absent every day), today+14 week 0 again. +""" +from __future__ import annotations + +import datetime +import importlib +from types import SimpleNamespace + +import pytest + +from tests.admin_jwt_harness import ( + ADMIN_KEY, + KID, + AdminRunner, + jwks_for, + make_token, +) + +# The DAOs and materializer through the SAME core copy the app uses +# (nestquest_core.*, NOT the conftest-stubbed custom_components copy). +_dao_instances = importlib.import_module("nestquest_core.dao_instances") +_dao_presence = importlib.import_module("nestquest_core.dao_presence") +_materialize = importlib.import_module("nestquest_core.materialize") + +CONSEQUENCE_URL = "/api/v1/admin/presence-overrides/consequence" +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" +) + +#: The two-week custody pattern: week 0 every weekday, week 1 none. +CUSTODY_PATTERN = {"0": [0, 1, 2, 3, 4, 5, 6], "1": []} + + +def _admin_headers() -> dict[str, str]: + """The Authorization header of a valid nestquest-admins JWT.""" + return {"Authorization": f"Bearer {make_token()}"} + + +def _day(offset: int) -> str: + """The ISO date ``offset`` days after the host's today.""" + return (datetime.date.today() + datetime.timedelta(days=offset)).isoformat() + + +def _body( + child_id: int, start: int, end: int, *, is_present: bool = False +) -> dict[str, object]: + """A consequence body over ``[today+start, today+end]``.""" + return { + "child_id": child_id, + "start_date": _day(start), + "end_date": _day(end), + "is_present": is_present, + } + + +async def _create_child(client: object, name: str = "Ada") -> int: + 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_daily_definition( + client: object, + child_id: int, + title: str, + windows: list[str], + *, + skip_on_away: bool = True, +) -> int: + created = await client.post( + "/api/v1/admin/quest-definitions", + json={ + "title": title, + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [child_id], + "windows": windows, + "skip_on_away": skip_on_away, + }, + headers=_admin_headers(), + ) + assert created.status_code == 201 + return created.json()["id"] + + +async def _set_custody_schedule(client: object, child_id: int) -> None: + """Anchor the two-week custody cycle on today (week 0 starts today).""" + response = await client.put( + f"/api/v1/admin/children/{child_id}/presence-schedule", + json={ + "cycle_length_weeks": 2, + "anchor_date": _day(0), + "pattern": CUSTODY_PATTERN, + }, + headers=_admin_headers(), + ) + assert response.status_code == 200 + + +async def _materialize_horizon(database: object) -> None: + """Materialize the household horizon so the stored state is current.""" + today = datetime.date.today() + await _materialize.materialize( + database, _day(0), _day(14), today=today + ) + + +async def _open_future_keys(database: object, child_id: int) -> set[tuple]: + """The child's stored open (no completion event) future instance keys.""" + instances = await _dao_instances.QuestInstancesDao( + database + ).list_by_date_range(child_id, _day(0), _day(60)) + events = _dao_instances.CompletionEventsDao(database) + keys = set() + for instance in instances: + if not await events.list_by_instance(instance.id): + keys.add( + (instance.definition_id, instance.due_date, instance.window) + ) + return keys + + +async def _complete( + database: object, child_id: int, definition_id: int, due_date: str, + window: str, +) -> None: + """Append a completion event to the stored instance.""" + instance = await _dao_instances.QuestInstancesDao(database).get( + definition_id, child_id, due_date, window + ) + assert instance is not None + await _dao_instances.CompletionEventsDao(database).append( + instance.id, + child_id, + "completed", + "user", + datetime.datetime.now(datetime.timezone.utc).isoformat( + timespec="seconds" + ), + True, + actor_user_id="admin-user-1", + ) + + +async def _consequence(client: object, body: dict[str, object]) -> int: + response = await client.post( + CONSEQUENCE_URL, json=body, headers=_admin_headers() + ) + assert response.status_code == 200, response.text + return response.json()["removed"] + + +@pytest.fixture +async def custody(temp_db_path: str) -> SimpleNamespace: + """A child on the two-week custody cycle with a two-window chore. + + Also assigns a ``skip_on_away = false`` daily chore (generated + regardless of presence) and materializes the horizon, so the stored + instances are the walk's current output. + """ + runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) + async with runner as client: + database = runner.app.state.db.database + child_id = await _create_child(client) + chore_id = await _create_daily_definition( + client, child_id, "Brush teeth", ["morning", "evening"] + ) + always_id = await _create_daily_definition( + client, child_id, "Feed the fish", ["afternoon"], + skip_on_away=False, + ) + await _set_custody_schedule(client, child_id) + await _materialize_horizon(database) + yield SimpleNamespace( + client=client, + database=database, + child_id=child_id, + chore_id=chore_id, + always_id=always_id, + ) + + +# --- the count --------------------------------------------------------------- + + +async def test_custody_override_removes_a_known_number( + custody: SimpleNamespace, +) -> None: + """Absent over today+2..today+8 removes days +2..+6 x two windows. + + Days +7 and +8 are already absent (cycle week 1), and the + skip_on_away=false chore is generated regardless of presence, so + exactly 5 present days x 2 windows = 10 instances would go. + """ + removed = await _consequence( + custody.client, _body(custody.child_id, 2, 8) + ) + assert removed == 10 + + +async def test_already_absent_range_removes_nothing( + custody: SimpleNamespace, +) -> None: + """An absent override over cycle week 1 changes nothing: 0.""" + assert await _consequence( + custody.client, _body(custody.child_id, 7, 13) + ) == 0 + + +async def test_present_override_removes_nothing( + custody: SimpleNamespace, +) -> None: + """A present override only ever adds instances: 0.""" + assert await _consequence( + custody.client, _body(custody.child_id, 2, 8, is_present=True) + ) == 0 + + +async def test_past_range_removes_nothing(custody: SimpleNamespace) -> None: + """A range entirely before today touches no upcoming instance: 0.""" + assert await _consequence( + custody.client, _body(custody.child_id, -20, -1) + ) == 0 + + +async def test_completed_instance_in_range_is_not_counted( + custody: SimpleNamespace, +) -> None: + """D-005: an instance with a completion event is never removed.""" + await _complete( + custody.database, custody.child_id, custody.chore_id, _day(3), + "morning", + ) + removed = await _consequence( + custody.client, _body(custody.child_id, 2, 8) + ) + assert removed == 9 + + +async def test_skip_on_away_false_definition_contributes_nothing( + temp_db_path: str, +) -> None: + """A child whose only chore ignores presence: absent removes 0.""" + runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) + async with runner as client: + database = runner.app.state.db.database + child_id = await _create_child(client) + await _create_daily_definition( + client, child_id, "Feed the fish", ["morning"], + skip_on_away=False, + ) + await _set_custody_schedule(client, child_id) + await _materialize_horizon(database) + assert await _consequence(client, _body(child_id, 0, 14)) == 0 + + +async def test_count_matches_a_direct_materialise_comparison( + custody: SimpleNamespace, +) -> None: + """Saving the override removes EXACTLY the previewed instances. + + With a completed instance inside the range, the preview is taken, + then the override is really created (its core path regenerates the + child) and the open future instances that disappeared are counted + from the database. The completed instance survives the regeneration. + """ + client, database = custody.client, custody.database + await _complete( + database, custody.child_id, custody.chore_id, _day(4), "evening" + ) + body = _body(custody.child_id, 1, 9) + removed = await _consequence(client, body) + + before = await _open_future_keys(database, custody.child_id) + created = await client.post( + OVERRIDES_URL, json=body, headers=_admin_headers() + ) + assert created.status_code == 201 + after = await _open_future_keys(database, custody.child_id) + + assert after <= before + assert len(before - after) == removed + # Days +1..+6 present x two windows, minus the completed one. + assert removed == 11 + completed = await _dao_instances.QuestInstancesDao(database).get( + custody.chore_id, custody.child_id, _day(4), "evening" + ) + assert completed is not None + + +async def test_preview_writes_nothing(custody: SimpleNamespace) -> None: + """No override is stored and no instance is removed by the preview.""" + client, database = custody.client, custody.database + before = await _open_future_keys(database, custody.child_id) + assert await _consequence(client, _body(custody.child_id, 0, 14)) > 0 + assert await _open_future_keys(database, custody.child_id) == before + overrides = await _dao_presence.PresenceOverridesDao( + database + ).list_by_child_and_range(custody.child_id, "2000-01-01", "2100-01-01") + assert overrides == [] + + +# --- errors ------------------------------------------------------------------ + + +async def test_unknown_child_is_404(custody: SimpleNamespace) -> None: + response = await custody.client.post( + CONSEQUENCE_URL, json=_body(9999, 1, 2), headers=_admin_headers() + ) + assert response.status_code == 404 + + +@pytest.mark.parametrize( + "overrides", + [ + {"start_date": "2026-02-30"}, + {"end_date": "2026-1-5"}, + {"start_date": "2026-03-10", "end_date": "2026-03-01"}, + {"is_present": "sometimes"}, + {"child_id": "one"}, + ], +) +async def test_malformed_range_or_status_is_422( + custody: SimpleNamespace, overrides: dict[str, object] +) -> None: + body = {**_body(custody.child_id, 1, 2), **overrides} + response = await custody.client.post( + CONSEQUENCE_URL, json=body, headers=_admin_headers() + ) + assert response.status_code == 422 + + +# --- authentication: the router's ONE require_admin -------------------------- + + +async def test_no_credential_is_401(custody: SimpleNamespace) -> None: + response = await custody.client.post( + CONSEQUENCE_URL, json=_body(custody.child_id, 1, 2) + ) + assert response.status_code == 401 + + +async def test_panel_service_token_is_403(custody: SimpleNamespace) -> None: + response = await custody.client.post( + CONSEQUENCE_URL, + json=_body(custody.child_id, 1, 2), + headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL + + +async def test_non_admin_jwt_is_403(custody: SimpleNamespace) -> None: + non_admin = make_token(groups=("some-other-group",)) + response = await custody.client.post( + CONSEQUENCE_URL, + json=_body(custody.child_id, 1, 2), + headers={"Authorization": f"Bearer {non_admin}"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == FORBIDDEN_DETAIL + + +async def test_admin_jwt_is_200(custody: SimpleNamespace) -> None: + response = await custody.client.post( + CONSEQUENCE_URL, + json=_body(custody.child_id, 1, 2), + headers=_admin_headers(), + ) + assert response.status_code == 200 + assert response.json() == {"removed": 4}