Task 7616e3eb: Admin override consequence count (#201)

This commit was merged in pull request #201.
This commit is contained in:
2026-09-23 22:04:43 -04:00
parent a6b3153dbd
commit 685f0f214c
4 changed files with 744 additions and 36 deletions
+74
View File
@@ -58,6 +58,13 @@ 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"}``.
- ``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 - ``GET /children/{child_id}/presence-schedule`` reads the child's
schedule through ONE ``get_presence_schedule`` call, wrapped as schedule through ONE ``get_presence_schedule`` call, wrapped as
``{"schedule": ...}`` in the PUT response's shape — ``null`` when the ``{"schedule": ...}`` in the PUT response's shape — ``null`` when the
@@ -666,6 +673,34 @@ class AdminPresenceOverrideListResponse(BaseModel):
overrides: list[AdminPresenceOverrideResponse] 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.
Strict fields: a JSON ``true`` is not child id 1, nor ``1`` a bool.
"""
child_id: StrictInt
start_date: str
end_date: str
is_present: StrictBool
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): class AdminUncompleteResponse(BaseModel):
"""The result of ``POST /api/v1/admin/instances/{id}/uncomplete``. """The result of ``POST /api/v1/admin/instances/{id}/uncomplete``.
@@ -1608,6 +1643,45 @@ async def admin_delete_presence_override(
return {"status": "ok"} 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( @router.get(
"/children/{child_id}/presence-schedule", "/children/{child_id}/presence-schedule",
summary="Read a child's repeating presence schedule", summary="Read a child's repeating presence schedule",
+184 -35
View File
@@ -64,9 +64,14 @@ the presence DAO (``dao_presence.py``) must stay free of business rules
from __future__ import annotations from __future__ import annotations
import datetime import datetime
from typing import NamedTuple
from .const import DEFAULT_HORIZON_DAYS 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 ( from .dao_rules import (
_validate_date, _validate_date,
load_materialization_input, load_materialization_input,
@@ -126,8 +131,15 @@ def _decode_rule(snapshot) -> ScheduleRule:
def _build_engine( def _build_engine(
schedules_records, schedules_records,
overrides_records, overrides_records,
*,
extra_overrides: tuple[PresenceOverride, ...] = (),
) -> PresenceEngine: ) -> 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] = {} schedules: dict[int, PresenceSchedule] = {}
for child_id, record in schedules_records.items(): for child_id, record in schedules_records.items():
schedules[child_id] = PresenceSchedule.decode( schedules[child_id] = PresenceSchedule.decode(
@@ -145,9 +157,75 @@ def _build_engine(
) )
for record in records for record in records
] ]
for override in extra_overrides:
overrides.setdefault(override.child_id, []).append(override)
return PresenceEngine(schedules, overrides) 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( async def materialize(
database: NestQuestDatabase, database: NestQuestDatabase,
start_date: str, start_date: str,
@@ -207,13 +285,6 @@ async def materialize(
engine = _build_engine(schedules_records, overrides_records) engine = _build_engine(schedules_records, overrides_records)
child_filter = None if child_ids is None else set(child_ids) 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() generated_at = _now_stamp()
instances = QuestInstancesDao(database) instances = QuestInstancesDao(database)
@@ -228,32 +299,20 @@ async def materialize(
start = max(start, today_date) start = max(start, today_date)
count = 0 count = 0
cursor = start for generated in _generation_preview(
while cursor <= end: snapshots, engine, start, end, child_filter=child_filter
iso = cursor.isoformat() ):
for ( written = await instances.upsert_if_valid(
definition_id, rule, assignees, windows, skip_on_away generated.definition_id,
) in definitions: generated.child_id,
if not occurs_on(rule, cursor): generated.due_date,
continue generated_at,
for child in assignees: window=generated.window,
if child_filter is not None and child.id not in child_filter: due_time=generated.due_time,
continue today=today,
if skip_on_away and not engine.is_present(child.id, cursor): )
continue if written is not None:
for window in windows: count += 1
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)
return count return count
@@ -379,3 +438,93 @@ async def regenerate_for_child(
return await materialize( return await materialize(
database, start.isoformat(), end_date, child_ids=[child_id], today=today 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
)
@@ -31,13 +31,14 @@ from __future__ import annotations
import datetime import datetime
from .dao_children import ChildrenDao
from .dao_presence import ( from .dao_presence import (
PresenceOverrideRecord, PresenceOverrideRecord,
PresenceOverridesDao, PresenceOverridesDao,
PresenceSchedulesDao, PresenceSchedulesDao,
) )
from .db import NestQuestDatabase 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 from .presence import PresenceOverride, PresenceSchedule
@@ -134,6 +135,42 @@ async def create_presence_override(
return record 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( async def get_presence_schedule(
database: NestQuestDatabase, child_id: int database: NestQuestDatabase, child_id: int
) -> PresenceSchedule | None: ) -> PresenceSchedule | None:
@@ -0,0 +1,448 @@
"""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
@pytest.mark.parametrize(
"overrides",
[
{"child_id": True},
{"child_id": False},
{"child_id": "1"},
{"child_id": 1.0},
{"is_present": 1},
{"is_present": 0},
{"is_present": "true"},
{"is_present": "false"},
],
)
async def test_coercible_child_id_or_status_is_422(
custody: SimpleNamespace, overrides: dict[str, object]
) -> None:
"""Strict fields: a bool id or a numeric/string status is rejected.
The fixture child's id is 1, so a coerced ``true`` would otherwise
target it and return 200. Nothing is stored and nothing removed.
"""
assert custody.child_id == 1
database = custody.database
before = await _open_future_keys(database, custody.child_id)
body = {**_body(custody.child_id, 0, 14), **overrides}
response = await custody.client.post(
CONSEQUENCE_URL, json=body, headers=_admin_headers()
)
assert response.status_code == 422
assert await _open_future_keys(database, custody.child_id) == before
overrides_stored = await _dao_presence.PresenceOverridesDao(
database
).list_by_child_and_range(custody.child_id, "2000-01-01", "2100-01-01")
assert overrides_stored == []
# --- 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}