572 lines
20 KiB
Python
572 lines
20 KiB
Python
"""Admin-plane presence route tests (task e4a9d31b).
|
|
|
|
These tests exercise the admin plane's presence 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 and the DOMAIN,
|
|
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 nothing is written.
|
|
- ``PUT /api/v1/admin/children/{id}/presence-schedule`` UPSERTS: the
|
|
first set persists the schedule, setting again REPLACES it — one
|
|
schedule row per child, never a duplicate.
|
|
- ``POST /api/v1/admin/presence-overrides`` creates the override (its
|
|
``id`` comes back) and ``DELETE /api/v1/admin/presence-overrides/{id}``
|
|
removes it again.
|
|
- The child's RESOLVED presence changes accordingly: the pure
|
|
:class:`~nestquest_core.presence.PresenceEngine` built from the
|
|
stored rows answers differently before/after each write — a schedule
|
|
restricts the child's present weekdays, an override beats the pattern
|
|
for its range, and deleting the override restores the pattern's
|
|
answer.
|
|
- The regeneration side effect lives in the core layer the route
|
|
calls: setting an all-absent schedule through the route deletes the
|
|
child's future open quest instances and they are NOT rebuilt while
|
|
the child is absent every day — the same proof the HA service test
|
|
makes, through the API instead.
|
|
|
|
Error mapping (documented in api/routes_admin.py): a core ValueError
|
|
for a non-existent child (schedule or override create) or override
|
|
(delete) is 404; every other core ValueError — a rejected schedule
|
|
(cycle length, pattern coverage, anchor date) or override (malformed or
|
|
inverted dates, an overlapping range) — is 422, the same failure class
|
|
FastAPI reports for malformed bodies.
|
|
"""
|
|
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 presence model, DAOs, business layer and materializer through the
|
|
# SAME core copy the app uses (nestquest_core.*, NOT the conftest-stubbed
|
|
# custom_components copy) — used for the tests' database and resolved-
|
|
# presence assertions only; the routes reach them through api.nestquest_core.
|
|
_children = importlib.import_module("nestquest_core.children")
|
|
_dao_presence = importlib.import_module("nestquest_core.dao_presence")
|
|
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
|
_materialize = importlib.import_module("nestquest_core.materialize")
|
|
_presence = importlib.import_module("nestquest_core.presence")
|
|
|
|
#: A Monday anchoring every schedule: cycle week 0 starts here.
|
|
ANCHOR_MONDAY = "2026-01-05"
|
|
#: The Tuesday of that week — absent under the test schedule.
|
|
TUESDAY = "2026-01-06"
|
|
|
|
|
|
def _admin_headers() -> dict[str, str]:
|
|
"""The Authorization header of a valid nestquest-admins JWT."""
|
|
return {"Authorization": f"Bearer {make_token()}"}
|
|
|
|
|
|
def _schedule_body(
|
|
cycle_length_weeks: int = 1, pattern: dict[int, list[int]] | None = None
|
|
) -> dict[str, object]:
|
|
"""A valid schedule body anchored to the test Monday."""
|
|
return {
|
|
"cycle_length_weeks": cycle_length_weeks,
|
|
"anchor_date": ANCHOR_MONDAY,
|
|
"pattern": {"0": [0, 2, 4]} if pattern is None else pattern,
|
|
}
|
|
|
|
|
|
def _override_body(
|
|
child_id: int,
|
|
*,
|
|
start_date: str = TUESDAY,
|
|
end_date: str = TUESDAY,
|
|
is_present: bool = True,
|
|
) -> dict[str, object]:
|
|
"""A valid single-day override body for the test child."""
|
|
return {
|
|
"child_id": child_id,
|
|
"start_date": start_date,
|
|
"end_date": end_date,
|
|
"is_present": is_present,
|
|
"note": "grandmother's week",
|
|
}
|
|
|
|
|
|
async def _create_child(client: object, name: str = "Ada") -> 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"]
|
|
|
|
|
|
def _engine(
|
|
database: object,
|
|
child_id: int,
|
|
schedule_record: object | None,
|
|
override_records: list[object],
|
|
) -> object:
|
|
"""Build the pure PresenceEngine from stored rows (the snapshot shape).
|
|
|
|
Mirrors what the core snapshot builder assembles: the child's
|
|
decoded schedule (absent → present every day) plus its overrides,
|
|
so :meth:`PresenceEngine.is_present` answers from the SAME rows the
|
|
routes produced.
|
|
"""
|
|
schedules: dict[int, object] = {}
|
|
if schedule_record is not None:
|
|
schedules[child_id] = _presence.PresenceSchedule.decode(
|
|
schedule_record.child_id,
|
|
schedule_record.anchor_date,
|
|
schedule_record.pattern,
|
|
)
|
|
overrides = {
|
|
child_id: [
|
|
_presence.PresenceOverride(
|
|
record.child_id,
|
|
record.start_date,
|
|
record.end_date,
|
|
record.is_present,
|
|
note=record.note,
|
|
)
|
|
for record in override_records
|
|
]
|
|
}
|
|
return _presence.PresenceEngine(schedules, overrides)
|
|
|
|
|
|
async def _child_state(
|
|
database: object, child_id: int
|
|
) -> tuple[object | None, list[object], object]:
|
|
"""The child's stored schedule record, overrides and PresenceEngine."""
|
|
schedule_record = await _dao_presence.PresenceSchedulesDao(
|
|
database
|
|
).get_by_child(child_id)
|
|
override_records = await _dao_presence.PresenceOverridesDao(
|
|
database
|
|
).list_by_child_and_range(child_id, "2020-01-01", "2030-01-01")
|
|
engine = _engine(database, child_id, schedule_record, override_records)
|
|
return schedule_record, override_records, engine
|
|
|
|
|
|
@pytest.fixture
|
|
async def admin_presence(temp_db_path: str) -> SimpleNamespace:
|
|
"""An admin-plane app client plus its live database.
|
|
|
|
The same runner the auth, children and quest-definition 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 and the resolved presence built from them.
|
|
"""
|
|
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_presence_routes_refuse_non_admin_and_absent_credentials(
|
|
admin_presence: SimpleNamespace,
|
|
) -> None:
|
|
"""A non-admin JWT is 403, an absent credential is 401: nothing written."""
|
|
client = admin_presence.client
|
|
child_id = await _create_child(client)
|
|
non_admin = make_token(groups=("some-other-group",))
|
|
non_admin_headers = {"Authorization": f"Bearer {non_admin}"}
|
|
|
|
forbidden_schedule = await client.put(
|
|
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
|
json=_schedule_body(),
|
|
headers=non_admin_headers,
|
|
)
|
|
assert forbidden_schedule.status_code == 403
|
|
assert forbidden_schedule.json()["detail"] == (
|
|
"Admin access requires membership in the nestquest-admins group"
|
|
)
|
|
|
|
forbidden_override = await client.post(
|
|
"/api/v1/admin/presence-overrides",
|
|
json=_override_body(child_id),
|
|
headers=non_admin_headers,
|
|
)
|
|
assert forbidden_override.status_code == 403
|
|
|
|
forbidden_delete = await client.delete(
|
|
"/api/v1/admin/presence-overrides/1", headers=non_admin_headers
|
|
)
|
|
assert forbidden_delete.status_code == 403
|
|
|
|
unauthenticated_schedule = await client.put(
|
|
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
|
json=_schedule_body(),
|
|
)
|
|
assert unauthenticated_schedule.status_code == 401
|
|
|
|
unauthenticated_override = await client.post(
|
|
"/api/v1/admin/presence-overrides", json=_override_body(child_id)
|
|
)
|
|
assert unauthenticated_override.status_code == 401
|
|
|
|
unauthenticated_delete = await client.delete(
|
|
"/api/v1/admin/presence-overrides/1"
|
|
)
|
|
assert unauthenticated_delete.status_code == 401
|
|
|
|
# No route ran its handler: the child still has no schedule and no
|
|
# overrides (and is present every day, the no-schedule default).
|
|
_schedule, _overrides, engine = await _child_state(
|
|
admin_presence.database, child_id
|
|
)
|
|
assert _schedule is None
|
|
assert _overrides == []
|
|
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
|
|
|
|
|
|
# --- schedule upsert --------------------------------------------------------
|
|
|
|
|
|
async def test_schedule_upserts_and_replaces_one_row_per_child(
|
|
admin_presence: SimpleNamespace,
|
|
) -> None:
|
|
"""PUT /presence-schedule persists, and setting again REPLACES it."""
|
|
client = admin_presence.client
|
|
child_id = await _create_child(client)
|
|
|
|
first = await client.put(
|
|
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
|
json=_schedule_body(),
|
|
headers=_admin_headers(),
|
|
)
|
|
assert first.status_code == 200
|
|
assert first.json() == {
|
|
"child_id": child_id,
|
|
"cycle_length_weeks": 1,
|
|
"anchor_date": ANCHOR_MONDAY,
|
|
"pattern": {"0": [0, 2, 4]},
|
|
}
|
|
# Database proof: exactly ONE schedule row (the schema's
|
|
# UNIQUE(child_id) makes a second row unrepresentable), stored as
|
|
# encoded CSV.
|
|
schedule, _overrides, _engine = await _child_state(
|
|
admin_presence.database, child_id
|
|
)
|
|
assert schedule is not None
|
|
row_id = schedule.id
|
|
assert schedule.cycle_length_weeks == 1
|
|
assert schedule.anchor_date == ANCHOR_MONDAY
|
|
assert schedule.pattern == "0,2,4"
|
|
|
|
# Setting again UPSERTS: the SAME row is updated in place — new
|
|
# values, unchanged id — never a duplicate or a stale first set.
|
|
second = await client.put(
|
|
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
|
json=_schedule_body(
|
|
cycle_length_weeks=2, pattern={"0": [], "1": [1, 3]}
|
|
),
|
|
headers=_admin_headers(),
|
|
)
|
|
assert second.status_code == 200
|
|
assert second.json() == {
|
|
"child_id": child_id,
|
|
"cycle_length_weeks": 2,
|
|
"anchor_date": ANCHOR_MONDAY,
|
|
"pattern": {"0": [], "1": [1, 3]},
|
|
}
|
|
schedule, _overrides, _engine = await _child_state(
|
|
admin_presence.database, child_id
|
|
)
|
|
assert schedule is not None
|
|
assert schedule.id == row_id
|
|
assert schedule.cycle_length_weeks == 2
|
|
assert schedule.pattern == "|1,3"
|
|
|
|
|
|
async def test_schedule_changes_the_childs_resolved_presence(
|
|
admin_presence: SimpleNamespace,
|
|
) -> None:
|
|
"""The resolved presence (PresenceEngine) follows the stored schedule."""
|
|
client = admin_presence.client
|
|
child_id = await _create_child(client)
|
|
|
|
# Before any schedule the child is present every day.
|
|
_schedule, _overrides, engine = await _child_state(
|
|
admin_presence.database, child_id
|
|
)
|
|
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
|
|
assert engine.is_present(child_id, TUESDAY) is True
|
|
|
|
# Mon/Wed/Fri only, week 0 of a one-week cycle.
|
|
response = await client.put(
|
|
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
|
json=_schedule_body(),
|
|
headers=_admin_headers(),
|
|
)
|
|
assert response.status_code == 200
|
|
_schedule, _overrides, engine = await _child_state(
|
|
admin_presence.database, child_id
|
|
)
|
|
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
|
|
assert engine.is_present(child_id, TUESDAY) is False
|
|
|
|
|
|
async def test_schedule_upsert_regenerates_future_instances(
|
|
admin_presence: SimpleNamespace,
|
|
) -> None:
|
|
"""Setting an all-absent schedule deletes the child's future instances.
|
|
|
|
The regeneration side effect lives in the core layer the route
|
|
calls: the child's open instances at or after today are deleted
|
|
across ALL its definitions and NOT rebuilt while the child is
|
|
absent every day — the same proof the HA service test makes,
|
|
exercised here through the API route instead.
|
|
"""
|
|
client = admin_presence.client
|
|
database = admin_presence.database
|
|
child_id = await _create_child(client)
|
|
created = await client.post(
|
|
"/api/v1/admin/quest-definitions",
|
|
json={
|
|
"title": "Brush teeth",
|
|
"rule": {"rule_type": "daily"},
|
|
"assignee_child_ids": [child_id],
|
|
"windows": ["morning"],
|
|
},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert created.status_code == 201
|
|
|
|
today = datetime.date.today()
|
|
start = today.isoformat()
|
|
end = (today + datetime.timedelta(days=14)).isoformat()
|
|
await _materialize.materialize(database, start, end, today=today)
|
|
instances = _dao_instances.QuestInstancesDao(database)
|
|
assert await instances.list_by_date_range(child_id, start, end)
|
|
|
|
# The route's write must regenerate: absent every day removes every
|
|
# open future instance.
|
|
response = await client.put(
|
|
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
|
json=_schedule_body(pattern={"0": []}),
|
|
headers=_admin_headers(),
|
|
)
|
|
assert response.status_code == 200
|
|
assert await instances.list_by_date_range(child_id, start, end) == []
|
|
|
|
|
|
# --- override create + delete -----------------------------------------------
|
|
|
|
|
|
async def test_override_is_created_then_deleted_and_presence_resolves(
|
|
admin_presence: SimpleNamespace,
|
|
) -> None:
|
|
"""POST creates the override, DELETE removes it; presence follows."""
|
|
client = admin_presence.client
|
|
database = admin_presence.database
|
|
child_id = await _create_child(client)
|
|
schedule_response = await client.put(
|
|
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
|
json=_schedule_body(),
|
|
headers=_admin_headers(),
|
|
)
|
|
assert schedule_response.status_code == 200
|
|
|
|
# Create: the override comes back with its id and the posted shape.
|
|
created = await client.post(
|
|
"/api/v1/admin/presence-overrides",
|
|
json=_override_body(child_id),
|
|
headers=_admin_headers(),
|
|
)
|
|
assert created.status_code == 201
|
|
payload = created.json()
|
|
assert set(payload) == {
|
|
"id",
|
|
"child_id",
|
|
"start_date",
|
|
"end_date",
|
|
"is_present",
|
|
"note",
|
|
}
|
|
assert payload["child_id"] == child_id
|
|
assert payload["start_date"] == TUESDAY
|
|
assert payload["end_date"] == TUESDAY
|
|
assert payload["is_present"] is True
|
|
assert payload["note"] == "grandmother's week"
|
|
|
|
# Database proof: the row exists — and the RESOLVED presence for the
|
|
# affected date flips: the override beats the Tuesday-absent pattern.
|
|
_schedule, overrides, engine = await _child_state(database, child_id)
|
|
assert [record.id for record in overrides] == [payload["id"]]
|
|
assert engine.is_present(child_id, TUESDAY) is True
|
|
# A date the override does not cover keeps the pattern's answer.
|
|
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
|
|
|
|
# Delete: the row is gone and the resolved presence follows the
|
|
# pattern again.
|
|
deleted = await client.delete(
|
|
f"/api/v1/admin/presence-overrides/{payload['id']}",
|
|
headers=_admin_headers(),
|
|
)
|
|
assert deleted.status_code == 200
|
|
assert deleted.json() == {"status": "ok"}
|
|
assert (
|
|
await _dao_presence.PresenceOverridesDao(database).get(payload["id"])
|
|
is None
|
|
)
|
|
_schedule, overrides, engine = await _child_state(database, child_id)
|
|
assert overrides == []
|
|
assert engine.is_present(child_id, TUESDAY) is False
|
|
|
|
|
|
# --- error mapping: 404 vs 422 ----------------------------------------------
|
|
|
|
|
|
async def test_schedule_and_override_for_unknown_child_are_404(
|
|
admin_presence: SimpleNamespace,
|
|
) -> None:
|
|
"""A presence write naming a child that does not exist is 404."""
|
|
client = admin_presence.client
|
|
|
|
schedule = await client.put(
|
|
"/api/v1/admin/children/999/presence-schedule",
|
|
json=_schedule_body(),
|
|
headers=_admin_headers(),
|
|
)
|
|
assert schedule.status_code == 404
|
|
assert schedule.json()["detail"] == "Child not found"
|
|
|
|
override = await client.post(
|
|
"/api/v1/admin/presence-overrides",
|
|
json=_override_body(999),
|
|
headers=_admin_headers(),
|
|
)
|
|
assert override.status_code == 404
|
|
assert override.json()["detail"] == "Child not found"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"body",
|
|
[
|
|
_schedule_body(cycle_length_weeks=0), # below the 1..4 range
|
|
_schedule_body(cycle_length_weeks=5), # above the documented cap
|
|
# week 1 of the cycle missing:
|
|
_schedule_body(cycle_length_weeks=2, pattern={"0": [0]}),
|
|
{
|
|
"cycle_length_weeks": 1,
|
|
"anchor_date": "2026-13-40",
|
|
"pattern": {"0": [0]},
|
|
},
|
|
_schedule_body(pattern={"0": [7]}), # weekday out of 0..6
|
|
_schedule_body(pattern={"nope": [0]}), # week index not an integer
|
|
_schedule_body(pattern={"0": "0,2,4"}), # weekdays not a list
|
|
],
|
|
)
|
|
async def test_rejected_schedule_is_422(
|
|
admin_presence: SimpleNamespace, body: dict[str, object]
|
|
) -> None:
|
|
"""A rejected schedule is 422 and stores nothing."""
|
|
client = admin_presence.client
|
|
child_id = await _create_child(client)
|
|
response = await client.put(
|
|
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
|
json=body,
|
|
headers=_admin_headers(),
|
|
)
|
|
assert response.status_code == 422
|
|
assert (
|
|
await _dao_presence.PresenceSchedulesDao(
|
|
admin_presence.database
|
|
).get_by_child(child_id)
|
|
is None
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"body",
|
|
[
|
|
_override_body(1, start_date="2026-01-07", end_date="2026-01-06"),
|
|
_override_body(1, start_date="2026-02-30", end_date="2026-02-30"),
|
|
dict(_override_body(1), note=5),
|
|
dict(_override_body(1), child_id="one"),
|
|
],
|
|
)
|
|
async def test_rejected_override_is_422(
|
|
admin_presence: SimpleNamespace, body: dict[str, object]
|
|
) -> None:
|
|
"""A rejected override (inverted/malformed dates, bad note/id) is 422.
|
|
|
|
The shape failures (a non-string note, a non-integer child id) are
|
|
the body model's; the date failures are the PresenceOverride
|
|
model's. A string ``is_present`` like ``"yes"`` is deliberately
|
|
absent: Pydantic's lax bool COERCES it to ``True`` (the same design
|
|
as the children active route), so it never reaches the model's
|
|
real-bool check.
|
|
"""
|
|
response = await admin_presence.client.post(
|
|
"/api/v1/admin/presence-overrides", json=body, headers=_admin_headers()
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
async def test_overlapping_override_is_422_and_first_survives(
|
|
admin_presence: SimpleNamespace,
|
|
) -> None:
|
|
"""An override overlapping the child's existing one is 422.
|
|
|
|
The conflicting write persists nothing: the child's original
|
|
override survives unchanged.
|
|
"""
|
|
client = admin_presence.client
|
|
child_id = await _create_child(client)
|
|
created = await client.post(
|
|
"/api/v1/admin/presence-overrides",
|
|
json=_override_body(child_id),
|
|
headers=_admin_headers(),
|
|
)
|
|
assert created.status_code == 201
|
|
|
|
response = await client.post(
|
|
"/api/v1/admin/presence-overrides",
|
|
json=_override_body(child_id, is_present=False),
|
|
headers=_admin_headers(),
|
|
)
|
|
assert response.status_code == 422
|
|
_schedule, overrides, _engine = await _child_state(
|
|
admin_presence.database, child_id
|
|
)
|
|
assert [record.id for record in overrides] == [created.json()["id"]]
|
|
|
|
|
|
async def test_delete_unknown_override_is_404(
|
|
admin_presence: SimpleNamespace,
|
|
) -> None:
|
|
"""Deleting an override id that does not exist is 404."""
|
|
response = await admin_presence.client.delete(
|
|
"/api/v1/admin/presence-overrides/999", headers=_admin_headers()
|
|
)
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"] == "Presence override not found"
|
|
|
|
malformed = await admin_presence.client.delete(
|
|
"/api/v1/admin/presence-overrides/not-an-id",
|
|
headers=_admin_headers(),
|
|
)
|
|
assert malformed.status_code == 422
|