The admin History screen (ADMIN-SPEC 1g) had no API: the read primitives existed (append-only completion-event reads, derived-state missed logic) but nothing assembled them, so the admin PWA could not render the event log or export it. Feature 13's export service was superseded by the Admin PWA and remains unimplemented by design. Add a Home-Assistant-free core history layer (core/history.py) that owns every rule so the two routes cannot drift: one query over the closed due-date range [start, end] for filter all (every completion event) / reversals (uncompleted only) / missed (DERIVED past-due open instances via the existing completion.list_missed_for_child rule -- never stored, never appended); strict ISO date and filter validation raising ValueError naming filter/start/end; and a csv-module serializer with a stable documented header (ADMIN-SPEC 5 event-row fields) so the CSV export is the SAME filtered range as the query, through one export_history_csv call. Admin routes GET /api/v1/admin/history and /history.csv are thin adapters on the existing admin router (the one require_admin dependency; no new auth check), mapping malformed input to 422 and never 500. completion_events stays append-only: history only reads. Tests (tests/test_api_admin_history.py) drive the ASGI app as an admin via the shared JWT harness: seeded completion + reversal + past-due open instances prove each filter's rows, the CSV header/rows via csv.reader, due-date range scoping, 403/401 refusals, and 422s for unknown filter, non-strict dates and inverted ranges.
548 lines
19 KiB
Python
548 lines
19 KiB
Python
"""Admin-plane history route tests (task 2d2dda53).
|
|
|
|
These tests exercise the admin plane's history query and CSV export
|
|
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. The
|
|
household is seeded with :func:`tests.test_api_panel._seed_household`
|
|
through the SAME core copy the app uses (``nestquest_core.*``; the
|
|
two-copy caveat in api/nestquest_core.py), and the reversal the
|
|
history tests read is appended through that same copy's
|
|
:func:`nestquest_core.completion.uncomplete_instance` — so the rows
|
|
the routes report are the rows the core's own write paths produced.
|
|
|
|
The seeded household (clock pinned to noon, the route's ``_local_now``
|
|
pinned to the same instant so nothing straddles midnight):
|
|
|
|
- Bo's ``Brush teeth`` TODAY instance: a ``completed`` event (panel
|
|
tap, on time) plus the appended ``uncompleted`` reversal — the
|
|
completion and the reversal the ``all`` and ``reversals`` filters
|
|
report.
|
|
- Ada's ``Pack bag`` and ``Stale chore`` YESTERDAY instances and Bo's
|
|
``Brush teeth`` YESTERDAY instance: open, past due, no events — the
|
|
DERIVED missed rows the ``missed`` filter reports.
|
|
|
|
Proven per the done-condition, everything read back through the API:
|
|
|
|
- ``GET /api/v1/admin/history`` returns the expected rows for EACH
|
|
filter — ``all`` (the completed event AND the reversal),
|
|
``reversals`` (the uncompleted event only) and ``missed`` (the
|
|
derived past-due open instances, actor ``nightly sweep``, no
|
|
``occurred_at``) — and the range scopes by the INSTANCE's due date
|
|
(the Feature 13 rule), so the events sit under their instance's due
|
|
day, not the wall-clock day.
|
|
- ``GET /api/v1/admin/history.csv`` answers ``text/csv`` with a
|
|
``Content-Disposition`` attachment filename, and the parsed body
|
|
(csv.reader) carries the documented header columns and exactly the
|
|
filtered rows.
|
|
- Authentication runs through the router's ONE ``require_admin``
|
|
dependency: a non-admin JWT is 403 and an absent credential is 401
|
|
on both routes.
|
|
- Malformed input — an unknown filter, a non-strict date, an inverted
|
|
range, a missing parameter — is 422 on both routes (never a 500).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import datetime
|
|
import importlib
|
|
import io
|
|
from types import SimpleNamespace
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pytest
|
|
|
|
from api import routes_admin
|
|
from api.database import _executor
|
|
from tests.admin_jwt_harness import (
|
|
ADMIN_KEY,
|
|
KID,
|
|
SUBJECT,
|
|
AdminRunner,
|
|
jwks_for,
|
|
make_token,
|
|
)
|
|
from tests.test_api_panel import _seed_household
|
|
|
|
# The completion, instance-DAO and db layers through the SAME core copy
|
|
# the app uses (nestquest_core.*, NOT the conftest-stubbed
|
|
# custom_components copy) — used for the tests' seeding only; the
|
|
# routes reach them through api.nestquest_core.
|
|
_completion = importlib.import_module("nestquest_core.completion")
|
|
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
|
_core_db = importlib.import_module("nestquest_core.db")
|
|
|
|
#: The documented CSV columns (Admin spec §5 event-row fields), in
|
|
#: header order — asserted literally so the contract is pinned here,
|
|
#: independent of the core constant.
|
|
DOCUMENTED_CSV_COLUMNS = [
|
|
"occurred_at",
|
|
"event_type",
|
|
"child_name",
|
|
"child_id",
|
|
"quest_title",
|
|
"instance_id",
|
|
"window",
|
|
"due_date",
|
|
"due_time",
|
|
"actor",
|
|
"was_on_time",
|
|
]
|
|
|
|
|
|
def _admin_headers() -> dict[str, str]:
|
|
"""The Authorization header of a valid nestquest-admins JWT."""
|
|
return {"Authorization": f"Bearer {make_token()}"}
|
|
|
|
|
|
def _pinned_noon() -> tuple[datetime.date, datetime.datetime]:
|
|
"""One host clock read pinned to 12:00 UTC that day.
|
|
|
|
The seed's completion, the reversal and the route's clock all
|
|
anchor to this instant (the reversal five minutes later), so
|
|
nothing can straddle midnight mid-test.
|
|
"""
|
|
today = datetime.datetime.now().astimezone().date()
|
|
return today, datetime.datetime(
|
|
today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC")
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def history_household(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
|
"""A seeded household served by the admin-plane app, clock pinned.
|
|
|
|
Beyond :func:`_seed_household`'s rows (Bo's today completion, the
|
|
past-due open yesterday instances), the fixture appends ONE
|
|
``uncompleted`` reversal on Bo's today instance through the core
|
|
completion layer — attributed to the harness subject, stamped five
|
|
minutes after the pinned completion — and exposes the yesterday
|
|
instances the missed filter must report.
|
|
"""
|
|
today, pinned_now = _pinned_noon()
|
|
yesterday = today - datetime.timedelta(days=1)
|
|
seed = await _seed_household(temp_db_path, today, pinned_now)
|
|
|
|
database = _core_db.NestQuestDatabase(_executor)
|
|
await database.open(temp_db_path)
|
|
try:
|
|
instances = _dao_instances.QuestInstancesDao(database)
|
|
ada_yesterday = await instances.list_by_child_and_date(
|
|
seed.ada.id, yesterday.isoformat()
|
|
)
|
|
pack_bag_yesterday = next(
|
|
record for record in ada_yesterday if record.due_time == "10:00"
|
|
)
|
|
bo_yesterday = await instances.list_by_child_and_date(
|
|
seed.bo.id, yesterday.isoformat()
|
|
)
|
|
brush_teeth_yesterday = next(
|
|
record for record in bo_yesterday if record.due_time == "17:00"
|
|
)
|
|
await _completion.uncomplete_instance(
|
|
database,
|
|
seed.brush_teeth_instance.id,
|
|
actor_source="user",
|
|
actor_user_id=SUBJECT,
|
|
now=pinned_now + datetime.timedelta(minutes=5),
|
|
today=today,
|
|
)
|
|
finally:
|
|
await database.close()
|
|
|
|
monkeypatch.setattr(routes_admin, "_local_now", lambda: pinned_now)
|
|
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
|
async with runner as client:
|
|
yield SimpleNamespace(
|
|
client=client,
|
|
seed=seed,
|
|
database=runner.app.state.db.database,
|
|
today=today,
|
|
yesterday=yesterday,
|
|
pack_bag_yesterday=pack_bag_yesterday,
|
|
brush_teeth_yesterday=brush_teeth_yesterday,
|
|
)
|
|
|
|
|
|
# --- authentication: the ONE require_admin dependency guards both routes ----
|
|
|
|
|
|
async def test_history_routes_refuse_non_admin_and_absent(
|
|
history_household: SimpleNamespace,
|
|
) -> None:
|
|
"""A non-admin JWT is 403, an absent credential is 401, both routes."""
|
|
client = history_household.client
|
|
query = f"start={history_household.yesterday.isoformat()}"
|
|
query += f"&end={history_household.today.isoformat()}"
|
|
non_admin = make_token(groups=("some-other-group",))
|
|
for path in ("/api/v1/admin/history", "/api/v1/admin/history.csv"):
|
|
forbidden = await client.get(
|
|
f"{path}?{query}",
|
|
headers={"Authorization": f"Bearer {non_admin}"},
|
|
)
|
|
assert forbidden.status_code == 403, path
|
|
assert forbidden.json()["detail"] == (
|
|
"Admin access requires membership in the nestquest-admins group"
|
|
)
|
|
unauthenticated = await client.get(f"{path}?{query}")
|
|
assert unauthenticated.status_code == 401, path
|
|
|
|
|
|
# --- the JSON history query: the expected rows per filter --------------------
|
|
|
|
|
|
async def test_history_all_returns_completed_and_reversal(
|
|
history_household: SimpleNamespace,
|
|
) -> None:
|
|
"""``all`` returns the completed event AND the reversal, in order.
|
|
|
|
Both events belong to Bo's Brush teeth instance due TODAY, so the
|
|
due-date-scoped range [yesterday, today] reports them under today;
|
|
the rows are ordered by due date, then instance, then event id —
|
|
the completed row before its reversal.
|
|
"""
|
|
client = history_household.client
|
|
seed = history_household.seed
|
|
brush = seed.brush_teeth_instance
|
|
yesterday_iso = history_household.yesterday.isoformat()
|
|
today_iso = history_household.today.isoformat()
|
|
|
|
response = await client.get(
|
|
"/api/v1/admin/history",
|
|
params={"filter": "all", "start": yesterday_iso, "end": today_iso},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["filter"] == "all"
|
|
assert body["start"] == yesterday_iso
|
|
assert body["end"] == today_iso
|
|
assert body["count"] == 2
|
|
|
|
completed, reversal = body["rows"]
|
|
assert completed == {
|
|
"occurred_at": f"{today_iso}T12:00:00+00:00",
|
|
"event_type": "completed",
|
|
"child_name": "Bo",
|
|
"child_id": brush.child_id,
|
|
"quest_title": "Brush teeth",
|
|
"instance_id": brush.id,
|
|
"window": "morning",
|
|
"due_date": today_iso,
|
|
"due_time": "17:00",
|
|
"actor": "panel (Bo)",
|
|
"was_on_time": True,
|
|
}
|
|
assert reversal == {
|
|
"occurred_at": f"{today_iso}T12:05:00+00:00",
|
|
"event_type": "uncompleted",
|
|
"child_name": "Bo",
|
|
"child_id": brush.child_id,
|
|
"quest_title": "Brush teeth",
|
|
"instance_id": brush.id,
|
|
"window": "morning",
|
|
"due_date": today_iso,
|
|
"due_time": "17:00",
|
|
"actor": SUBJECT,
|
|
"was_on_time": None,
|
|
}
|
|
|
|
|
|
async def test_history_reversals_returns_only_the_uncompleted_event(
|
|
history_household: SimpleNamespace,
|
|
) -> None:
|
|
"""``reversals`` returns the uncompleted event only, user-attributed."""
|
|
client = history_household.client
|
|
seed = history_household.seed
|
|
brush = seed.brush_teeth_instance
|
|
today_iso = history_household.today.isoformat()
|
|
|
|
response = await client.get(
|
|
"/api/v1/admin/history",
|
|
params={
|
|
"filter": "reversals",
|
|
"start": today_iso,
|
|
"end": today_iso,
|
|
},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["filter"] == "reversals"
|
|
assert body["count"] == 1
|
|
(reversal,) = body["rows"]
|
|
assert reversal["event_type"] == "uncompleted"
|
|
assert reversal["instance_id"] == brush.id
|
|
assert reversal["actor"] == SUBJECT
|
|
assert reversal["occurred_at"] == f"{today_iso}T12:05:00+00:00"
|
|
assert reversal["was_on_time"] is None
|
|
|
|
|
|
async def test_history_missed_returns_derived_missed_instances(
|
|
history_household: SimpleNamespace,
|
|
) -> None:
|
|
"""``missed`` returns the DERIVED past-due open instances.
|
|
|
|
Three seeded instances are open and due yesterday: Ada's Pack bag
|
|
and Stale chore and Bo's Brush teeth. Each is a DERIVED row —
|
|
never a stored event: no ``occurred_at``, ``was_on_time`` None and
|
|
the spec's ``nightly sweep`` actor.
|
|
"""
|
|
client = history_household.client
|
|
seed = history_household.seed
|
|
yesterday_iso = history_household.yesterday.isoformat()
|
|
|
|
response = await client.get(
|
|
"/api/v1/admin/history",
|
|
params={
|
|
"filter": "missed",
|
|
"start": yesterday_iso,
|
|
"end": yesterday_iso,
|
|
},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["filter"] == "missed"
|
|
assert body["count"] == 3
|
|
|
|
rows = body["rows"]
|
|
assert all(row["event_type"] == "missed" for row in rows)
|
|
assert all(row["occurred_at"] is None for row in rows)
|
|
assert all(row["was_on_time"] is None for row in rows)
|
|
assert all(row["actor"] == "nightly sweep" for row in rows)
|
|
assert all(row["due_date"] == yesterday_iso for row in rows)
|
|
assert {
|
|
(row["child_name"], row["quest_title"], row["due_time"])
|
|
for row in rows
|
|
} == {
|
|
("Ada", "Pack bag", "10:00"),
|
|
("Bo", "Brush teeth", "17:00"),
|
|
("Ada", "Stale chore", "09:00"),
|
|
}
|
|
assert {
|
|
row["instance_id"]
|
|
for row in rows
|
|
} == {
|
|
history_household.pack_bag_yesterday.id,
|
|
history_household.brush_teeth_yesterday.id,
|
|
seed.stale_instance.id,
|
|
}
|
|
# Deterministic order: due date, then instance id.
|
|
keys = [(row["due_date"], row["instance_id"]) for row in rows]
|
|
assert keys == sorted(keys)
|
|
|
|
|
|
async def test_history_range_scopes_by_the_instance_due_date(
|
|
history_household: SimpleNamespace,
|
|
) -> None:
|
|
"""The range scopes by the INSTANCE's due date, not the wall clock.
|
|
|
|
Both events OCCURRED today but belong to the instance due today, so
|
|
a yesterday-only ``all`` range reports nothing; the missed filter
|
|
over a today-only range reports nothing either (a due-TODAY open
|
|
instance is not past due).
|
|
"""
|
|
client = history_household.client
|
|
yesterday_iso = history_household.yesterday.isoformat()
|
|
today_iso = history_household.today.isoformat()
|
|
|
|
yesterday_all = await client.get(
|
|
"/api/v1/admin/history",
|
|
params={"filter": "all", "start": yesterday_iso, "end": yesterday_iso},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert yesterday_all.status_code == 200
|
|
assert yesterday_all.json()["count"] == 0
|
|
|
|
today_missed = await client.get(
|
|
"/api/v1/admin/history",
|
|
params={"filter": "missed", "start": today_iso, "end": today_iso},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert today_missed.status_code == 200
|
|
assert today_missed.json()["count"] == 0
|
|
|
|
|
|
# --- the CSV export: documented columns and the filtered rows ----------------
|
|
|
|
|
|
async def test_history_csv_carries_documented_columns_and_all_rows(
|
|
history_household: SimpleNamespace,
|
|
) -> None:
|
|
"""The CSV export carries the documented header and the filtered rows.
|
|
|
|
The body is parsed with csv.reader: the first row is the documented
|
|
column set, the remaining rows are exactly the ``all`` filter's
|
|
rows (the completion and its reversal) in the same order the JSON
|
|
query reports them.
|
|
"""
|
|
client = history_household.client
|
|
seed = history_household.seed
|
|
brush = seed.brush_teeth_instance
|
|
yesterday_iso = history_household.yesterday.isoformat()
|
|
today_iso = history_household.today.isoformat()
|
|
|
|
response = await client.get(
|
|
"/api/v1/admin/history.csv",
|
|
params={"filter": "all", "start": yesterday_iso, "end": today_iso},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"].startswith("text/csv")
|
|
disposition = response.headers["content-disposition"]
|
|
assert disposition.startswith("attachment;")
|
|
assert (
|
|
f'filename="nestquest-history-{yesterday_iso}-to-{today_iso}.csv"'
|
|
in disposition
|
|
)
|
|
|
|
parsed = list(csv.reader(io.StringIO(response.text)))
|
|
assert parsed[0] == DOCUMENTED_CSV_COLUMNS
|
|
assert len(parsed) == 3 # header + completed + reversal
|
|
assert parsed[1] == [
|
|
f"{today_iso}T12:00:00+00:00",
|
|
"completed",
|
|
"Bo",
|
|
str(brush.child_id),
|
|
"Brush teeth",
|
|
str(brush.id),
|
|
"morning",
|
|
today_iso,
|
|
"17:00",
|
|
"panel (Bo)",
|
|
"true",
|
|
]
|
|
assert parsed[2] == [
|
|
f"{today_iso}T12:05:00+00:00",
|
|
"uncompleted",
|
|
"Bo",
|
|
str(brush.child_id),
|
|
"Brush teeth",
|
|
str(brush.id),
|
|
"morning",
|
|
today_iso,
|
|
"17:00",
|
|
SUBJECT,
|
|
"",
|
|
]
|
|
|
|
|
|
async def test_history_csv_missed_rows_are_derived_rows(
|
|
history_household: SimpleNamespace,
|
|
) -> None:
|
|
"""The CSV export of the ``missed`` filter carries the derived rows.
|
|
|
|
Same filtered range as the JSON query's missed answer: three rows
|
|
with an empty ``occurred_at`` (no event exists), the ``missed``
|
|
event type and the ``nightly sweep`` actor.
|
|
"""
|
|
client = history_household.client
|
|
seed = history_household.seed
|
|
yesterday_iso = history_household.yesterday.isoformat()
|
|
|
|
response = await client.get(
|
|
"/api/v1/admin/history.csv",
|
|
params={
|
|
"filter": "missed",
|
|
"start": yesterday_iso,
|
|
"end": yesterday_iso,
|
|
},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert response.status_code == 200
|
|
parsed = list(csv.reader(io.StringIO(response.text)))
|
|
assert parsed[0] == DOCUMENTED_CSV_COLUMNS
|
|
assert len(parsed) == 4 # header + the three derived missed rows
|
|
missed_rows = parsed[1:]
|
|
assert all(row[1] == "missed" for row in missed_rows)
|
|
assert all(row[0] == "" for row in missed_rows)
|
|
assert all(row[9] == "nightly sweep" for row in missed_rows)
|
|
assert all(row[10] == "" for row in missed_rows)
|
|
assert {
|
|
(row[2], row[4], row[8]) for row in missed_rows
|
|
} == {
|
|
("Ada", "Pack bag", "10:00"),
|
|
("Bo", "Brush teeth", "17:00"),
|
|
("Ada", "Stale chore", "09:00"),
|
|
}
|
|
assert {row[5] for row in missed_rows} == {
|
|
str(history_household.pack_bag_yesterday.id),
|
|
str(history_household.brush_teeth_yesterday.id),
|
|
str(seed.stale_instance.id),
|
|
}
|
|
|
|
|
|
# --- validation: malformed filter and date range are 422, never 500 ----------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"params",
|
|
[
|
|
{"filter": "everything", "start": "2026-03-01", "end": "2026-03-02"},
|
|
{"filter": "all", "start": "2026/03/01", "end": "2026-03-02"},
|
|
{"filter": "all", "start": "2026-3-1", "end": "2026-03-02"},
|
|
{"filter": "all", "start": "2026-03-02", "end": "2026-03-01"},
|
|
{"filter": "all", "start": "not-a-date", "end": "2026-03-02"},
|
|
],
|
|
)
|
|
async def test_history_query_rejects_malformed_params_as_422(
|
|
history_household: SimpleNamespace, params: dict[str, str]
|
|
) -> None:
|
|
"""An unknown filter or a rejected range is 422 naming the field."""
|
|
client = history_household.client
|
|
response = await client.get(
|
|
"/api/v1/admin/history", params=params, headers=_admin_headers()
|
|
)
|
|
assert response.status_code == 422, params
|
|
detail = response.json()["detail"]
|
|
assert any(
|
|
name in detail for name in ("filter", "start", "end")
|
|
), detail
|
|
|
|
|
|
async def test_history_csv_rejects_malformed_params_as_422(
|
|
history_household: SimpleNamespace,
|
|
) -> None:
|
|
"""The CSV route maps the same rejections to 422."""
|
|
client = history_household.client
|
|
unknown_filter = await client.get(
|
|
"/api/v1/admin/history.csv",
|
|
params={"filter": "galaxy", "start": "2026-03-01", "end": "2026-03-02"},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert unknown_filter.status_code == 422
|
|
assert "filter" in unknown_filter.json()["detail"]
|
|
|
|
inverted = await client.get(
|
|
"/api/v1/admin/history.csv",
|
|
params={"filter": "all", "start": "2026-03-02", "end": "2026-03-01"},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert inverted.status_code == 422
|
|
assert "end must be on or after start" in inverted.json()["detail"]
|
|
|
|
|
|
async def test_history_query_missing_params_are_422(
|
|
history_household: SimpleNamespace,
|
|
) -> None:
|
|
"""A missing required query parameter is FastAPI's 422 (the shape)."""
|
|
client = history_household.client
|
|
no_params = await client.get(
|
|
"/api/v1/admin/history", headers=_admin_headers()
|
|
)
|
|
assert no_params.status_code == 422
|
|
missing_start = await client.get(
|
|
"/api/v1/admin/history",
|
|
params={"end": "2026-03-02"},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert missing_start.status_code == 422
|
|
missing_end = await client.get(
|
|
"/api/v1/admin/history",
|
|
params={"start": "2026-03-01"},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert missing_end.status_code == 422
|