Feature 23: Admin API Completion - preview, presence, live (#199-#202) (#203)

Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
This commit was merged in pull request #203.
This commit is contained in:
2026-09-23 22:16:47 -04:00
committed by approver_agent
parent 4b39af4b67
commit b8532bacde
11 changed files with 2226 additions and 71 deletions
+431
View File
@@ -0,0 +1,431 @@
"""Admin-plane occurrence-preview route tests (task 5f843564).
These tests exercise ``POST /api/v1/admin/quest-definitions/occurrences-preview``
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 with NO network access.
- Authentication runs through the router's ONE ``require_admin``
dependency: no credential is 401, the panel service token is 403
(refused as a credential type), a JWT without the admin group is
403, an admin JWT is 200.
- The returned dates are asserted EXACTLY for a weekly rule with an
interval and a start_date, a daily rule with an interval, a rule
with an end_date (the list stops at it), a month-end-clamped monthly
rule, and the default start (the pinned host clock's today); one
case also asserts parity with the core's own ``occurrences_between``
over the documented window.
- A rejected rule (the same core ``from_dict`` validation create/edit
use), a non-strict ``start_date`` and a ``count`` outside 1..50 are
422.
"""
from __future__ import annotations
import datetime
import importlib
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import pytest
from api import routes_admin
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
# The recurrence engine through the SAME core copy the app uses
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy).
_recurrence = importlib.import_module("nestquest_core.recurrence")
PREVIEW_URL = "/api/v1/admin/quest-definitions/occurrences-preview"
#: 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 pinned host instant: Wednesday 2026-09-23, noon UTC.
PINNED_NOW = datetime.datetime(2026, 9, 23, 12, 0, 0, tzinfo=ZoneInfo("UTC"))
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
def _weekly_rule() -> dict[str, object]:
"""Every OTHER week's Tuesday and Friday, anchored on Tue 2026-03-03."""
return {
"rule_type": "weekly",
"interval": 2,
"weekday_set": [1, 4],
"start_date": "2026-03-03",
}
@pytest.fixture
async def preview(temp_db_path: str, monkeypatch) -> SimpleNamespace:
"""An admin-plane app client with the host clock pinned (and counted)."""
clock_reads: list[datetime.datetime] = []
def _admin_now() -> datetime.datetime:
clock_reads.append(PINNED_NOW)
return PINNED_NOW
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(client=client, clock_reads=clock_reads)
async def _post(client: object, body: dict[str, object]) -> object:
"""POST a preview body with an admin JWT."""
return await client.post(PREVIEW_URL, json=body, headers=_admin_headers())
# --- authentication: the ONE require_admin dependency ----------------------
async def test_preview_without_credential_is_401(
preview: SimpleNamespace,
) -> None:
"""No Authorization header is the uniform 401."""
response = await preview.client.post(
PREVIEW_URL, json={"rule": _weekly_rule()}
)
assert response.status_code == 401
async def test_preview_refuses_panel_service_token(
preview: SimpleNamespace,
) -> None:
"""The panel service token is refused as a credential type (403)."""
response = await preview.client.post(
PREVIEW_URL,
json={"rule": _weekly_rule()},
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
)
assert response.status_code == 403
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
async def test_preview_refuses_non_admin_jwt(
preview: SimpleNamespace,
) -> None:
"""A valid JWT without the nestquest-admins group is 403."""
non_admin = make_token(groups=("some-other-group",))
response = await preview.client.post(
PREVIEW_URL,
json={"rule": _weekly_rule()},
headers={"Authorization": f"Bearer {non_admin}"},
)
assert response.status_code == 403
assert response.json()["detail"] == FORBIDDEN_DETAIL
async def test_preview_admin_jwt_is_200(preview: SimpleNamespace) -> None:
"""An admin JWT is 200 with the documented ``dates`` shape."""
response = await _post(
preview.client, {"rule": _weekly_rule(), "start_date": "2026-03-01"}
)
assert response.status_code == 200
assert set(response.json()) == {"dates"}
# --- the returned dates ----------------------------------------------------
async def test_weekly_interval_rule_from_before_its_start(
preview: SimpleNamespace,
) -> None:
"""Every other week's Tue/Fri, starting at the rule's own start_date.
The preview starts on Sun 2026-03-01, before the rule's anchor, so
the first date is the anchor itself; the off weeks (Mar 10-16,
Mar 24-30) are skipped.
"""
response = await _post(
preview.client,
{"rule": _weekly_rule(), "start_date": "2026-03-01", "count": 6},
)
assert response.status_code == 200
assert response.json()["dates"] == [
"2026-03-03",
"2026-03-06",
"2026-03-17",
"2026-03-20",
"2026-03-31",
"2026-04-03",
]
async def test_weekly_interval_rule_from_mid_cadence(
preview: SimpleNamespace,
) -> None:
"""A start inside an on-week keeps the rule's anchor cadence."""
response = await _post(
preview.client,
{"rule": _weekly_rule(), "start_date": "2026-03-18", "count": 4},
)
assert response.status_code == 200
assert response.json()["dates"] == [
"2026-03-20",
"2026-03-31",
"2026-04-03",
"2026-04-14",
]
async def test_daily_interval_rule(preview: SimpleNamespace) -> None:
"""Every third day from 2026-01-01, previewed from 2026-01-05."""
response = await _post(
preview.client,
{
"rule": {
"rule_type": "daily",
"interval": 3,
"start_date": "2026-01-01",
},
"start_date": "2026-01-05",
"count": 4,
},
)
assert response.status_code == 200
assert response.json()["dates"] == [
"2026-01-07",
"2026-01-10",
"2026-01-13",
"2026-01-16",
]
async def test_rule_with_end_date_stops_at_it(
preview: SimpleNamespace,
) -> None:
"""A daily rule ending 2026-05-04 yields only its four dates."""
response = await _post(
preview.client,
{
"rule": {
"rule_type": "daily",
"start_date": "2026-05-01",
"end_date": "2026-05-04",
},
"start_date": "2026-04-28",
"count": 10,
},
)
assert response.status_code == 200
assert response.json()["dates"] == [
"2026-05-01",
"2026-05-02",
"2026-05-03",
"2026-05-04",
]
async def test_monthly_day_rule_clamps_to_month_end(
preview: SimpleNamespace,
) -> None:
"""The core's month-end policy: the 31st fires on Feb 28th."""
response = await _post(
preview.client,
{
"rule": {
"rule_type": "monthly_day",
"day_of_month": 31,
"start_date": "2026-01-31",
},
"start_date": "2026-01-01",
"count": 3,
},
)
assert response.status_code == 200
assert response.json()["dates"] == [
"2026-01-31",
"2026-02-28",
"2026-03-31",
]
async def test_default_start_is_host_today_with_default_count(
preview: SimpleNamespace,
) -> None:
"""No start_date: ONE clock read's date; no count: ten dates."""
response = await _post(preview.client, {"rule": {"rule_type": "daily"}})
assert response.status_code == 200
expected = [
(datetime.date(2026, 9, 23) + datetime.timedelta(days=n)).isoformat()
for n in range(10)
]
assert response.json()["dates"] == expected
assert preview.clock_reads == [PINNED_NOW]
async def test_supplied_start_date_reads_no_clock(
preview: SimpleNamespace,
) -> None:
"""A supplied start_date is used as-is: the host clock is not read."""
response = await _post(
preview.client, {"rule": _weekly_rule(), "start_date": "2026-03-01"}
)
assert response.status_code == 200
assert preview.clock_reads == []
async def test_dates_match_core_occurrences_between_over_window(
preview: SimpleNamespace,
) -> None:
"""The route's dates ARE the core's walk over the 366-day window."""
rule_body = {
"rule_type": "monthly_weekday",
"nth_weekday": -1,
"nth_weekday_weekday": 4,
"start_date": "2026-01-01",
}
response = await _post(
preview.client,
{"rule": rule_body, "start_date": "2026-02-10", "count": 50},
)
assert response.status_code == 200
core_dates = _recurrence.occurrences_between(
_recurrence.ScheduleRule.from_dict(rule_body),
"2026-02-10",
"2027-02-11",
)
assert response.json()["dates"] == core_dates
assert len(core_dates) == 12
async def test_yearly_rule_shows_next_occurrence_within_window(
preview: SimpleNamespace,
) -> None:
"""The 366-day window always reaches a yearly rule's next date."""
response = await _post(
preview.client,
{
"rule": {
"rule_type": "yearly",
"month": 9,
"day_of_month": 22,
"start_date": "2020-09-22",
},
"start_date": "2026-09-23",
},
)
assert response.status_code == 200
assert response.json()["dates"] == ["2027-09-22"]
async def test_start_near_calendar_end_is_422(
preview: SimpleNamespace,
) -> None:
"""A window that would reach 9999-12-31 is rejected, not clamped."""
response = await _post(
preview.client,
{"rule": {"rule_type": "daily"}, "start_date": "9999-12-30"},
)
assert response.status_code == 422
assert "too close to the end of the calendar" in response.json()["detail"]
async def test_latest_accepted_start_date(preview: SimpleNamespace) -> None:
"""The last start whose window ends before 9999-12-31 is accepted."""
response = await _post(
preview.client,
{
"rule": {"rule_type": "daily"},
"start_date": "9998-12-29",
"count": 1,
},
)
assert response.status_code == 200
assert response.json()["dates"] == ["9998-12-29"]
# --- rejections: 422 -------------------------------------------------------
@pytest.mark.parametrize(
"rule",
[
{"rule_type": "weekly"}, # weekly without weekday_set
{"rule_type": "fortnightly"}, # unknown rule_type
{"rule_type": "daily", "interval": 0},
{"rule_type": "daily", "weekday_set": [1]}, # forbidden field
{"rule_type": "daily", "start_date": "2026-3-1"},
{
"rule_type": "daily",
"start_date": "2026-05-04",
"end_date": "2026-05-01",
},
],
)
async def test_malformed_rule_is_422(
preview: SimpleNamespace, rule: dict[str, object]
) -> None:
"""A rule the core's from_dict rejects is 422, as on create/edit."""
response = await _post(
preview.client, {"rule": rule, "start_date": "2026-03-01"}
)
assert response.status_code == 422
async def test_missing_rule_is_422(preview: SimpleNamespace) -> None:
"""The rule is required: the body model's 422."""
response = await _post(preview.client, {"start_date": "2026-03-01"})
assert response.status_code == 422
@pytest.mark.parametrize(
"start_date", ["2026-3-1", "20260301", "tomorrow", "2026-02-30", ""]
)
async def test_non_strict_start_date_is_422(
preview: SimpleNamespace, start_date: str
) -> None:
"""start_date must be a strict YYYY-MM-DD date (the core's check)."""
response = await _post(
preview.client, {"rule": _weekly_rule(), "start_date": start_date}
)
assert response.status_code == 422
@pytest.mark.parametrize("count", [0, -1, 51, 1000, True, "5", 2.5])
async def test_count_out_of_range_is_422(
preview: SimpleNamespace, count: object
) -> None:
"""count must be a strict JSON integer in 1..50."""
response = await _post(
preview.client,
{"rule": _weekly_rule(), "start_date": "2026-03-01", "count": count},
)
assert response.status_code == 422
@pytest.mark.parametrize("count", [1, 50])
async def test_count_bounds_are_inclusive(
preview: SimpleNamespace, count: int
) -> None:
"""count 1 and 50 are accepted and truncate the list exactly."""
response = await _post(
preview.client,
{
"rule": {"rule_type": "daily"},
"start_date": "2026-01-01",
"count": count,
},
)
assert response.status_code == 200
assert len(response.json()["dates"]) == count
@@ -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}
+345
View File
@@ -0,0 +1,345 @@
"""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
+283
View File
@@ -0,0 +1,283 @@
"""Admin-plane SSE tests: the admin transition stream (task 92f75680).
These tests exercise ``GET /api/v1/admin/events`` — the admin plane's
Server-Sent Events stream for the Admin PWA — against a REAL server:
- The stream inherits the admin router's ONE ``require_admin``
dependency: an absent credential is 401, the panel service token and
a non-admin JWT are 403, all before any stream is established.
- An admin JWT subscribes; each of the four documented transitions
(quest-completed, quest-uncompleted, quest-missed, child-day-complete)
arrives as an ``event: <type>`` / ``data: <json>`` frame carrying its
documented payload fields (design/ENTITIES-AND-SERVICES.md §3).
- The admin and panel streams are fed by the SAME in-process publisher:
one transition arrives, identically, on both.
Streaming approach: :mod:`tests.test_api_sse`'s live-uvicorn reader
(httpx's ASGITransport buffers SSE bodies), on the admin-enabled
:class:`tests.test_api_admin_uncomplete._AdminServer` whose JWKS cache
is stubbed, so a real admin JWT verifies with NO network access.
"""
from __future__ import annotations
import datetime
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import httpx
import pytest
from api import routes_admin, routes_panel
from tests.admin_jwt_harness import make_token
from tests.test_api_admin_uncomplete import _AdminServer
from tests.test_api_panel import PANEL_TOKEN, _seed_household
from tests.test_api_sse import (
EVENT_CHILD_DAY_COMPLETE,
EVENT_QUEST_COMPLETED,
EVENT_QUEST_MISSED,
EVENT_QUEST_UNCOMPLETED,
READ_TIMEOUT,
_stream,
)
EVENTS_PATH = "/api/v1/admin/events"
@pytest.fixture
def temp_db_path(tmp_path) -> str:
"""A fresh per-test SQLite path under pytest's tmp_path."""
return str(tmp_path / "nestquest.db")
@pytest.fixture
async def admin_live(temp_db_path: str) -> SimpleNamespace:
"""A seeded household served by a live admin-enabled uvicorn app.
Both planes' clocks are pinned to 12:00 UTC (past Ada's 10:00 due,
before Bo's 17:00) BEFORE the server starts — the app is created
inside uvicorn's factory thread.
"""
today = datetime.datetime.now().astimezone().date()
pinned_now = datetime.datetime(
today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC")
)
seed = await _seed_household(temp_db_path, today, pinned_now)
original_panel = routes_panel._local_now
original_admin = routes_admin._local_now
routes_panel._local_now = lambda: pinned_now
routes_admin._local_now = lambda: pinned_now
try:
with _AdminServer(temp_db_path) as server:
client = httpx.AsyncClient(
base_url=server.url(""), timeout=READ_TIMEOUT
)
async with client:
yield SimpleNamespace(client=client, seed=seed, server=server)
finally:
routes_panel._local_now = original_panel
routes_admin._local_now = original_admin
async def _panel_complete(live: SimpleNamespace, instance_id: int, child_id: int):
"""Complete ``instance_id`` through the panel route (a real transition)."""
response = await live.client.post(
f"/api/v1/panel/instances/{instance_id}/complete",
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
json={"actor_child_id": child_id},
)
assert response.status_code == 200
# --- auth: the ONE require_admin dependency, no stream on refusal ----------
async def test_admin_events_refuses_absent_panel_and_non_admin(
admin_live: SimpleNamespace,
) -> None:
"""No credential is 401; the panel token and a non-admin JWT are 403.
Each refusal is a complete (non-streaming) JSON error response, so
no stream is established for any of them.
"""
client = admin_live.client
absent = await client.get(EVENTS_PATH)
assert absent.status_code == 401
assert not absent.headers["content-type"].startswith("text/event-stream")
panel = await client.get(
EVENTS_PATH, headers={"Authorization": f"Bearer {PANEL_TOKEN}"}
)
assert panel.status_code == 403
assert panel.json()["detail"] == (
"This credential type is not accepted for admin access"
)
non_admin = await client.get(
EVENTS_PATH,
headers={
"Authorization": f"Bearer {make_token(groups=('other-group',))}"
},
)
assert non_admin.status_code == 403
assert non_admin.json()["detail"] == (
"Admin access requires membership in the nestquest-admins group"
)
async def test_admin_jwt_is_not_accepted_on_panel_events(
admin_live: SimpleNamespace,
) -> None:
"""The panel plane is unchanged: an admin JWT does not open it."""
response = await admin_live.client.get(
"/api/v1/panel/events",
headers={"Authorization": f"Bearer {make_token()}"},
)
assert response.status_code == 401
async def test_admin_events_sets_no_cache(admin_live: SimpleNamespace) -> None:
"""The admin stream is ``text/event-stream`` with ``no-cache``."""
async with admin_live.client.stream(
"GET",
EVENTS_PATH,
headers={"Authorization": f"Bearer {make_token()}"},
) as response:
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/event-stream")
assert response.headers["cache-control"] == "no-cache"
# --- the four documented transitions on the admin stream -------------------
async def test_admin_stream_carries_completed_and_day_complete(
admin_live: SimpleNamespace,
) -> None:
"""A panel completion streams quest-completed then child-day-complete.
Ada's only quest today is Pack bag, so completing it also clears her
day; both frames arrive on the ADMIN stream with their documented
payload fields.
"""
seed = admin_live.seed
pack_bag = seed.pack_bag_instance
stream = _stream(admin_live.server.url(EVENTS_PATH), make_token())
async with stream:
await _panel_complete(admin_live, pack_bag.id, seed.ada.id)
event_type, payload = await stream.next_event()
assert event_type == EVENT_QUEST_COMPLETED == "nestquest_quest_completed"
assert payload["child_id"] == seed.ada.id
assert payload["child_name"] == "Ada"
assert payload["instance_id"] == pack_bag.id
assert payload["quest_title"] == "Pack bag"
assert payload["window"] == "morning"
assert payload["due_date"] == pack_bag.due_date
assert payload["due_time"] == "10:00"
assert payload["occurred_at"]
assert payload["was_on_time"] is False # 10:00 due, 12:00 now
completed_at = payload["occurred_at"]
event_type, payload = await stream.next_event()
assert event_type == EVENT_CHILD_DAY_COMPLETE
assert event_type == "nestquest_child_day_complete"
assert payload["child_id"] == seed.ada.id
assert payload["child_name"] == "Ada"
assert payload["quests_due"] == 1
assert payload["quests_completed"] == 1
assert payload["occurred_at"] == completed_at
async def test_admin_stream_carries_uncompleted(
admin_live: SimpleNamespace,
) -> None:
"""The admin uncomplete route's reversal streams quest-uncompleted."""
seed = admin_live.seed
brush = seed.brush_teeth_instance
admin_headers = {"Authorization": f"Bearer {make_token()}"}
stream = _stream(admin_live.server.url(EVENTS_PATH), make_token())
async with stream:
response = await admin_live.client.post(
f"/api/v1/admin/instances/{brush.id}/uncomplete",
headers=admin_headers,
)
assert response.status_code == 200
assert response.json()["appended"] is True
event_type, payload = await stream.next_event()
assert event_type == EVENT_QUEST_UNCOMPLETED
assert event_type == "nestquest_quest_uncompleted"
assert payload["child_id"] == seed.bo.id
assert payload["child_name"] == "Bo"
assert payload["instance_id"] == brush.id
assert payload["quest_title"] == "Brush teeth"
assert payload["window"] == "morning"
assert payload["due_date"] == brush.due_date
assert payload["due_time"] == "17:00"
assert payload["occurred_at"]
assert "was_on_time" not in payload
async def test_admin_stream_carries_missed(admin_live: SimpleNamespace) -> None:
"""The missed-sweep publisher's transition streams to the admin client.
Drives the API-side helper the sweep calls, on the SERVER's loop,
against the app's own database and its ONE publisher.
"""
from api import transitions
seed = admin_live.seed
stale = seed.stale_instance
app = admin_live.server.app
stream = _stream(admin_live.server.url(EVENTS_PATH), make_token())
async with stream:
admin_live.server.run_on_loop(
transitions.publish_quest_missed(
app.state.db.database, stale.id, app.state.publisher
)
)
event_type, payload = await stream.next_event()
assert event_type == EVENT_QUEST_MISSED == "nestquest_quest_missed"
assert payload["child_id"] == seed.ada.id
assert payload["child_name"] == "Ada"
assert payload["instance_id"] == stale.id
assert payload["quest_title"] == "Stale chore"
assert payload["window"] == "morning"
assert payload["due_date"] == stale.due_date
assert payload["due_time"] == "09:00"
assert payload["occurred_at"]
# --- one publisher feeds both planes ---------------------------------------
async def test_admin_and_panel_streams_share_one_publisher(
admin_live: SimpleNamespace,
) -> None:
"""One transition arrives, identically, on the admin AND panel streams.
The route publishes once to ``app.state.publisher``; both planes'
subscribers receive the same frames (same ``occurred_at``), which a
second, admin-only publisher could never produce.
"""
seed = admin_live.seed
pack_bag = seed.pack_bag_instance
admin_stream = _stream(admin_live.server.url(EVENTS_PATH), make_token())
panel_stream = _stream(admin_live.server.url("/api/v1/panel/events"))
async with admin_stream, panel_stream:
await _panel_complete(admin_live, pack_bag.id, seed.ada.id)
admin_events = [
await admin_stream.next_event(),
await admin_stream.next_event(),
]
panel_events = [
await panel_stream.next_event(),
await panel_stream.next_event(),
]
assert [event_type for event_type, _ in admin_events] == [
EVENT_QUEST_COMPLETED,
EVENT_CHILD_DAY_COMPLETE,
]
assert admin_events == panel_events
+10 -5
View File
@@ -158,7 +158,8 @@ class _EventStream:
parsed frame with a timeout.
"""
def __init__(self) -> None:
def __init__(self, token: str = PANEL_TOKEN) -> None:
self.token = token
self.queue: asyncio.Queue = asyncio.Queue()
self._task: asyncio.Task | None = None
self._client: httpx.AsyncClient | None = None
@@ -170,7 +171,7 @@ class _EventStream:
self._stream_cm = self._client.stream(
"GET",
self.url,
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
headers={"Authorization": f"Bearer {self.token}"},
)
self._response = await self._stream_cm.__aenter__()
assert self._response.status_code == 200
@@ -263,9 +264,13 @@ async def seeded_panel(temp_db_path: str) -> SimpleNamespace:
routes_panel._local_now = original
def _stream(url: str) -> _EventStream:
"""A subscribed SSE reader for ``url``."""
return _EventStream()._set_url(url)
def _stream(url: str, token: str = PANEL_TOKEN) -> _EventStream:
"""A subscribed SSE reader for ``url``, sending ``token`` as Bearer.
Defaults to the panel service token; the admin-plane stream tests
pass an admin JWT instead.
"""
return _EventStream(token)._set_url(url)
# --- auth and content type ----------------------------------------------