Feature 16: NestQuest API Service (#165)
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 #165.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
"""Shared admin-plane JWT test harness: local keys, stubbed JWKS.
|
||||
|
||||
The admin-plane API test modules share ONE harness instead of
|
||||
duplicating it: the auth contract tests (:mod:`tests.test_api_admin_auth`)
|
||||
and the children route tests (:mod:`tests.test_api_admin_children`) both
|
||||
drive the ASGI app with httpx using a locally generated RSA key pair and
|
||||
a stubbed JWKS fetcher — NO network access:
|
||||
|
||||
- ``ADMIN_KEY`` signs the test tokens; :func:`jwks_for` builds the JWKS
|
||||
document publishing its public half, so genuinely signed tokens
|
||||
verify, and tokens signed with the second pair (``OTHER_KEY``) fail
|
||||
the signature check for real.
|
||||
- :func:`make_token` mints one RS256 JWT the way Authentik would (its
|
||||
claims, our key), defaulting ``groups`` to the admin group a real
|
||||
admin's token carries.
|
||||
- :class:`StubbedFetch` is the counting JWKS fetcher stub.
|
||||
- :class:`AdminRunner` runs the app's lifespan around an httpx
|
||||
AsyncClient (httpx's ASGI transport does not run the lifespan, so it
|
||||
is driven explicitly and shut down in ``__aexit__``'s finally block
|
||||
even on failure) with the app's JWKS cache swapped for one built
|
||||
around the stub fetcher.
|
||||
|
||||
The harness carries no tests itself; the ``admin_client`` fixture in
|
||||
``tests/conftest.py`` hands its runner to the test modules.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
from api.app import create_app
|
||||
from api.auth import ADMIN_GROUP, JwksCache
|
||||
from api.config import ApiConfig
|
||||
|
||||
#: The configured OIDC settings the test apps run with (values a real
|
||||
#: deployment would get from the NESTQUEST_OIDC_* environment vars).
|
||||
ISSUER = "https://authentik.example.com/application/o/nestquest/"
|
||||
AUDIENCE = "nestquest-api"
|
||||
JWKS_URL = "https://authentik.example.com/application/o/nestquest/jwks/"
|
||||
|
||||
#: The ``kid`` the test tokens carry, matching the JWKS entry.
|
||||
KID = "admin-test-key-1"
|
||||
|
||||
#: The subject a valid token authenticates as.
|
||||
SUBJECT = "admin-user-1"
|
||||
|
||||
|
||||
def _generate_key() -> rsa.RSAPrivateKey:
|
||||
"""Generate one local RSA key pair (no network, no fixtures)."""
|
||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
#: The key pair whose PUBLIC half the stubbed JWKS publishes — tokens
|
||||
#: signed with it verify; the second pair below proves they don't when
|
||||
#: signed otherwise.
|
||||
ADMIN_KEY = _generate_key()
|
||||
OTHER_KEY = _generate_key()
|
||||
|
||||
|
||||
def _b64url_uint(value: int) -> str:
|
||||
"""Base64url-encode a non-negative integer (no padding), per JWK."""
|
||||
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def jwks_for(private_key: rsa.RSAPrivateKey, kid: str) -> dict[str, object]:
|
||||
"""Build a JWKS document publishing ``private_key``'s public half."""
|
||||
numbers = private_key.public_key().public_numbers()
|
||||
return {
|
||||
"keys": [
|
||||
{
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"kid": kid,
|
||||
"n": _b64url_uint(numbers.n),
|
||||
"e": _b64url_uint(numbers.e),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class StubbedFetch:
|
||||
"""A JWKS fetcher stub: returns a fixed document, counts calls."""
|
||||
|
||||
def __init__(self, document: dict[str, object]) -> None:
|
||||
self.document = document
|
||||
self.calls = 0
|
||||
|
||||
async def __call__(self, url: str) -> dict[str, object]:
|
||||
self.calls += 1
|
||||
return self.document
|
||||
|
||||
|
||||
def make_token(
|
||||
signing_key: rsa.RSAPrivateKey = ADMIN_KEY,
|
||||
*,
|
||||
kid: str | None = KID,
|
||||
iss: str = ISSUER,
|
||||
aud: object = AUDIENCE,
|
||||
sub: str = SUBJECT,
|
||||
expires_in: int | None = 300,
|
||||
nbf_in: int | None = -1,
|
||||
groups: object = (ADMIN_GROUP,),
|
||||
) -> str:
|
||||
"""Mint one RS256 JWT the way Authentik would (its claims, our key).
|
||||
|
||||
``groups`` defaults to the admin group (what a real admin user's
|
||||
token carries); tests pass a different value — or ``None`` for NO
|
||||
groups claim at all — to prove the group gate rejects it.
|
||||
"""
|
||||
now = int(time.time())
|
||||
claims: dict[str, object] = {"iss": iss, "aud": aud, "sub": sub, "iat": now}
|
||||
if groups is not None:
|
||||
claims["groups"] = (
|
||||
list(groups) if isinstance(groups, (list, tuple)) else groups
|
||||
)
|
||||
if nbf_in is not None:
|
||||
claims["nbf"] = now + nbf_in
|
||||
if expires_in is not None:
|
||||
claims["exp"] = now + expires_in
|
||||
headers: dict[str, str] = {}
|
||||
if kid is not None:
|
||||
headers["kid"] = kid
|
||||
return jwt.encode(
|
||||
claims, signing_key, algorithm="RS256", headers=headers
|
||||
)
|
||||
|
||||
|
||||
class AdminRunner:
|
||||
"""Run the app's lifespan around an httpx AsyncClient.
|
||||
|
||||
Same pattern as test_api_health.py's ``_AppRunner``: httpx's ASGI
|
||||
transport does not run the lifespan, so it is driven explicitly and
|
||||
shut down in ``__aexit__``'s finally block even on failure.
|
||||
|
||||
The app's JWKS cache is replaced (before the lifespan runs) with
|
||||
one built around a counting stub fetcher, so verification goes
|
||||
through the REAL cache logic but never the network.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path: str,
|
||||
jwks_document: dict[str, object],
|
||||
*,
|
||||
ttl_seconds: float | None = None,
|
||||
backoff_seconds: float | None = None,
|
||||
kid_refetch_seconds: float | None = None,
|
||||
) -> None:
|
||||
self.app = create_app(
|
||||
ApiConfig(
|
||||
db_path=db_path,
|
||||
panel_token="admin-test-panel-token",
|
||||
oidc_issuer=ISSUER,
|
||||
oidc_audience=AUDIENCE,
|
||||
oidc_jwks_url=JWKS_URL,
|
||||
)
|
||||
)
|
||||
self.fetch = StubbedFetch(jwks_document)
|
||||
cache_kwargs: dict[str, float] = {}
|
||||
if ttl_seconds is not None:
|
||||
cache_kwargs["ttl_seconds"] = ttl_seconds
|
||||
if backoff_seconds is not None:
|
||||
cache_kwargs["backoff_seconds"] = backoff_seconds
|
||||
if kid_refetch_seconds is not None:
|
||||
cache_kwargs["kid_refetch_seconds"] = kid_refetch_seconds
|
||||
self.app.state.jwks_cache = JwksCache(
|
||||
JWKS_URL, fetcher=self.fetch, **cache_kwargs
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> httpx.AsyncClient:
|
||||
self._lifespan = self.app.router.lifespan_context(self.app)
|
||||
await self._lifespan.__aenter__()
|
||||
self._client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=self.app),
|
||||
base_url="http://testserver",
|
||||
)
|
||||
return await self._client.__aenter__()
|
||||
|
||||
async def __aexit__(self, *exc) -> None:
|
||||
try:
|
||||
await self._client.__aexit__(*exc)
|
||||
finally:
|
||||
await self._lifespan.__aexit__(*exc)
|
||||
+25
-1
@@ -33,8 +33,11 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Homeassistant module provisioning (explicitly mock-only).
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -953,4 +956,25 @@ def freeze_nestquest_clock(
|
||||
date=datetime.date,
|
||||
)
|
||||
monkeypatch.setattr(nestquest, "datetime", fake_module)
|
||||
return frozen, requested
|
||||
return frozen, requested
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin-plane API fixtures (shared by the admin-plane test modules).
|
||||
#
|
||||
# The local-key/stubbed-JWKS harness itself lives in
|
||||
# :mod:`tests.admin_jwt_harness` (imported by name, the same pattern as
|
||||
# tests.blueprint_helpers); these fixtures hand its runner to every test
|
||||
# module in tests/ without re-declaring it.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path: Path) -> str:
|
||||
"""A fresh per-test SQLite path under pytest's tmp_path."""
|
||||
return str(tmp_path / "nestquest.db")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_client(temp_db_path: str) -> httpx.AsyncClient:
|
||||
"""An app client whose provider publishes the admin test key."""
|
||||
async with AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) as client:
|
||||
yield client
|
||||
@@ -0,0 +1,760 @@
|
||||
"""Admin-plane JWT auth tests: Authentik-style OIDC verification (task e70e6bfb).
|
||||
|
||||
These tests exercise the NestQuest API's admin plane against the ASGI
|
||||
app with httpx, using a locally generated RSA key pair and a stubbed
|
||||
JWKS fetcher — NO network access. That harness (the key pairs, the
|
||||
JWKS builder, the token minting and the app runner) is shared with the
|
||||
other admin-plane route tests in :mod:`tests.admin_jwt_harness`; this
|
||||
module imports it and proves the auth CONTRACT itself:
|
||||
|
||||
- ``GET /api/v1/admin/ping`` requires a valid Authentik OIDC JWT: a
|
||||
request with NO ``Authorization`` header and a MALFORMED header
|
||||
return 401; a garbage, expired, wrong-issuer, wrong-audience,
|
||||
not-yet-valid (future ``nbf``), unknown-``kid``, or bad-signature
|
||||
token returns 401; a token without ``exp`` is rejected; a VALID
|
||||
token returns 200 with the verified subject.
|
||||
- Every 401 carries the SAME detail — nothing in the response
|
||||
distinguishes which check failed.
|
||||
- The token's HEADER is validated BEFORE the JWKS cache is touched: a
|
||||
garbage or non-RS256 token triggers NO fetch at all.
|
||||
- A JWKS entry that matches the ``kid`` but carries corrupt key
|
||||
material maps to the same uniform 401.
|
||||
- The JWKS document is fetched ONCE through the app's
|
||||
:class:`api.auth.JwksCache` and then reused across requests (the
|
||||
stub fetcher counts calls); a zero TTL refetches.
|
||||
- A JWKS OUTAGE fails fast: after one failed fetch the cache re-raises
|
||||
the fault for the whole backoff window without further fetches, even
|
||||
under a burst of concurrent requests; an unknown ``kid`` forces one
|
||||
TTL-bypassing refetch, throttled to at most one per interval.
|
||||
|
||||
``ApiConfig.from_env`` requires the three OIDC variables
|
||||
(``NESTQUEST_OIDC_ISSUER``, ``NESTQUEST_OIDC_AUDIENCE``,
|
||||
``NESTQUEST_OIDC_JWKS_URL``) with the same fail-fast discipline as
|
||||
``NESTQUEST_DB_PATH`` / ``NESTQUEST_PANEL_TOKEN``: tests build the app
|
||||
with explicit ApiConfig values so they stay hermetic.
|
||||
|
||||
The signature/expiry/audience proof is real, not stubbed: tokens are
|
||||
signed with a locally generated RSA private key, the app verifies
|
||||
against the JWKS built from its public key, and the wrong-signature
|
||||
case signs with a SECOND locally generated key pair.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from api.auth import JwksCache
|
||||
from api.config import ApiConfig, ConfigError
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
AUDIENCE,
|
||||
ISSUER,
|
||||
JWKS_URL,
|
||||
KID,
|
||||
OTHER_KEY,
|
||||
SUBJECT,
|
||||
AdminRunner as _AdminRunner,
|
||||
jwks_for as _jwks_for,
|
||||
make_token as _make_token,
|
||||
)
|
||||
|
||||
#: The uniform 401 detail every rejection must carry.
|
||||
REJECTION_DETAIL = "Missing or invalid admin credentials"
|
||||
|
||||
#: 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"
|
||||
)
|
||||
|
||||
|
||||
class _FailingFetch:
|
||||
"""A JWKS fetcher stub that always fails, counting the attempts."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
async def __call__(self, url: str) -> dict[str, object]:
|
||||
self.calls += 1
|
||||
raise httpx.ConnectError("provider unreachable")
|
||||
|
||||
|
||||
class _FlakyFetch:
|
||||
"""A fetcher that succeeds ONCE, then fails on every later call."""
|
||||
|
||||
def __init__(self, document: dict[str, object]) -> None:
|
||||
self.document = document
|
||||
self.calls = 0
|
||||
|
||||
async def __call__(self, url: str) -> dict[str, object]:
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
return self.document
|
||||
raise httpx.ConnectError("provider unreachable")
|
||||
|
||||
|
||||
class _FailThenServe:
|
||||
"""A fetcher that fails the FIRST call, then serves the document."""
|
||||
|
||||
def __init__(self, document: dict[str, object]) -> None:
|
||||
self.document = document
|
||||
self.calls = 0
|
||||
|
||||
async def __call__(self, url: str) -> dict[str, object]:
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
raise httpx.ConnectError("provider unreachable")
|
||||
return self.document
|
||||
|
||||
|
||||
def _token_with_header(
|
||||
header: dict[str, object], payload: dict[str, object]
|
||||
) -> str:
|
||||
"""Build a JWT with an EXACT header (bypassing jwt.encode's rules).
|
||||
|
||||
Used to prove header checks (``alg``, ``kid``) happen before the
|
||||
JWKS cache is consulted; the signature segment is never reached.
|
||||
"""
|
||||
def part(obj: dict[str, object]) -> str:
|
||||
raw = json.dumps(obj).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
return f"{part(header)}.{part(payload)}.c2lnbmF0dXJl"
|
||||
|
||||
|
||||
def _verified_payload() -> dict[str, object]:
|
||||
"""Claims that would verify — the header checks fire before them."""
|
||||
now = int(time.time())
|
||||
return {
|
||||
"iss": ISSUER,
|
||||
"aud": AUDIENCE,
|
||||
"sub": SUBJECT,
|
||||
"exp": now + 300,
|
||||
"nbf": now - 1,
|
||||
}
|
||||
|
||||
|
||||
# --- the happy path -------------------------------------------------------
|
||||
|
||||
|
||||
async def test_valid_token_returns_200_with_subject(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A valid token returns 200 with the status and verified subject."""
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {_make_token()}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok", "sub": SUBJECT}
|
||||
|
||||
|
||||
async def test_bearer_scheme_is_case_insensitive(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""``bearer`` (lowercase) is accepted, as RFC 9110 requires."""
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"bearer {_make_token()}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# --- 401s: missing / malformed credentials --------------------------------
|
||||
|
||||
|
||||
async def test_missing_token_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A request with no Authorization header returns 401."""
|
||||
response = await admin_client.get("/api/v1/admin/ping")
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == REJECTION_DETAIL
|
||||
assert "www-authenticate" in {
|
||||
key.lower() for key in response.headers
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"header",
|
||||
[
|
||||
"Basic cGFuZWw6dG9rZW4=", # wrong scheme
|
||||
"Bearer", # scheme, no credential
|
||||
"Bearer ", # blank credential
|
||||
"BearerNotAToken", # no scheme separator at all
|
||||
],
|
||||
)
|
||||
async def test_malformed_header_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient, header: str
|
||||
) -> None:
|
||||
"""A malformed Authorization header returns 401."""
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping", headers={"Authorization": header}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_garbage_token_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A Bearer credential that is not a JWT at all returns 401."""
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": "Bearer not-a-jwt"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# --- 401s: token content the verifier must reject ---------------------------
|
||||
|
||||
|
||||
async def test_expired_token_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token whose exp has passed returns 401."""
|
||||
token = _make_token(expires_in=-60)
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_wrong_issuer_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token issued by another issuer returns 401."""
|
||||
token = _make_token(iss="https://evil.example.com/application/o/nestquest/")
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_wrong_audience_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token minted for another audience returns 401."""
|
||||
token = _make_token(aud="some-other-client")
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_future_nbf_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token that is not yet valid (future nbf) returns 401."""
|
||||
token = _make_token(nbf_in=3600)
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_token_without_exp_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token with no exp claim returns 401 (expiry is REQUIRED)."""
|
||||
token = _make_token(expires_in=None)
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_unknown_kid_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token naming a kid the JWKS does not publish returns 401."""
|
||||
token = _make_token(kid="no-such-key")
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_bad_signature_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token signed by a key the provider never published returns 401.
|
||||
|
||||
Signed with the SECOND local key pair (header kid still points at
|
||||
the published key), so the signature check itself must fail.
|
||||
"""
|
||||
token = _make_token(signing_key=OTHER_KEY)
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_corrupt_jwks_entry_matching_kid_is_unauthorized(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A JWKS entry matching the kid but with corrupt key data → 401.
|
||||
|
||||
``RSAAlgorithm.from_jwk`` raises ValueError/TypeError (NOT a
|
||||
PyJWTError) on such an entry; it must map to the SAME uniform 401,
|
||||
not surface as a server error.
|
||||
"""
|
||||
corrupt_jwks: dict[str, object] = {
|
||||
"keys": [
|
||||
{"kty": "RSA", "kid": KID, "n": "AQ", "e": "AQAB"},
|
||||
]
|
||||
}
|
||||
runner = _AdminRunner(temp_db_path, corrupt_jwks)
|
||||
async with runner as client:
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {_make_token()}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == REJECTION_DETAIL
|
||||
|
||||
|
||||
# --- the header is checked BEFORE the JWKS cache -----------------------------
|
||||
|
||||
|
||||
async def test_garbage_token_triggers_no_jwks_fetch(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A token that is not a JWT costs NO fetch, lock or provider load."""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": "Bearer not-a-jwt"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == REJECTION_DETAIL
|
||||
assert runner.fetch.calls == 0
|
||||
|
||||
|
||||
async def test_non_rs256_token_triggers_no_jwks_fetch(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A structurally valid JWT with a non-RS256 alg is 401 pre-cache.
|
||||
|
||||
The header carries a kid pointing at the published key, so ONLY the
|
||||
early alg check can reject it — the fetcher must never be called.
|
||||
"""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
token = _token_with_header(
|
||||
{"alg": "HS256", "typ": "JWT", "kid": KID}, _verified_payload()
|
||||
)
|
||||
async with runner as client:
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == REJECTION_DETAIL
|
||||
assert runner.fetch.calls == 0
|
||||
|
||||
|
||||
# --- the uniform 401 contract -----------------------------------------------
|
||||
|
||||
|
||||
async def test_every_rejection_carries_the_same_detail(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""No failure mode is distinguishable in the response body.
|
||||
|
||||
Collects the (status, detail) pair for every rejection — missing
|
||||
and malformed headers, garbage, expired, wrong issuer, wrong
|
||||
audience, future nbf, missing exp, unknown kid, bad signature —
|
||||
and asserts they are ALL the same one 401.
|
||||
"""
|
||||
cases: list[dict[str, str]] = [
|
||||
{},
|
||||
{"Authorization": "Basic cGFuZWw6dG9rZW4="},
|
||||
{"Authorization": "Bearer"},
|
||||
{"Authorization": "Bearer not-a-jwt"},
|
||||
{"Authorization": f"Bearer {_make_token(expires_in=-60)}"},
|
||||
{"Authorization": f"Bearer {_make_token(iss='https://evil.example.com/')}"},
|
||||
{"Authorization": f"Bearer {_make_token(aud='other-client')}"},
|
||||
{"Authorization": f"Bearer {_make_token(nbf_in=3600)}"},
|
||||
{"Authorization": f"Bearer {_make_token(expires_in=None)}"},
|
||||
{"Authorization": f"Bearer {_make_token(kid='no-such-key')}"},
|
||||
{"Authorization": f"Bearer {_make_token(signing_key=OTHER_KEY)}"},
|
||||
]
|
||||
observed = set()
|
||||
for headers in cases:
|
||||
response = await admin_client.get("/api/v1/admin/ping", headers=headers)
|
||||
observed.add((response.status_code, response.json()["detail"]))
|
||||
assert observed == {(401, REJECTION_DETAIL)}
|
||||
|
||||
|
||||
# --- the JWKS cache ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_jwks_is_fetched_once_across_requests(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""Multiple requests reuse ONE cached JWKS document.
|
||||
|
||||
Three separately authenticated requests trigger exactly ONE stub
|
||||
fetch — the cache is per app and never per request.
|
||||
"""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
token = _make_token()
|
||||
async with runner as client:
|
||||
for _ in range(3):
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert runner.fetch.calls == 1
|
||||
|
||||
|
||||
async def test_expired_ttl_refetches_the_jwks(temp_db_path: str) -> None:
|
||||
"""A lapsed TTL triggers a fresh fetch instead of serving stale keys."""
|
||||
runner = _AdminRunner(
|
||||
temp_db_path, _jwks_for(ADMIN_KEY, KID), ttl_seconds=0.0
|
||||
)
|
||||
token = _make_token()
|
||||
async with runner as client:
|
||||
for _ in range(2):
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert runner.fetch.calls == 2
|
||||
|
||||
|
||||
# --- the JWKS cache under an outage: fail fast, don't amplify ---------------
|
||||
|
||||
|
||||
async def test_jwks_outage_fails_fast_during_backoff() -> None:
|
||||
"""After one failed fetch, later requests fail fast for the window."""
|
||||
fetch = _FailingFetch()
|
||||
cache = JwksCache(JWKS_URL, fetcher=fetch)
|
||||
for _ in range(4):
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
await cache.get()
|
||||
assert fetch.calls == 1
|
||||
|
||||
|
||||
async def test_jwks_outage_burst_calls_the_fetcher_once() -> None:
|
||||
"""A burst of concurrent requests costs ONE fetch, not one each.
|
||||
|
||||
The first caller fetches and fails; every other caller — queued on
|
||||
the lock or arriving inside the backoff window — fails fast on the
|
||||
recorded fault without touching the fetcher.
|
||||
"""
|
||||
fetch = _FailingFetch()
|
||||
cache = JwksCache(JWKS_URL, fetcher=fetch)
|
||||
results = await asyncio.gather(
|
||||
*(cache.get() for _ in range(6)), return_exceptions=True
|
||||
)
|
||||
assert fetch.calls == 1
|
||||
assert all(
|
||||
isinstance(result, httpx.ConnectError) for result in results
|
||||
)
|
||||
|
||||
|
||||
async def test_jwks_backoff_lapses_and_recovers() -> None:
|
||||
"""A backoff window is not a lockout: retries resume after it.
|
||||
|
||||
With a zero-length window the very next request retries (and the
|
||||
successful document is then served from the cache), proving a
|
||||
failed state is never cached for good.
|
||||
"""
|
||||
document = _jwks_for(ADMIN_KEY, KID)
|
||||
fetch = _FailThenServe(document)
|
||||
cache = JwksCache(JWKS_URL, fetcher=fetch, backoff_seconds=0.0)
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
await cache.get()
|
||||
assert await cache.get() == document
|
||||
assert await cache.get() == document
|
||||
assert fetch.calls == 2
|
||||
|
||||
|
||||
async def test_jwks_outage_is_a_server_fault_not_a_401(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A provider outage propagates as a fault, never as a 401."""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
runner.app.state.jwks_cache = JwksCache(JWKS_URL, fetcher=_FailingFetch())
|
||||
async with runner as client:
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {_make_token()}"},
|
||||
)
|
||||
|
||||
|
||||
# --- the unknown-kid refetch: one forced, bounded attempt --------------------
|
||||
|
||||
|
||||
async def test_unknown_kid_refetch_picks_up_a_rotated_key(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""An unmatched kid forces ONE refetch, and the retry can verify.
|
||||
|
||||
The JWKS first publishes a DIFFERENT kid: the token is 401 after
|
||||
exactly two fetches (normal + forced). When the rotated key lands
|
||||
in the provider's document, the NEXT token forces another refetch
|
||||
that picks it up — the same token verifies WITHOUT waiting out the
|
||||
TTL.
|
||||
"""
|
||||
runner = _AdminRunner(
|
||||
temp_db_path, _jwks_for(ADMIN_KEY, "other-key"), kid_refetch_seconds=0.0
|
||||
)
|
||||
token = _make_token()
|
||||
async with runner as client:
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == REJECTION_DETAIL
|
||||
assert runner.fetch.calls == 2
|
||||
|
||||
# The rotation lands at the provider (same stub fetcher).
|
||||
runner.fetch.document = _jwks_for(ADMIN_KEY, KID)
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert runner.fetch.calls == 3
|
||||
|
||||
|
||||
async def test_unknown_kid_refetch_is_throttled(temp_db_path: str) -> None:
|
||||
"""Back-to-back unknown-kid tokens force at most one refetch.
|
||||
|
||||
The interval bounds forced refetches: the second unknown-kid token
|
||||
inside it is served from the cached document (still unmatched →
|
||||
401) with no further fetch.
|
||||
"""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, "other-key"))
|
||||
token = _make_token()
|
||||
async with runner as client:
|
||||
for _ in range(2):
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert runner.fetch.calls == 2
|
||||
|
||||
|
||||
async def test_unknown_kid_refetch_resumes_after_the_interval(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A lapsed interval allows the next forced refetch again.
|
||||
|
||||
With a zero-length interval every request may force its refetch:
|
||||
the first costs one normal fetch plus one forced (two), and the
|
||||
second — its cached document still fresh — costs just the forced
|
||||
one (three in total), proving the throttle is a bounded interval
|
||||
rather than a permanent lockout.
|
||||
"""
|
||||
runner = _AdminRunner(
|
||||
temp_db_path, _jwks_for(ADMIN_KEY, "other-key"), kid_refetch_seconds=0.0
|
||||
)
|
||||
token = _make_token()
|
||||
async with runner as client:
|
||||
for _ in range(2):
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert runner.fetch.calls == 3
|
||||
|
||||
|
||||
async def test_failed_forced_refetch_propagates_then_fails_fast(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A failed forced refetch is a fault, and its backoff bounds the rest.
|
||||
|
||||
First request: cache fills (1 fetch), the kid is unmatched, the
|
||||
forced refetch FAILS (2 fetches) and propagates as the server
|
||||
fault. Second request inside the backoff window: the cached
|
||||
document is served, the match fails, and the token is 401 — with
|
||||
NO third fetch.
|
||||
"""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
runner.app.state.jwks_cache = JwksCache(
|
||||
JWKS_URL, fetcher=_FlakyFetch(_jwks_for(ADMIN_KEY, "other-key"))
|
||||
)
|
||||
token = _make_token()
|
||||
async with runner as client:
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == REJECTION_DETAIL
|
||||
|
||||
|
||||
# --- ApiConfig.from_env: the OIDC settings -----------------------------------
|
||||
|
||||
|
||||
def _full_env() -> dict[str, str]:
|
||||
"""A from_env dict with every required variable set to a valid value."""
|
||||
return {
|
||||
"NESTQUEST_DB_PATH": "/data/nq.db",
|
||||
"NESTQUEST_PANEL_TOKEN": "panel-token",
|
||||
"NESTQUEST_OIDC_ISSUER": ISSUER,
|
||||
"NESTQUEST_OIDC_AUDIENCE": AUDIENCE,
|
||||
"NESTQUEST_OIDC_JWKS_URL": JWKS_URL,
|
||||
}
|
||||
|
||||
|
||||
def test_from_env_reads_oidc_settings() -> None:
|
||||
"""Set, non-empty OIDC env values are returned verbatim."""
|
||||
cfg = ApiConfig.from_env(_full_env())
|
||||
assert cfg.oidc_issuer == ISSUER
|
||||
assert cfg.oidc_audience == AUDIENCE
|
||||
assert cfg.oidc_jwks_url == JWKS_URL
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"env_var",
|
||||
[
|
||||
"NESTQUEST_OIDC_ISSUER",
|
||||
"NESTQUEST_OIDC_AUDIENCE",
|
||||
"NESTQUEST_OIDC_JWKS_URL",
|
||||
],
|
||||
)
|
||||
def test_from_env_requires_oidc_setting(env_var: str) -> None:
|
||||
"""A missing OR empty OIDC variable raises ConfigError (fail fast)."""
|
||||
env = _full_env()
|
||||
del env[env_var]
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env(env)
|
||||
env[env_var] = " "
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env(env)
|
||||
|
||||
|
||||
# --- the nestquest-admins group gate (task 0f2afb29) -------------------------
|
||||
|
||||
|
||||
async def test_admin_jwt_with_group_returns_200(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A valid JWT whose groups claim names nestquest-admins returns 200."""
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {_make_token()}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok", "sub": SUBJECT}
|
||||
|
||||
|
||||
async def test_non_admin_jwt_is_forbidden(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A valid JWT WITHOUT nestquest-admins in groups returns 403."""
|
||||
token = _make_token(groups=("some-other-group",))
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
||||
|
||||
|
||||
async def test_empty_groups_is_forbidden(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A valid JWT with an EMPTY groups list returns 403."""
|
||||
token = _make_token(groups=())
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("groups", ["nestquest-admins", None])
|
||||
async def test_malformed_groups_is_forbidden(
|
||||
admin_client: httpx.AsyncClient, groups: object
|
||||
) -> None:
|
||||
"""A valid JWT with a non-list (or missing) groups claim returns 403.
|
||||
|
||||
Even a plain STRING naming the group is not enough — the claim must
|
||||
be a list, so a token crafted with a scalar ``groups`` cannot pass.
|
||||
"""
|
||||
token = _make_token(groups=groups)
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
||||
|
||||
|
||||
async def test_panel_service_token_is_refused_outright(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""The panel service token on an admin route is 403, never a JWT.
|
||||
|
||||
403 (not the uniform 401) proves the credential was refused as a
|
||||
TYPE, not run through JWT verification — and the JWKS fetcher is
|
||||
never called, proving it was not treated as a token to verify.
|
||||
"""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": "Bearer admin-test-panel-token"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
||||
assert runner.fetch.calls == 0
|
||||
|
||||
|
||||
async def test_admin_router_is_wired_through_require_admin(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""The admin router's dependency IS the one require_admin dependency.
|
||||
|
||||
The router-level dependency list carries
|
||||
:func:`api.auth.require_admin`, so every admin route (current and
|
||||
later) inherits the service-token refusal and the group gate.
|
||||
"""
|
||||
from api.auth import require_admin
|
||||
from api.routes_admin import router as admin_router
|
||||
|
||||
dependencies = [d.dependency for d in admin_router.dependencies]
|
||||
assert require_admin in dependencies
|
||||
|
||||
# Behavioral proof: the probe returns 200 for an admin JWT through
|
||||
# the same dependency.
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {_make_token()}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
"""Admin-plane children route tests (task e4845baa).
|
||||
|
||||
These tests exercise the admin plane's children 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, 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 no row is written.
|
||||
- ``POST /api/v1/admin/children``: the created child EXISTS with the
|
||||
given display name (and the optional colour/avatar_ref/sort_order).
|
||||
- ``PATCH /api/v1/admin/children/{id}``: an edit PERSISTS the supplied
|
||||
fields and leaves omitted fields untouched (exclude_unset semantics —
|
||||
an omitted field is never passed to the core).
|
||||
- ``PATCH /api/v1/admin/children/{id}/active`` with ``is_active: false``
|
||||
sets the active flag FALSE and the row STILL EXISTS (children are
|
||||
never hard-deleted).
|
||||
- ``POST /api/v1/admin/children/reorder``: the posted order becomes the
|
||||
rows' sort_order; a list that is not a complete permutation is 422
|
||||
and leaves the stored order untouched.
|
||||
|
||||
Error mapping (documented in api/routes_admin.py): a core ValueError
|
||||
for a non-existent child is 404; every other core ValueError (an empty
|
||||
display name, an explicit null colour, a bad permutation) is 422 — the
|
||||
same failure class FastAPI reports for malformed bodies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
|
||||
# The children business layer through the SAME core copy the app uses
|
||||
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy) —
|
||||
# used for the tests' database assertions only; the routes reach it
|
||||
# through api.nestquest_core.
|
||||
_children = importlib.import_module("nestquest_core.children")
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
async def _children_by_id(database: object) -> dict[int, object]:
|
||||
"""Every child row through the app's core copy, keyed by id."""
|
||||
records = await _children.list_children(database)
|
||||
return {record.id: record for record in records}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_children(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database.
|
||||
|
||||
The same runner the auth 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 themselves.
|
||||
"""
|
||||
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_children_routes_refuse_non_admin_and_absent_credentials(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-admin JWT is 403, an absent credential is 401: nothing written."""
|
||||
client = admin_children.client
|
||||
body = {"display_name": "Ada"}
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
|
||||
forbidden = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
assert forbidden.json()["detail"] == (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
|
||||
unauthenticated = await client.post("/api/v1/admin/children", json=body)
|
||||
assert unauthenticated.status_code == 401
|
||||
|
||||
unauthenticated_list = await client.get("/api/v1/admin/children")
|
||||
assert unauthenticated_list.status_code == 401
|
||||
|
||||
# No route ran its handler: the household is still empty.
|
||||
assert await _children_by_id(admin_children.database) == {}
|
||||
|
||||
|
||||
# --- create -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_create_child_persists_the_given_name(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""POST /children creates the row; the child exists with that name."""
|
||||
response = await admin_children.client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={
|
||||
"display_name": "Ada",
|
||||
"colour": "#ff8800",
|
||||
"avatar_ref": "avatar:bee",
|
||||
"sort_order": 2,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["display_name"] == "Ada"
|
||||
assert body["colour"] == "#ff8800"
|
||||
assert body["avatar_ref"] == "avatar:bee"
|
||||
assert body["sort_order"] == 2
|
||||
assert body["is_active"] is True
|
||||
|
||||
stored = (await _children_by_id(admin_children.database))[body["id"]]
|
||||
assert stored.display_name == "Ada"
|
||||
assert stored.colour == "#ff8800"
|
||||
assert stored.avatar_ref == "avatar:bee"
|
||||
assert stored.sort_order == 2
|
||||
assert stored.is_active is True
|
||||
|
||||
|
||||
async def test_create_child_with_only_a_name_uses_core_defaults(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Omitted optional fields default through the core layer."""
|
||||
response = await admin_children.client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Bo"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
stored = (await _children_by_id(admin_children.database))[
|
||||
response.json()["id"]
|
||||
]
|
||||
assert stored.display_name == "Bo"
|
||||
assert stored.colour is None
|
||||
assert stored.avatar_ref is None
|
||||
assert stored.sort_order == 0
|
||||
assert stored.is_active is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
{}, # display_name missing
|
||||
{"display_name": 5}, # wrong type
|
||||
{"display_name": ""}, # empty: the core rejects it
|
||||
{"display_name": " "}, # whitespace-only: empty to a parent
|
||||
{"display_name": "Ada", "sort_order": "first"}, # wrong type
|
||||
],
|
||||
)
|
||||
async def test_create_child_malformed_input_is_422(
|
||||
admin_children: SimpleNamespace, body: dict[str, object]
|
||||
) -> None:
|
||||
"""Malformed bodies are 422 and never create a row."""
|
||||
response = await admin_children.client.post(
|
||||
"/api/v1/admin/children", json=body, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert await _children_by_id(admin_children.database) == {}
|
||||
|
||||
|
||||
# --- edit -------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_edit_child_persists_supplied_fields_and_skips_omitted_ones(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""PATCH persists exactly the supplied fields (exclude_unset)."""
|
||||
client = admin_children.client
|
||||
created = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={
|
||||
"display_name": "Ada",
|
||||
"colour": "#ff8800",
|
||||
"avatar_ref": "avatar:bee",
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
child_id = created.json()["id"]
|
||||
|
||||
# Only display_name and sort_order: colour/avatar_ref must survive.
|
||||
edited = await client.patch(
|
||||
f"/api/v1/admin/children/{child_id}",
|
||||
json={"display_name": "Ada Lovelace", "sort_order": 4},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert edited.status_code == 200
|
||||
assert edited.json()["display_name"] == "Ada Lovelace"
|
||||
assert edited.json()["sort_order"] == 4
|
||||
stored = (await _children_by_id(admin_children.database))[child_id]
|
||||
assert stored.display_name == "Ada Lovelace"
|
||||
assert stored.sort_order == 4
|
||||
# The omitted fields were never passed to the core: unchanged.
|
||||
assert stored.colour == "#ff8800"
|
||||
assert stored.avatar_ref == "avatar:bee"
|
||||
|
||||
# A second edit touching only colour leaves the name alone.
|
||||
recoloured = await client.patch(
|
||||
f"/api/v1/admin/children/{child_id}",
|
||||
json={"colour": "#00aa55"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert recoloured.status_code == 200
|
||||
stored = (await _children_by_id(admin_children.database))[child_id]
|
||||
assert stored.colour == "#00aa55"
|
||||
assert stored.display_name == "Ada Lovelace"
|
||||
|
||||
|
||||
async def test_edit_unknown_child_is_404(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An edit naming a child that does not exist is 404."""
|
||||
response = await admin_children.client.patch(
|
||||
"/api/v1/admin/children/999",
|
||||
json={"display_name": "Nobody"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Child not found"
|
||||
|
||||
|
||||
async def test_edit_explicit_null_colour_is_422_and_changes_nothing(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An explicit null (clearing) is passed through and rejected by the core.
|
||||
|
||||
Only the fields the request SUPPLIES are forwarded: a null colour is
|
||||
a supplied None, which the core refuses (the DAO cannot express
|
||||
NULL) — mapped to 422, with the stored colour left untouched.
|
||||
"""
|
||||
created = await admin_children.client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Ada", "colour": "#ff8800"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
child_id = created.json()["id"]
|
||||
response = await admin_children.client.patch(
|
||||
f"/api/v1/admin/children/{child_id}",
|
||||
json={"colour": None},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
stored = (await _children_by_id(admin_children.database))[child_id]
|
||||
assert stored.colour == "#ff8800"
|
||||
|
||||
|
||||
# --- set_active -------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_deactivation_sets_the_flag_false_and_never_deletes(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""is_active=false deactivates the row; the row STILL exists."""
|
||||
client = admin_children.client
|
||||
created = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Ada"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
child_id = created.json()["id"]
|
||||
|
||||
deactivated = await client.patch(
|
||||
f"/api/v1/admin/children/{child_id}/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
assert deactivated.json()["is_active"] is False
|
||||
|
||||
# Database proof: the flag is False and the row was NOT hard-deleted.
|
||||
rows = await _children_by_id(admin_children.database)
|
||||
stored = rows[child_id]
|
||||
assert stored.is_active is False
|
||||
assert stored.display_name == "Ada"
|
||||
|
||||
# Reactivation goes through the same route: the flag is set, not
|
||||
# one-way.
|
||||
reactivated = await client.patch(
|
||||
f"/api/v1/admin/children/{child_id}/active",
|
||||
json={"is_active": True},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert reactivated.status_code == 200
|
||||
assert reactivated.json()["is_active"] is True
|
||||
rows = await _children_by_id(admin_children.database)
|
||||
assert rows[child_id].is_active is True
|
||||
|
||||
|
||||
async def test_set_active_unknown_child_is_404(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A set_active naming a child that does not exist is 404."""
|
||||
response = await admin_children.client.patch(
|
||||
"/api/v1/admin/children/999/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Child not found"
|
||||
|
||||
|
||||
async def test_set_active_malformed_body_is_422(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-bool is_active is rejected by the body model (422)."""
|
||||
response = await admin_children.client.patch(
|
||||
"/api/v1/admin/children/1/active",
|
||||
json={"is_active": "maybe"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# --- reorder ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_reorder_writes_the_posted_order_to_sort_order(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The posted id order becomes the rows' sort_order."""
|
||||
client = admin_children.client
|
||||
ids = []
|
||||
for name in ("Ada", "Bo", "Cyd"):
|
||||
created = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": name},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
ids.append(created.json()["id"])
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/children/reorder",
|
||||
json={"ordered_ids": [ids[2], ids[0], ids[1]]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
# Database proof: each posted position became that child's sort_order.
|
||||
rows = await _children_by_id(admin_children.database)
|
||||
assert rows[ids[2]].sort_order == 0
|
||||
assert rows[ids[0]].sort_order == 1
|
||||
assert rows[ids[1]].sort_order == 2
|
||||
|
||||
|
||||
async def test_reorder_rejects_non_permutations_and_writes_nothing(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A list that is not a complete permutation is 422, order intact.
|
||||
|
||||
The core rejects the list BEFORE any write, so the stored sort_order
|
||||
values survive every rejected call unchanged.
|
||||
"""
|
||||
client = admin_children.client
|
||||
ids = []
|
||||
for name in ("Ada", "Bo"):
|
||||
created = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": name},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
ids.append(created.json()["id"])
|
||||
|
||||
bad_lists = [
|
||||
ids[:1], # partial: missing an existing child
|
||||
[ids[0], ids[0], ids[1]], # duplicate id
|
||||
[*ids, ids[1] + 999], # unknown id
|
||||
]
|
||||
for ordered_ids in bad_lists:
|
||||
response = await client.post(
|
||||
"/api/v1/admin/children/reorder",
|
||||
json={"ordered_ids": ordered_ids},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422, ordered_ids
|
||||
|
||||
# A non-integer id never reaches the core either (body model).
|
||||
malformed = await client.post(
|
||||
"/api/v1/admin/children/reorder",
|
||||
json={"ordered_ids": ["first", "second"]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert malformed.status_code == 422
|
||||
|
||||
# Database proof: no write happened for any rejected call.
|
||||
rows = await _children_by_id(admin_children.database)
|
||||
assert [rows[child_id].sort_order for child_id in ids] == [0, 0]
|
||||
|
||||
|
||||
# --- list (the read model the admin PWA re-renders from) ---------------------
|
||||
|
||||
|
||||
async def test_list_children_returns_all_in_display_order(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""GET /children returns every child (inactive included) in order."""
|
||||
client = admin_children.client
|
||||
ada = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Ada", "sort_order": 5},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
bo = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Bo", "sort_order": 1},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
deactivated = await client.patch(
|
||||
f"/api/v1/admin/children/{ada.json()['id']}/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/children", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
children = response.json()["children"]
|
||||
assert [child["id"] for child in children] == [
|
||||
bo.json()["id"],
|
||||
ada.json()["id"],
|
||||
]
|
||||
by_id = {child["id"]: child for child in children}
|
||||
assert by_id[ada.json()["id"]]["is_active"] is False
|
||||
assert by_id[bo.json()["id"]]["is_active"] is True
|
||||
# The serialized shape is the documented child payload.
|
||||
assert set(by_id[bo.json()["id"]]) == {
|
||||
"id",
|
||||
"display_name",
|
||||
"colour",
|
||||
"avatar_ref",
|
||||
"sort_order",
|
||||
"is_active",
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
"""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
|
||||
@@ -0,0 +1,571 @@
|
||||
"""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
|
||||
@@ -0,0 +1,611 @@
|
||||
"""Admin-plane quest-definition route tests (task b586a2f1).
|
||||
|
||||
These tests exercise the admin plane's quest-definition 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, 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 no definition is written.
|
||||
- ``POST /api/v1/admin/quest-definitions``: the created definition
|
||||
EXISTS with its title, description, icon, decoded rule, assignees
|
||||
and windows exactly as posted.
|
||||
- ``PATCH /api/v1/admin/quest-definitions/{id}``: an edit PERSISTS the
|
||||
supplied fields and leaves omitted fields untouched (exclude_unset
|
||||
semantics); a supplied ``windows`` list replaces the whole set;
|
||||
assignment is not settable through the edit.
|
||||
- ``PATCH /api/v1/admin/quest-definitions/{id}/active`` with
|
||||
``is_active: false`` sets the flag FALSE and the definition STILL
|
||||
EXISTS with its rule, assignees and windows (never hard-deleted);
|
||||
reactivation restores it.
|
||||
|
||||
Error mapping (documented in api/routes_admin.py): a core ValueError
|
||||
for a non-existent DEFINITION is 404; every other core ValueError — a
|
||||
rejected rule (RuleValidationError), a bad window name or due time, a
|
||||
rejected assignee list (an unknown assignee arrives in the BODY, so it
|
||||
is 422, never 404) — is 422, the same failure class FastAPI reports
|
||||
for malformed bodies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
|
||||
# The quest-definition business layer and its DAO through the SAME core
|
||||
# copy the app uses (nestquest_core.*, NOT the conftest-stubbed
|
||||
# custom_components copy) — used for the tests' database assertions
|
||||
# only; the routes reach them through api.nestquest_core.
|
||||
_quest_definitions = importlib.import_module(
|
||||
"nestquest_core.quest_definitions"
|
||||
)
|
||||
_dao_rules = importlib.import_module("nestquest_core.dao_rules")
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _weekly_rule_body() -> dict[str, object]:
|
||||
"""A valid weekly rule body: Mondays and Wednesdays from 2026-03-02."""
|
||||
return {
|
||||
"rule_type": "weekly",
|
||||
"interval": 1,
|
||||
"weekday_set": [0, 2],
|
||||
"start_date": "2026-03-02",
|
||||
}
|
||||
|
||||
|
||||
def _weekly_rule_dict() -> dict[str, object]:
|
||||
"""The lossless dict form _weekly_rule_body() must decode back to."""
|
||||
return {
|
||||
"rule_type": "weekly",
|
||||
"interval": 1,
|
||||
"weekday_set": [0, 2],
|
||||
"day_of_month": None,
|
||||
"nth_weekday": None,
|
||||
"nth_weekday_weekday": None,
|
||||
"month": None,
|
||||
"start_date": "2026-03-02",
|
||||
"end_date": None,
|
||||
}
|
||||
|
||||
|
||||
async def _create_children(
|
||||
client: object, names: tuple[str, ...]
|
||||
) -> list[int]:
|
||||
"""Create ACTIVE children through the admin children route."""
|
||||
ids = []
|
||||
for name in names:
|
||||
created = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": name},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
ids.append(created.json()["id"])
|
||||
return ids
|
||||
|
||||
|
||||
async def _create_definition(
|
||||
client: object, assignee_ids: list[int]
|
||||
) -> object:
|
||||
"""POST a valid two-window definition for the given assignees."""
|
||||
return await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Tidy the den",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": assignee_ids,
|
||||
"windows": ["morning", ["evening", "19:30"]],
|
||||
"description": "Put everything back where it belongs.",
|
||||
"icon": "toy-box",
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
|
||||
async def _definitions_by_id(database: object) -> dict[int, object]:
|
||||
"""Every ACTIVE definition bundle through the app's core copy."""
|
||||
bundles = await _quest_definitions.list_active_definitions(database)
|
||||
return {bundle.definition.id: bundle for bundle in bundles}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_questdefs(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database.
|
||||
|
||||
The same runner the auth and children 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 themselves.
|
||||
"""
|
||||
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_quest_definition_routes_refuse_non_admin_and_absent(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-admin JWT is 403, an absent credential is 401: nothing written."""
|
||||
client = admin_questdefs.client
|
||||
body = {"title": "Sweep the porch"}
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
|
||||
forbidden = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
assert forbidden.json()["detail"] == (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
|
||||
unauthenticated_create = await client.post(
|
||||
"/api/v1/admin/quest-definitions", json=body
|
||||
)
|
||||
assert unauthenticated_create.status_code == 401
|
||||
|
||||
unauthenticated_edit = await client.patch(
|
||||
"/api/v1/admin/quest-definitions/1", json={"title": "X"}
|
||||
)
|
||||
assert unauthenticated_edit.status_code == 401
|
||||
|
||||
unauthenticated_active = await client.patch(
|
||||
"/api/v1/admin/quest-definitions/1/active", json={"is_active": False}
|
||||
)
|
||||
assert unauthenticated_active.status_code == 401
|
||||
|
||||
# No route ran its handler: no definition was written.
|
||||
assert await _definitions_by_id(admin_questdefs.database) == {}
|
||||
|
||||
|
||||
# --- create -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_create_persists_definition_with_assignees_and_windows(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""POST /quest-definitions persists rule, assignees and windows."""
|
||||
client = admin_questdefs.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Tidy the den",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": [ada, bo],
|
||||
"windows": ["morning", ["evening", "19:30"]],
|
||||
"description": "Put everything back where it belongs.",
|
||||
"icon": "toy-box",
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
payload = response.json()
|
||||
definition_id = payload["id"]
|
||||
# The serialized shape is the documented definition payload.
|
||||
assert set(payload) == {
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"icon",
|
||||
"is_active",
|
||||
"rule",
|
||||
"assignees",
|
||||
"windows",
|
||||
}
|
||||
assert payload["title"] == "Tidy the den"
|
||||
assert payload["description"] == "Put everything back where it belongs."
|
||||
assert payload["icon"] == "toy-box"
|
||||
assert payload["is_active"] is True
|
||||
assert payload["rule"] == _weekly_rule_dict()
|
||||
assert {a["id"]: a["display_name"] for a in payload["assignees"]} == {
|
||||
ada: "Ada",
|
||||
bo: "Bo",
|
||||
}
|
||||
assert {w["window"]: w["due_time"] for w in payload["windows"]} == {
|
||||
"morning": None,
|
||||
"evening": "19:30",
|
||||
}
|
||||
|
||||
# Database proof through the app's core copy: the definition, its
|
||||
# decoded rule, its assignees and its windows all exist as posted.
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
definition_id
|
||||
]
|
||||
assert stored.definition.title == "Tidy the den"
|
||||
assert stored.definition.description == (
|
||||
"Put everything back where it belongs."
|
||||
)
|
||||
assert stored.definition.icon == "toy-box"
|
||||
assert stored.definition.is_active is True
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
assert sorted(child.id for child in stored.assignees) == [ada, bo]
|
||||
assert {(w.window, w.due_time) for w in stored.windows} == {
|
||||
("morning", None),
|
||||
("evening", "19:30"),
|
||||
}
|
||||
|
||||
|
||||
async def test_create_with_required_fields_only_uses_core_defaults(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Omitted optional fields default to NULL through the core layer."""
|
||||
client = admin_questdefs.client
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Water the plants",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": [ada],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
payload = response.json()
|
||||
assert payload["description"] is None
|
||||
assert payload["icon"] is None
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
payload["id"]
|
||||
]
|
||||
assert stored.definition.description is None
|
||||
assert stored.definition.icon is None
|
||||
|
||||
|
||||
async def test_create_without_required_fields_is_422(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A body missing any required field is 422 and creates nothing."""
|
||||
client = admin_questdefs.client
|
||||
bodies: list[dict[str, object]] = [
|
||||
{}, # everything missing
|
||||
{"title": "Tidy the den"}, # rule, assignees, windows missing
|
||||
{
|
||||
"title": "Tidy the den",
|
||||
"rule": _weekly_rule_body(),
|
||||
}, # assignees and windows missing
|
||||
{
|
||||
"title": "Tidy the den",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": [1],
|
||||
}, # windows missing
|
||||
]
|
||||
for body in bodies:
|
||||
response = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json=body,
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422, body
|
||||
assert await _definitions_by_id(admin_questdefs.database) == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"override",
|
||||
[
|
||||
{"title": ""}, # empty: the core rejects it
|
||||
{"title": " "}, # whitespace-only: empty to a parent
|
||||
{"assignee_child_ids": []}, # empty assignee list
|
||||
{"assignee_child_ids": [999, 1000]}, # unknown children: 422 not 404
|
||||
{"assignee_child_ids": ["Ada"]}, # wrong-typed ids
|
||||
{"windows": []}, # empty window set
|
||||
{"windows": "morning"}, # not a list
|
||||
{"windows": ["attic"]}, # not a const.QUEST_WINDOWS name
|
||||
{"windows": [["morning", "25:00"]]}, # not a 24-hour time
|
||||
{"windows": [["morning", "8:00"]]}, # not a strict HH:MM
|
||||
{"windows": [["morning", None], "morning"]}, # duplicate window
|
||||
{"rule": {"rule_type": "weekly"}}, # weekly without weekday_set
|
||||
{"rule": {"rule_type": "fortnightly"}}, # unknown rule_type
|
||||
{"rule": {"rule_type": "daily", "interval": 0}}, # interval < 1
|
||||
{"rule": {"rule_type": "weekly", "weekday_set": [7]}}, # out of range
|
||||
{"rule": {"rule_type": "monthly_day"}}, # missing day_of_month
|
||||
{"rule": {"rule_type": "daily", "day_of_month": 5}}, # forbidden field
|
||||
{"rule": {"rule_type": "daily", "start_date": "2026-13-01"}},
|
||||
{"rule": None}, # an explicit null rule is a supplied invalid value
|
||||
],
|
||||
)
|
||||
async def test_create_rejections_are_422_and_persist_nothing(
|
||||
admin_questdefs: SimpleNamespace, override: dict[str, object]
|
||||
) -> None:
|
||||
"""Rejected bodies are 422 and never create a definition."""
|
||||
client = admin_questdefs.client
|
||||
ids = await _create_children(client, ("Ada", "Bo"))
|
||||
body: dict[str, object] = {
|
||||
"title": "Tidy the den",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": ids,
|
||||
"windows": ["morning", ["evening", "19:30"]],
|
||||
}
|
||||
body.update(override)
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json=body,
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422, override
|
||||
assert await _definitions_by_id(admin_questdefs.database) == {}
|
||||
|
||||
|
||||
# --- edit -------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_edit_persists_supplied_fields_and_leaves_omitted_untouched(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""PATCH persists exactly the supplied fields (exclude_unset)."""
|
||||
client = admin_questdefs.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
created = await _create_definition(client, [ada, bo])
|
||||
assert created.status_code == 201
|
||||
definition_id = created.json()["id"]
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
|
||||
original_rule_id = (await dao.get(definition_id)).schedule_rule_id
|
||||
|
||||
# Only title and windows: description, icon, rule and assignees
|
||||
# must survive; the supplied windows REPLACE the whole set.
|
||||
edited = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"title": "Clear the den", "windows": ["afternoon"]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert edited.status_code == 200
|
||||
assert edited.json()["title"] == "Clear the den"
|
||||
assert {w["window"]: w["due_time"] for w in edited.json()["windows"]} == {
|
||||
"afternoon": None
|
||||
}
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
definition_id
|
||||
]
|
||||
assert stored.definition.title == "Clear the den"
|
||||
assert {(w.window, w.due_time) for w in stored.windows} == {
|
||||
("afternoon", None)
|
||||
}
|
||||
# Omitted fields were never passed to the core: unchanged.
|
||||
assert stored.definition.description == (
|
||||
"Put everything back where it belongs."
|
||||
)
|
||||
assert stored.definition.icon == "toy-box"
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
# Assignment is not settable through the edit route: untouched.
|
||||
assert sorted(child.id for child in stored.assignees) == [ada, bo]
|
||||
|
||||
# A second edit touching only the rule leaves the metadata and the
|
||||
# windows alone, and updates the rule row IN PLACE (same rule id).
|
||||
rescheduled = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"rule": {"rule_type": "daily", "start_date": "2026-04-01"}},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert rescheduled.status_code == 200
|
||||
assert rescheduled.json()["rule"]["rule_type"] == "daily"
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
definition_id
|
||||
]
|
||||
assert stored.definition.title == "Clear the den"
|
||||
assert {(w.window, w.due_time) for w in stored.windows} == {
|
||||
("afternoon", None)
|
||||
}
|
||||
assert stored.rule.to_dict() == {
|
||||
"rule_type": "daily",
|
||||
"interval": 1,
|
||||
"weekday_set": None,
|
||||
"day_of_month": None,
|
||||
"nth_weekday": None,
|
||||
"nth_weekday_weekday": None,
|
||||
"month": None,
|
||||
"start_date": "2026-04-01",
|
||||
"end_date": None,
|
||||
}
|
||||
record = await dao.get(definition_id)
|
||||
assert record.schedule_rule_id == original_rule_id
|
||||
rule_row = await _dao_rules.ScheduleRulesDao(
|
||||
admin_questdefs.database
|
||||
).get(record.schedule_rule_id)
|
||||
assert rule_row.rule_type == "daily"
|
||||
assert rule_row.start_date == "2026-04-01"
|
||||
|
||||
|
||||
async def test_edit_can_clear_description_and_icon_explicitly(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An explicit null description/icon clears them (the core's edit path).
|
||||
|
||||
Unlike the children edit, the definition edit supports clearing: a
|
||||
supplied null IS forwarded and writes NULL; everything omitted
|
||||
stays untouched.
|
||||
"""
|
||||
client = admin_questdefs.client
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
created = await _create_definition(client, [ada])
|
||||
definition_id = created.json()["id"]
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"description": None, "icon": None},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["description"] is None
|
||||
assert response.json()["icon"] is None
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
definition_id
|
||||
]
|
||||
assert stored.definition.description is None
|
||||
assert stored.definition.icon is None
|
||||
assert stored.definition.title == "Tidy the den"
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
|
||||
|
||||
async def test_edit_unknown_definition_is_404(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An edit naming a definition that does not exist is 404."""
|
||||
response = await admin_questdefs.client.patch(
|
||||
"/api/v1/admin/quest-definitions/999",
|
||||
json={"title": "Ghost chore"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Quest definition not found"
|
||||
|
||||
|
||||
async def test_edit_rejections_are_422_and_change_nothing(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A rejected rule or window set is 422; the stored state survives.
|
||||
|
||||
The core rejects BEFORE any write (and the DAO edit runs in one
|
||||
transaction), so a 422 never leaves a half-applied change behind.
|
||||
"""
|
||||
client = admin_questdefs.client
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
created = await _create_definition(client, [ada])
|
||||
definition_id = created.json()["id"]
|
||||
|
||||
rejected_bodies: list[dict[str, object]] = [
|
||||
{"rule": {"rule_type": "weekly"}}, # weekly without weekday_set
|
||||
{"rule": None}, # an explicit null rule is rejected by the core
|
||||
{"windows": ["attic"]}, # unknown window name
|
||||
{"windows": None}, # an explicit null window set is rejected
|
||||
{"title": ""}, # the core rejects an empty title
|
||||
]
|
||||
for body in rejected_bodies:
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json=body,
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422, body
|
||||
|
||||
# Database proof: nothing changed for any rejected call.
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
definition_id
|
||||
]
|
||||
assert stored.definition.title == "Tidy the den"
|
||||
assert stored.definition.description == (
|
||||
"Put everything back where it belongs."
|
||||
)
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
assert {(w.window, w.due_time) for w in stored.windows} == {
|
||||
("morning", None),
|
||||
("evening", "19:30"),
|
||||
}
|
||||
|
||||
|
||||
# --- set_active -------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_set_active_toggles_the_flag_and_the_definition_survives(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""is_active=false deactivates; the definition STILL exists intact.
|
||||
|
||||
Deactivation is the only removal path: the row, rule, assignees and
|
||||
windows all survive, and reactivation through the same route
|
||||
restores the flag without anything having been lost.
|
||||
"""
|
||||
client = admin_questdefs.client
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
created = await _create_definition(client, [ada])
|
||||
assert created.status_code == 201
|
||||
definition_id = created.json()["id"]
|
||||
|
||||
deactivated = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
payload = deactivated.json()
|
||||
assert payload["is_active"] is False
|
||||
# Everything else came back unchanged: deactivation only flips a flag.
|
||||
assert payload["title"] == "Tidy the den"
|
||||
assert payload["rule"] == _weekly_rule_dict()
|
||||
assert [a["id"] for a in payload["assignees"]] == [ada]
|
||||
assert {w["window"]: w["due_time"] for w in payload["windows"]} == {
|
||||
"morning": None,
|
||||
"evening": "19:30",
|
||||
}
|
||||
|
||||
# Database proof: the definition left the ACTIVE list but its row,
|
||||
# rule, assignees and windows were NOT hard-deleted.
|
||||
assert definition_id not in await _definitions_by_id(
|
||||
admin_questdefs.database
|
||||
)
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
|
||||
record = await dao.get(definition_id)
|
||||
assert record is not None
|
||||
assert record.is_active is False
|
||||
assert record.title == "Tidy the den"
|
||||
assert await dao.list_assignees(definition_id) != []
|
||||
assert await dao.list_windows(definition_id) != []
|
||||
|
||||
# Reactivation goes through the same route: the flag is set, not
|
||||
# one-way, and the surviving rule is still the one that was stored.
|
||||
reactivated = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}/active",
|
||||
json={"is_active": True},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert reactivated.status_code == 200
|
||||
assert reactivated.json()["is_active"] is True
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
definition_id
|
||||
]
|
||||
assert stored.definition.is_active is True
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
assert [child.id for child in stored.assignees] == [ada]
|
||||
|
||||
|
||||
async def test_set_active_unknown_definition_is_404(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A set_active naming a definition that does not exist is 404."""
|
||||
response = await admin_questdefs.client.patch(
|
||||
"/api/v1/admin/quest-definitions/999/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Quest definition not found"
|
||||
|
||||
|
||||
async def test_set_active_malformed_body_is_422(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-bool is_active is rejected by the body model (422)."""
|
||||
response = await admin_questdefs.client.patch(
|
||||
"/api/v1/admin/quest-definitions/1/active",
|
||||
json={"is_active": "maybe"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Admin-plane settings route tests (task 2b3de7e5).
|
||||
|
||||
These tests exercise the admin plane's settings read and update 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 stored settings
|
||||
document is read back through the SAME core copy the app uses
|
||||
(``nestquest_core.*``; see the two-copy caveat in api/nestquest_core.py)
|
||||
via its :class:`nestquest_core.dao_meta.MetaStateDao`, so persistence is
|
||||
proven against the DATABASE the routes wrote, not a parallel copy.
|
||||
|
||||
Proven per the done-condition, everything read back through the API:
|
||||
|
||||
- ``GET /api/v1/admin/settings`` returns the current effective settings
|
||||
— the ten documented fields at their defaults on a fresh database.
|
||||
- ``PATCH /api/v1/admin/settings`` PERSISTS the supplied fields and the
|
||||
NEXT read reflects them; a later partial update merges onto the
|
||||
current effective settings (omitted fields keep their stored value),
|
||||
and the document is stored under the well-known ``nestquest_meta_state``
|
||||
key.
|
||||
- An invalid value (a non-positive or boolean horizon, a non-strict
|
||||
time, a non-bool toggle, a non-string target) is rejected with 422
|
||||
naming the field, and the stored settings are UNCHANGED — the next
|
||||
read still shows the previous value. An unknown field is rejected
|
||||
the same way, also when mixed with a valid field (validate
|
||||
everything before writing).
|
||||
- A corrupt stored document (invalid JSON, a non-object, an invalid
|
||||
field value) does not crash the service: the read falls back to the
|
||||
defaults, per field where possible.
|
||||
- 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 — and no refused patch writes anything.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
|
||||
# The meta-state DAO through the SAME core copy the app uses
|
||||
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy) —
|
||||
# used for the tests' database assertions only; the routes reach it
|
||||
# through api.nestquest_core.
|
||||
_dao_meta = importlib.import_module("nestquest_core.dao_meta")
|
||||
|
||||
#: The well-known ``nestquest_meta_state`` key the API stores its
|
||||
#: settings under — asserted literally so the contract is pinned here,
|
||||
#: independent of the core constant.
|
||||
SETTINGS_META_KEY = "settings"
|
||||
|
||||
#: The all-defaults effective settings a fresh database reports — the
|
||||
#: documented ten fields, asserted literally so the payload contract is
|
||||
#: pinned here, independent of the core constants.
|
||||
DEFAULT_SETTINGS = {
|
||||
"horizon_days": 14,
|
||||
"day_rollover_time": "00:00",
|
||||
"notify_target": "",
|
||||
"morning_summary_time": "08:00",
|
||||
"afternoon_reminder_time": "15:00",
|
||||
"end_of_day_report_time": "20:00",
|
||||
"morning_summary_enabled": True,
|
||||
"afternoon_reminder_enabled": True,
|
||||
"end_of_day_report_enabled": True,
|
||||
"celebration_enabled": True,
|
||||
}
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def settings_client(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database.
|
||||
|
||||
The same runner the children tests use (stubbed JWKS, no network);
|
||||
the fixture also exposes the app's ONE database connection so tests
|
||||
can read (or corrupt) the stored settings document itself.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# --- read: the effective settings, defaults on a fresh database --------
|
||||
|
||||
|
||||
async def test_get_settings_returns_defaults_on_a_fresh_database(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""GET returns the ten documented fields at their defaults."""
|
||||
response = await settings_client.client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == DEFAULT_SETTINGS
|
||||
|
||||
|
||||
# --- update: persists, and the NEXT read reflects it --------------------
|
||||
|
||||
|
||||
async def test_update_persists_and_the_next_read_reflects_it(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""PATCH persists the supplied fields; GET reads them back."""
|
||||
client = settings_client.client
|
||||
changes = {
|
||||
"horizon_days": 21,
|
||||
"day_rollover_time": "03:30",
|
||||
"notify_target": "notify.mobile_app_dads_phone",
|
||||
"celebration_enabled": False,
|
||||
}
|
||||
patched = await client.patch(
|
||||
"/api/v1/admin/settings", json=changes, headers=_admin_headers()
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
expected = {**DEFAULT_SETTINGS, **changes}
|
||||
assert patched.json() == expected
|
||||
|
||||
# The NEXT read reflects the persisted settings...
|
||||
reread = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert reread.status_code == 200
|
||||
assert reread.json() == expected
|
||||
|
||||
# ...and the DATABASE holds the merged document: the complete ten
|
||||
# fields, keyed by field name, under the well-known meta_state key.
|
||||
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
|
||||
SETTINGS_META_KEY
|
||||
)
|
||||
assert raw is not None
|
||||
assert json.loads(raw) == expected
|
||||
|
||||
|
||||
async def test_partial_update_merges_onto_the_current_settings(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A later PATCH changes only its own fields; the rest survive."""
|
||||
client = settings_client.client
|
||||
first = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"horizon_days": 7, "notify_target": " notify.x "},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
# The notify target went through the core's own normalizer: stripped.
|
||||
assert first.json()["horizon_days"] == 7
|
||||
assert first.json()["notify_target"] == "notify.x"
|
||||
|
||||
second = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"morning_summary_time": "07:45"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json() == {
|
||||
**DEFAULT_SETTINGS,
|
||||
"horizon_days": 7,
|
||||
"notify_target": "notify.x",
|
||||
"morning_summary_time": "07:45",
|
||||
}
|
||||
|
||||
reread = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert reread.json() == second.json()
|
||||
|
||||
|
||||
async def test_empty_update_is_a_noop(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""PATCHing no fields leaves the effective settings as they are."""
|
||||
client = settings_client.client
|
||||
response = await client.patch(
|
||||
"/api/v1/admin/settings", json={}, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == DEFAULT_SETTINGS
|
||||
|
||||
|
||||
async def test_null_resets_a_field_to_its_default(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A JSON ``null`` maps to the field's default (None-as-absent)."""
|
||||
client = settings_client.client
|
||||
set_horizon = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"horizon_days": 7},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert set_horizon.status_code == 200
|
||||
assert set_horizon.json()["horizon_days"] == 7
|
||||
|
||||
reset = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"horizon_days": None},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert reset.status_code == 200
|
||||
assert reset.json()["horizon_days"] == 14
|
||||
|
||||
reread = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert reread.json()["horizon_days"] == 14
|
||||
|
||||
|
||||
# --- validation: rejected values never change the stored settings -------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changes", "field"),
|
||||
[
|
||||
({"horizon_days": 0}, "horizon_days"),
|
||||
({"horizon_days": -3}, "horizon_days"),
|
||||
# A bool is rejected — the core's guardrail survives the route.
|
||||
({"horizon_days": True}, "horizon_days"),
|
||||
({"horizon_days": "soon"}, "horizon_days"),
|
||||
({"day_rollover_time": "25:00"}, "day_rollover_time"),
|
||||
({"day_rollover_time": "7:30"}, "day_rollover_time"),
|
||||
({"morning_summary_enabled": "yes"}, "morning_summary_enabled"),
|
||||
({"celebration_enabled": 1}, "celebration_enabled"),
|
||||
({"notify_target": 5}, "notify_target"),
|
||||
],
|
||||
)
|
||||
async def test_invalid_value_is_rejected_and_stored_settings_unchanged(
|
||||
settings_client: SimpleNamespace, changes: dict[str, object], field: str
|
||||
) -> None:
|
||||
"""A rejected value is 422 naming the field; the next read is same."""
|
||||
client = settings_client.client
|
||||
seeded = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={
|
||||
"horizon_days": 21,
|
||||
"day_rollover_time": "01:30",
|
||||
"morning_summary_enabled": False,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert seeded.status_code == 200
|
||||
before = seeded.json()
|
||||
|
||||
response = await client.patch(
|
||||
"/api/v1/admin/settings", json=changes, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422, changes
|
||||
assert field in str(response.json()["detail"]), changes
|
||||
|
||||
after = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert after.status_code == 200
|
||||
assert after.json() == before
|
||||
|
||||
|
||||
async def test_unknown_field_is_rejected_and_stored_settings_unchanged(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An unknown field is 422 naming it — alone or beside a valid one."""
|
||||
client = settings_client.client
|
||||
unknown = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"bogus_setting": 1},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert unknown.status_code == 422
|
||||
assert "bogus_setting" in str(unknown.json()["detail"])
|
||||
|
||||
mixed = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"horizon_days": 7, "bogus_setting": 1},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert mixed.status_code == 422
|
||||
assert "bogus_setting" in str(mixed.json()["detail"])
|
||||
|
||||
# Neither rejection wrote anything, not even the valid sibling.
|
||||
after = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert after.json() == DEFAULT_SETTINGS
|
||||
|
||||
|
||||
async def test_non_object_body_is_422(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A body that is not a JSON object is FastAPI's 422 (the shape)."""
|
||||
client = settings_client.client
|
||||
for body in ([1, 2], "nope", 5):
|
||||
response = await client.patch(
|
||||
"/api/v1/admin/settings", json=body, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422, body
|
||||
# A bare JSON null is likewise not an object.
|
||||
response = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
content=b"null",
|
||||
headers={**_admin_headers(), "content-type": "application/json"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
after = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert after.json() == DEFAULT_SETTINGS
|
||||
|
||||
|
||||
# --- resilience: a corrupt stored document does not crash the service ---
|
||||
|
||||
|
||||
async def test_corrupt_stored_document_falls_back_to_defaults(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""GET degrades gracefully over an unusable settings document."""
|
||||
client = settings_client.client
|
||||
dao = _dao_meta.MetaStateDao(settings_client.database)
|
||||
|
||||
# Not JSON at all: all defaults, no crash.
|
||||
await dao.set(SETTINGS_META_KEY, "{not-json")
|
||||
response = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == DEFAULT_SETTINGS
|
||||
|
||||
# JSON but not an object: all defaults, no crash.
|
||||
await dao.set(SETTINGS_META_KEY, json.dumps([1, 2]))
|
||||
response = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == DEFAULT_SETTINGS
|
||||
|
||||
# An object with one invalid field: PER-FIELD fallback — the valid
|
||||
# sibling survives, the invalid one falls back to its default.
|
||||
await dao.set(
|
||||
SETTINGS_META_KEY,
|
||||
json.dumps({"horizon_days": 7, "day_rollover_time": "soon"}),
|
||||
)
|
||||
response = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {**DEFAULT_SETTINGS, "horizon_days": 7}
|
||||
|
||||
|
||||
# --- authentication: the ONE require_admin dependency guards both routes
|
||||
|
||||
|
||||
async def test_settings_routes_refuse_non_admin_and_absent_credentials(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-admin JWT is 403, an absent credential is 401; none write."""
|
||||
client = settings_client.client
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
forbidden_get = await client.get(
|
||||
"/api/v1/admin/settings",
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden_get.status_code == 403
|
||||
assert forbidden_get.json()["detail"] == (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
forbidden_patch = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"horizon_days": 7},
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden_patch.status_code == 403
|
||||
|
||||
absent_get = await client.get("/api/v1/admin/settings")
|
||||
assert absent_get.status_code == 401
|
||||
absent_patch = await client.patch(
|
||||
"/api/v1/admin/settings", json={"horizon_days": 7}
|
||||
)
|
||||
assert absent_patch.status_code == 401
|
||||
|
||||
# No refused request changed anything: the settings are untouched.
|
||||
after = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert after.json() == DEFAULT_SETTINGS
|
||||
@@ -0,0 +1,694 @@
|
||||
"""Admin-plane uncomplete and regenerate route tests (task da0226b3).
|
||||
|
||||
These tests exercise the admin plane's completion-reversal and
|
||||
re-materialization 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 route's ONE clock read
|
||||
(:func:`api.routes_admin._local_now`) is pinned the same way the panel
|
||||
route's is, so seeded rows and route stamps cannot straddle midnight.
|
||||
|
||||
Every done-condition case is proven against the DATABASE, read through
|
||||
that same core copy:
|
||||
|
||||
- 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.
|
||||
- ``POST /api/v1/admin/instances/{id}/uncomplete``: the completion
|
||||
events log for the instance holds the ORIGINAL ``completed`` row
|
||||
UNTOUCHED plus ONE appended ``uncompleted`` row attributed to the
|
||||
verified token's ``sub`` (actor_source ``user``), and the instance's
|
||||
DERIVED state is ``open`` again; a second uncomplete is a no-op that
|
||||
appends nothing.
|
||||
- On an ACTUAL reversal the ``nestquest_quest_uncompleted`` transition
|
||||
arrives on the SSE stream exactly once (subscribed live as
|
||||
:mod:`tests.test_api_sse` does); a no-op uncomplete publishes
|
||||
nothing.
|
||||
- ``POST /api/v1/admin/regenerate`` re-materializes without
|
||||
duplicating: running it twice (household, definition and child
|
||||
scopes) leaves the instance count stable, and the scoped variants
|
||||
rebuild only their own scope's rows.
|
||||
|
||||
Error mapping (documented in api/routes_admin.py): an unknown
|
||||
instance is 404 (the core's ``instance_id:`` prefix, the same key the
|
||||
panel complete route uses); unknown definition/child scope ids are 404
|
||||
from the route's own existence reads; malformed input — a bad scope,
|
||||
a scope/id mismatch, a zero id, a non-integer path id — is 422 before
|
||||
any handler code runs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from api import routes_admin
|
||||
from api.app import create_app
|
||||
from api.auth import JwksCache
|
||||
from api.config import ApiConfig
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
AUDIENCE,
|
||||
ISSUER,
|
||||
JWKS_URL,
|
||||
KID,
|
||||
AdminRunner,
|
||||
StubbedFetch,
|
||||
SUBJECT,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
from tests.test_api_panel import PANEL_TOKEN, _seed_household
|
||||
from tests.test_api_sse import _EventStream, _Server, _stream
|
||||
|
||||
# The completion and instance-DAO layers through the SAME core copy the
|
||||
# app uses (nestquest_core.*, NOT the conftest-stubbed
|
||||
# custom_components copy) — used for the tests' database assertions
|
||||
# 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")
|
||||
|
||||
#: The documented quest-uncompleted event name (core const, §3).
|
||||
EVENT_QUEST_UNCOMPLETED = importlib.import_module(
|
||||
"nestquest_core.const"
|
||||
).EVENT_QUEST_UNCOMPLETED
|
||||
|
||||
|
||||
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 and the route's clock both anchor to this
|
||||
instant, 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")
|
||||
)
|
||||
|
||||
|
||||
async def _instance_count(database: object) -> int:
|
||||
"""The whole ``quest_instances`` row count (the sanctioned probe)."""
|
||||
row = await database.fetch_one("SELECT COUNT(*) FROM quest_instances")
|
||||
return row[0]
|
||||
|
||||
|
||||
async def _events_for_instance(
|
||||
database: object, instance_id: int
|
||||
) -> list:
|
||||
"""The instance's completion events, in append order."""
|
||||
dao = _dao_instances.CompletionEventsDao(database)
|
||||
return await dao.list_by_instance(instance_id)
|
||||
|
||||
|
||||
async def _derived_state(
|
||||
database: object, instance_id: int, today: datetime.date
|
||||
) -> str:
|
||||
"""The instance's derived state through the app's core copy."""
|
||||
dao = _dao_instances.QuestInstancesDao(database)
|
||||
instance = await dao.get_by_id(instance_id)
|
||||
assert instance is not None
|
||||
latest = await _dao_instances.CompletionEventsDao(
|
||||
database
|
||||
).get_latest_for_instance(instance_id)
|
||||
return _completion.derive_state(instance, latest, today)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_api(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database.
|
||||
|
||||
The same runner the auth and children tests use (stubbed JWKS, no
|
||||
network); the fixture exposes the app's ONE database connection so
|
||||
tests assert the routes' effects on the stored rows themselves.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_household(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
||||
"""A seeded household served by the admin-plane app, clock pinned.
|
||||
|
||||
``_seed_household`` completes Bo's today instance (17:00 due, 12:00
|
||||
pinned — on time), which is the completion the uncomplete tests
|
||||
reverse. The route's ``_local_now`` is pinned to the same instant.
|
||||
"""
|
||||
today, pinned_now = _pinned_noon()
|
||||
seed = await _seed_household(temp_db_path, today, pinned_now)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class _AdminServer(_Server):
|
||||
""":mod:`tests.test_api_sse`'s live-uvicorn server, admin-enabled.
|
||||
|
||||
Same ephemeral-port uvicorn thread (httpx's ASGI transport buffers
|
||||
SSE bodies, so a live server is the only way to stream), but the
|
||||
app factory swaps the JWKS cache for one built around the harness's
|
||||
stub fetcher, so a real admin JWT verifies with NO network access.
|
||||
"""
|
||||
|
||||
def _make_app_factory(self, db_path: str):
|
||||
"""The uvicorn app factory with the stubbed JWKS cache."""
|
||||
|
||||
def factory():
|
||||
app = create_app(
|
||||
ApiConfig(
|
||||
db_path=db_path,
|
||||
panel_token=PANEL_TOKEN,
|
||||
oidc_issuer=ISSUER,
|
||||
oidc_audience=AUDIENCE,
|
||||
oidc_jwks_url=JWKS_URL,
|
||||
)
|
||||
)
|
||||
app.state.jwks_cache = JwksCache(
|
||||
JWKS_URL, fetcher=StubbedFetch(jwks_for(ADMIN_KEY, KID))
|
||||
)
|
||||
self._server_loop = asyncio.get_running_loop()
|
||||
self.app = app
|
||||
return app
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_sse(temp_db_path: str) -> SimpleNamespace:
|
||||
"""A seeded household served by a live uvicorn app, clock pinned.
|
||||
|
||||
Same pinning discipline as the panel tests, applied to
|
||||
:func:`api.routes_admin._local_now` BEFORE the server starts (the
|
||||
app is created inside uvicorn's factory thread).
|
||||
"""
|
||||
today, pinned_now = _pinned_noon()
|
||||
seed = await _seed_household(temp_db_path, today, pinned_now)
|
||||
original = routes_admin._local_now
|
||||
routes_admin._local_now = lambda: pinned_now
|
||||
try:
|
||||
with _AdminServer(temp_db_path) as server:
|
||||
client = httpx.AsyncClient(base_url=server.url(""))
|
||||
async with client:
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
seed=seed,
|
||||
server=server,
|
||||
database=server.app.state.db.database,
|
||||
today=today,
|
||||
)
|
||||
finally:
|
||||
routes_admin._local_now = original
|
||||
|
||||
|
||||
# --- authentication: the ONE require_admin dependency guards every route ---
|
||||
|
||||
|
||||
async def test_uncomplete_and_regenerate_refuse_non_admin_and_absent(
|
||||
admin_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-admin JWT is 403, an absent credential is 401: nothing written."""
|
||||
client = admin_household.client
|
||||
database = admin_household.database
|
||||
brush = admin_household.seed.brush_teeth_instance
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
events_before = await _events_for_instance(database, brush.id)
|
||||
instances_before = await _instance_count(database)
|
||||
|
||||
uncomplete_url = f"/api/v1/admin/instances/{brush.id}/uncomplete"
|
||||
forbidden = await client.post(
|
||||
uncomplete_url,
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
assert forbidden.json()["detail"] == (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
unauthenticated = await client.post(uncomplete_url)
|
||||
assert unauthenticated.status_code == 401
|
||||
|
||||
regenerate_bodies = (
|
||||
{},
|
||||
{"scope": "household"},
|
||||
{"scope": "child", "child_id": 1},
|
||||
)
|
||||
for body in regenerate_bodies:
|
||||
forbidden = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden.status_code == 403, body
|
||||
unauthenticated = await client.post(
|
||||
"/api/v1/admin/regenerate", json=body
|
||||
)
|
||||
assert unauthenticated.status_code == 401, body
|
||||
|
||||
# No handler ran: the seeded completion is still the ONLY event for
|
||||
# the instance and the instance table is untouched.
|
||||
assert (
|
||||
await _events_for_instance(database, brush.id) == events_before
|
||||
)
|
||||
assert await _instance_count(database) == instances_before
|
||||
|
||||
|
||||
# --- uncomplete: the append-only reversal -----------------------------------
|
||||
|
||||
|
||||
async def test_uncomplete_appends_reversal_and_derived_state_is_open(
|
||||
admin_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A reversal APPENDS an uncompleted event; the instance is open again.
|
||||
|
||||
The original completed row is proven byte-identical after the call
|
||||
(frozen-dataclass equality), the new row carries the verified
|
||||
token's ``sub`` as the user actor, and the DERIVED state —
|
||||
recomputed from the latest event — is ``open`` again.
|
||||
"""
|
||||
client = admin_household.client
|
||||
database = admin_household.database
|
||||
brush = admin_household.seed.brush_teeth_instance
|
||||
today = admin_household.today
|
||||
|
||||
before = await _events_for_instance(database, brush.id)
|
||||
assert [event.event_type for event in before] == ["completed"]
|
||||
assert await _derived_state(database, brush.id, today) == "done"
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/admin/instances/{brush.id}/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body == {
|
||||
"instance_id": brush.id,
|
||||
"state": "open",
|
||||
"appended": True,
|
||||
}
|
||||
|
||||
events = await _events_for_instance(database, brush.id)
|
||||
assert len(events) == 2
|
||||
# The original completed row is UNTOUCHED — never edited, never
|
||||
# deleted (append-only completion_events).
|
||||
assert events[0] == before[0]
|
||||
assert events[0].event_type == "completed"
|
||||
assert events[0].actor_source == "panel"
|
||||
# The reversal is the NEW row, attributed to the verified token.
|
||||
reversal = events[1]
|
||||
assert reversal.event_type == "uncompleted"
|
||||
assert reversal.instance_id == brush.id
|
||||
assert reversal.child_id == brush.child_id
|
||||
assert reversal.actor_source == "user"
|
||||
assert reversal.actor_user_id == SUBJECT
|
||||
assert reversal.actor_child_id is None
|
||||
assert reversal.was_on_time is None
|
||||
# The derived state is recomputed from the latest event: open again.
|
||||
assert await _derived_state(database, brush.id, today) == "open"
|
||||
|
||||
|
||||
async def test_second_uncomplete_is_a_noop_that_appends_nothing(
|
||||
admin_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A repeat uncomplete appends NOTHING and still succeeds."""
|
||||
client = admin_household.client
|
||||
database = admin_household.database
|
||||
brush = admin_household.seed.brush_teeth_instance
|
||||
today = admin_household.today
|
||||
|
||||
first = await client.post(
|
||||
f"/api/v1/admin/instances/{brush.id}/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert first.json()["appended"] is True
|
||||
|
||||
second = await client.post(
|
||||
f"/api/v1/admin/instances/{brush.id}/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert second.status_code == 200
|
||||
body = second.json()
|
||||
assert body["appended"] is False
|
||||
assert body["state"] == "open"
|
||||
|
||||
events = await _events_for_instance(database, brush.id)
|
||||
assert len(events) == 2
|
||||
assert [event.event_type for event in events] == [
|
||||
"completed",
|
||||
"uncompleted",
|
||||
]
|
||||
assert await _derived_state(database, brush.id, today) == "open"
|
||||
|
||||
|
||||
async def test_uncomplete_unknown_instance_is_404_and_malformed_is_422(
|
||||
admin_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An unknown instance is 404; a non-integer path id is 422."""
|
||||
client = admin_household.client
|
||||
brush = admin_household.seed.brush_teeth_instance
|
||||
before = await _events_for_instance(
|
||||
admin_household.database, brush.id
|
||||
)
|
||||
|
||||
unknown = await client.post(
|
||||
"/api/v1/admin/instances/999/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert unknown.status_code == 404
|
||||
assert unknown.json()["detail"] == "Quest instance not found"
|
||||
|
||||
malformed = await client.post(
|
||||
"/api/v1/admin/instances/not-an-int/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert malformed.status_code == 422
|
||||
|
||||
# Neither rejection wrote anything.
|
||||
assert (
|
||||
await _events_for_instance(admin_household.database, brush.id)
|
||||
== before
|
||||
)
|
||||
|
||||
|
||||
async def test_uncomplete_already_open_past_due_reports_missed(
|
||||
admin_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A no-op uncomplete still reports the honest DERIVED state.
|
||||
|
||||
The seeded stale instance is open and past due: uncompleting it is
|
||||
a no-op (nothing appended) and the derived state is ``missed`` —
|
||||
open-but-past-due, never an event type.
|
||||
"""
|
||||
client = admin_household.client
|
||||
database = admin_household.database
|
||||
stale = admin_household.seed.stale_instance
|
||||
today = admin_household.today
|
||||
assert await _derived_state(database, stale.id, today) == "missed"
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/admin/instances/{stale.id}/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["appended"] is False
|
||||
assert body["state"] == "missed"
|
||||
|
||||
# The no-op appended nothing: the instance still has NO events.
|
||||
assert await _events_for_instance(database, stale.id) == []
|
||||
|
||||
|
||||
# --- the SSE transition on an actual reversal --------------------------------
|
||||
|
||||
|
||||
async def test_uncomplete_publishes_exactly_one_uncompleted_transition(
|
||||
admin_sse: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An actual reversal streams ONE quest-uncompleted event; a no-op none.
|
||||
|
||||
The stream is subscribed FIRST (test_api_sse's live-server reader),
|
||||
the completed instance is reversed through the admin route, and the
|
||||
documented payload fields are asserted; the no-op second call adds
|
||||
no frame.
|
||||
"""
|
||||
seed = admin_sse.seed
|
||||
brush = seed.brush_teeth_instance
|
||||
stream = _stream(admin_sse.server.url("/api/v1/panel/events"))
|
||||
async with stream:
|
||||
first = await admin_sse.client.post(
|
||||
f"/api/v1/admin/instances/{brush.id}/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert first.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"] # strict UTC ISO-8601 stamp
|
||||
assert "was_on_time" not in payload
|
||||
|
||||
# The no-op second call publishes nothing: the stream stays
|
||||
# silent past the window.
|
||||
second = await admin_sse.client.post(
|
||||
f"/api/v1/admin/instances/{brush.id}/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["appended"] is False
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(stream.queue.get(), timeout=0.3)
|
||||
|
||||
|
||||
# --- regenerate: re-materialize without duplicating --------------------------
|
||||
|
||||
|
||||
async def _create_child(client: object, name: str) -> int:
|
||||
"""Create one child through the admin children route."""
|
||||
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, assignee_ids: list[int]
|
||||
) -> int:
|
||||
"""Create one daily one-window definition for the given assignees."""
|
||||
created = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Tidy the den",
|
||||
"rule": {"rule_type": "daily", "start_date": "2026-03-02"},
|
||||
"assignee_child_ids": assignee_ids,
|
||||
"windows": ["morning"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
return created.json()["id"]
|
||||
|
||||
|
||||
async def test_regenerate_household_never_duplicates(
|
||||
admin_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Two household regenerations leave the instance count stable.
|
||||
|
||||
The first call re-materializes the rolling horizon
|
||||
``[today, today + DEFAULT_HORIZON_DAYS]``; the second walks the
|
||||
SAME tuples and upserts them in place — the row count never grows
|
||||
and the core reports the same upsert count both times.
|
||||
"""
|
||||
client = admin_household.client
|
||||
database = admin_household.database
|
||||
|
||||
before = await _instance_count(database)
|
||||
assert before > 0 # the seed already materialized a few days
|
||||
|
||||
first = await client.post(
|
||||
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
|
||||
)
|
||||
assert first.status_code == 200
|
||||
body = first.json()
|
||||
assert body["scope"] == "household"
|
||||
assert body["definition_id"] is None
|
||||
assert body["child_id"] is None
|
||||
assert body["count"] >= before
|
||||
after_first = await _instance_count(database)
|
||||
assert after_first >= before
|
||||
|
||||
second = await client.post(
|
||||
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["count"] == body["count"]
|
||||
# The idempotent walk: the second run's row count is EXACTLY the
|
||||
# first's — no duplicated instances.
|
||||
assert await _instance_count(database) == after_first
|
||||
|
||||
|
||||
async def test_regenerate_definition_scope_rematerializes(
|
||||
admin_api: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The definition scope rebuilds that definition's horizon, twice, stably.
|
||||
|
||||
Creating a definition through the admin route materializes NOTHING
|
||||
(the core's create path does not regenerate); the scoped regenerate
|
||||
call is what materializes its instances, and re-running it leaves
|
||||
the row count exactly where the first run left it.
|
||||
"""
|
||||
client = admin_api.client
|
||||
database = admin_api.database
|
||||
ada = await _create_child(client, "Ada")
|
||||
definition_id = await _create_daily_definition(client, [ada])
|
||||
|
||||
assert await _instance_count(database) == 0
|
||||
|
||||
first = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
json={"scope": "definition", "definition_id": definition_id},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
body = first.json()
|
||||
assert body["scope"] == "definition"
|
||||
assert body["definition_id"] == definition_id
|
||||
assert body["child_id"] is None
|
||||
assert body["count"] > 0
|
||||
after_first = await _instance_count(database)
|
||||
assert after_first == body["count"]
|
||||
|
||||
second = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
json={"scope": "definition", "definition_id": definition_id},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["count"] == body["count"]
|
||||
assert await _instance_count(database) == after_first
|
||||
|
||||
|
||||
async def test_regenerate_child_scope_touches_only_that_child(
|
||||
admin_api: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The child scope rebuilds that child's rows and no others'.
|
||||
|
||||
Two children share one daily definition; the household walk
|
||||
materializes both. A child-scoped regenerate for Ada re-runs her
|
||||
side of the walk (same count) while Bo's rows keep their identity,
|
||||
and re-running it never grows the table.
|
||||
"""
|
||||
client = admin_api.client
|
||||
database = admin_api.database
|
||||
instances = _dao_instances.QuestInstancesDao(database)
|
||||
ada = await _create_child(client, "Ada")
|
||||
bo = await _create_child(client, "Bo")
|
||||
# One daily definition assigned to BOTH children: the household walk
|
||||
# gives each child its own rows.
|
||||
await _create_daily_definition(client, [ada, bo])
|
||||
|
||||
# Materialize the household first so BOTH children have rows.
|
||||
seeded = await client.post(
|
||||
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
|
||||
)
|
||||
assert seeded.status_code == 200
|
||||
assert seeded.json()["scope"] == "household"
|
||||
|
||||
ada_rows = await instances.list_by_date_range(
|
||||
ada, "2026-03-02", "2100-01-01"
|
||||
)
|
||||
bo_rows = await instances.list_by_date_range(
|
||||
bo, "2026-03-02", "2100-01-01"
|
||||
)
|
||||
assert ada_rows and bo_rows
|
||||
total = await _instance_count(database)
|
||||
assert total == len(ada_rows) + len(bo_rows)
|
||||
|
||||
for expected_count in (len(ada_rows), len(ada_rows)):
|
||||
response = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
json={"scope": "child", "child_id": ada},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body == {
|
||||
"scope": "child",
|
||||
"definition_id": None,
|
||||
"child_id": ada,
|
||||
"count": expected_count,
|
||||
}
|
||||
# Ada's side re-materialized the same number of rows and Bo's
|
||||
# rows kept their identity; the table never grew.
|
||||
assert await instances.list_by_date_range(
|
||||
ada, "2026-03-02", "2100-01-01"
|
||||
)
|
||||
assert await instances.list_by_date_range(
|
||||
bo, "2026-03-02", "2100-01-01"
|
||||
) == bo_rows
|
||||
assert await _instance_count(database) == total
|
||||
|
||||
|
||||
async def test_regenerate_unknown_scope_ids_are_404(
|
||||
admin_api: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An unknown definition or child scope id is 404, nothing written."""
|
||||
client = admin_api.client
|
||||
|
||||
unknown_definition = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
json={"scope": "definition", "definition_id": 999},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert unknown_definition.status_code == 404
|
||||
assert unknown_definition.json()["detail"] == "Quest definition not found"
|
||||
|
||||
unknown_child = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
json={"scope": "child", "child_id": 999},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert unknown_child.status_code == 404
|
||||
assert unknown_child.json()["detail"] == "Child not found"
|
||||
|
||||
assert await _instance_count(admin_api.database) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
{"scope": "galaxy"}, # unknown scope
|
||||
{"scope": "definition"}, # scope without its id
|
||||
{"scope": "child"}, # scope without its id
|
||||
{"scope": "household", "child_id": 1}, # id without its scope
|
||||
{"scope": "household", "definition_id": 1}, # id without its scope
|
||||
{
|
||||
"scope": "definition",
|
||||
"definition_id": 1,
|
||||
"child_id": 2,
|
||||
}, # scope mismatch
|
||||
{"scope": "child", "child_id": 0}, # zero id
|
||||
{"scope": "child", "child_id": "seven"}, # wrong type
|
||||
],
|
||||
)
|
||||
async def test_regenerate_malformed_bodies_are_422(
|
||||
admin_api: SimpleNamespace, body: dict[str, object]
|
||||
) -> None:
|
||||
"""Malformed scope/id combinations are 422 before any core call."""
|
||||
response = await admin_api.client.post(
|
||||
"/api/v1/admin/regenerate", json=body, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422, body
|
||||
assert await _instance_count(admin_api.database) == 0
|
||||
@@ -0,0 +1,355 @@
|
||||
"""FastAPI skeleton tests: health, OpenAPI, core wiring (task 042dfc5c).
|
||||
|
||||
These tests start the NestQuest API app with httpx against the ASGI
|
||||
app and assert the skeleton contract of Feature 16's first task:
|
||||
|
||||
- ``GET /health`` returns 200 with a JSON status body (``status: ok``)
|
||||
and does NOT leak the filesystem path;
|
||||
- ``GET /docs`` (Swagger UI) is served, and ``GET /openapi.json``
|
||||
returns the OpenAPI document;
|
||||
- the app wires core (opens the configured SQLite file and applies
|
||||
migrations on startup via the lifespan) and closes the connection on
|
||||
shutdown (``connected is False`` afterwards);
|
||||
- ``ApiConfig.from_env`` raises when ``NESTQUEST_DB_PATH`` is missing or
|
||||
empty, and returns the value when set;
|
||||
- a startup failure (unwritable path / non-SQLite file) surfaces from
|
||||
the lifespan rather than the first request;
|
||||
- each test is hermetic — it points ``NESTQUEST_DB_PATH`` at a fresh
|
||||
temp file.
|
||||
|
||||
A SEPARATE subprocess test asserts the stronger invariant the task
|
||||
calls out: the app imports the bundled ``core`` WITHOUT importing
|
||||
``homeassistant``. That cannot run inside the pytest process because
|
||||
``tests/conftest.py`` installs mock ``homeassistant`` modules globally
|
||||
before any test imports the integration; a subprocess with a clean
|
||||
``sys.modules`` is the only honest proof (the same approach
|
||||
``tests/test_core_no_ha_smoke.py`` takes for the core bundle).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from api.app import create_app
|
||||
from api.config import ApiConfig, ConfigError
|
||||
from api.database import DatabaseState, _executor, make_database
|
||||
|
||||
#: The repo root, so the no-HA subprocess can import the API package.
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
#: The marker the no-HA subprocess prints on success.
|
||||
_SUCCESS_MARKER = "API NO-HA IMPORT OK"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path: Path) -> str:
|
||||
"""A fresh per-test SQLite path under pytest's tmp_path."""
|
||||
return str(tmp_path / "nestquest.db")
|
||||
|
||||
|
||||
class _AppRunner:
|
||||
"""Run the app's lifespan around an httpx AsyncClient.
|
||||
|
||||
httpx's :class:`ASGITransport` does NOT run the ASGI lifespan on its
|
||||
own, so the database would never open. Starlette exposes the app's
|
||||
lifespan as ``app.router.lifespan_context``; wrapping the client
|
||||
session in it drives the same startup/shutdown path uvicorn would,
|
||||
so ``open_and_migrate`` runs before the first request and ``close``
|
||||
runs after the last.
|
||||
|
||||
``__aexit__`` uses try/finally so a client-side error still shuts
|
||||
the lifespan down — otherwise a failure mid-test would leak the
|
||||
database connection open.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str) -> None:
|
||||
# The token and OIDC values are irrelevant to these tests (the
|
||||
# health route is unauthenticated); they just satisfy the
|
||||
# required fields — the panel token's own tests live in
|
||||
# test_api_panel.py and the OIDC settings' in
|
||||
# test_api_admin_auth.py.
|
||||
self.app = create_app(
|
||||
ApiConfig(
|
||||
db_path=db_path,
|
||||
panel_token="health-runner-token",
|
||||
oidc_issuer="https://authentik.example.com/application/o/nestquest/",
|
||||
oidc_audience="nestquest-api",
|
||||
oidc_jwks_url=(
|
||||
"https://authentik.example.com/application/o/nestquest/jwks/"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> httpx.AsyncClient:
|
||||
self._lifespan = self.app.router.lifespan_context(self.app)
|
||||
await self._lifespan.__aenter__()
|
||||
self._client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=self.app),
|
||||
base_url="http://testserver",
|
||||
)
|
||||
return await self._client.__aenter__()
|
||||
|
||||
async def __aexit__(self, *exc) -> None:
|
||||
try:
|
||||
await self._client.__aexit__(*exc)
|
||||
finally:
|
||||
await self._lifespan.__aexit__(*exc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def app_client(temp_db_path: str) -> httpx.AsyncClient:
|
||||
async with _AppRunner(temp_db_path) as client:
|
||||
yield client
|
||||
|
||||
|
||||
# --- ApiConfig.from_env -------------------------------------------------
|
||||
|
||||
|
||||
def test_from_env_returns_set_value() -> None:
|
||||
"""Set, non-empty env values are returned verbatim."""
|
||||
cfg = ApiConfig.from_env(
|
||||
{
|
||||
"NESTQUEST_DB_PATH": "/data/nq.db",
|
||||
"NESTQUEST_PANEL_TOKEN": "panel-token",
|
||||
"NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/",
|
||||
"NESTQUEST_OIDC_AUDIENCE": "nestquest-api",
|
||||
"NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/",
|
||||
}
|
||||
)
|
||||
assert cfg.db_path == "/data/nq.db"
|
||||
assert cfg.panel_token == "panel-token"
|
||||
|
||||
|
||||
def test_from_env_raises_on_empty_value() -> None:
|
||||
"""An empty/whitespace-only value raises ConfigError (no silent default)."""
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env({"NESTQUEST_DB_PATH": " "})
|
||||
|
||||
|
||||
def test_from_env_raises_on_missing_value() -> None:
|
||||
"""A missing NESTQUEST_DB_PATH raises ConfigError (no silent default)."""
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env({})
|
||||
|
||||
|
||||
# --- health / openapi / docs -------------------------------------------
|
||||
|
||||
|
||||
async def test_health_returns_200_with_status_body(
|
||||
app_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""GET /health returns 200 with status ok and no filesystem path."""
|
||||
response = await app_client.get("/health")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body == {"status": "ok"}
|
||||
assert "db_path" not in body
|
||||
|
||||
|
||||
async def test_openapi_document_is_served(
|
||||
app_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""GET /openapi.json returns the OpenAPI document."""
|
||||
response = await app_client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
schema = response.json()
|
||||
assert schema["openapi"].startswith("3.")
|
||||
assert "/health" in schema["paths"]
|
||||
|
||||
|
||||
async def test_docs_swagger_ui_is_served(app_client: httpx.AsyncClient) -> None:
|
||||
"""GET /docs serves the Swagger UI HTML (OpenAPI at /docs)."""
|
||||
response = await app_client.get("/docs")
|
||||
assert response.status_code == 200
|
||||
assert "swagger" in response.text.lower()
|
||||
|
||||
|
||||
# --- core wiring & shutdown --------------------------------------------
|
||||
|
||||
|
||||
async def test_app_wires_core_and_applies_migrations(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""The lifespan opens the SQLite file and applies migrations.
|
||||
|
||||
After startup, the file exists and carries the NestQuest schema
|
||||
version stamp — proving the app imported core, opened the database
|
||||
at the configured path, and ran the migration runner.
|
||||
"""
|
||||
async with _AppRunner(temp_db_path):
|
||||
assert Path(temp_db_path).exists()
|
||||
# Re-open the file with a fresh core database to read the version
|
||||
# stamp independent of the app's connection. Reuse the API's own
|
||||
# executor so the test exercises the same wiring the app does.
|
||||
from api.nestquest_core import core_db, core_migrations
|
||||
|
||||
database = core_db.NestQuestDatabase(_executor)
|
||||
await database.open(temp_db_path)
|
||||
try:
|
||||
version = await core_migrations.read_schema_version(database)
|
||||
finally:
|
||||
await database.close()
|
||||
assert version >= 1
|
||||
|
||||
|
||||
async def test_shutdown_closes_the_connection(temp_db_path: str) -> None:
|
||||
"""The lifespan closes the database connection on shutdown.
|
||||
|
||||
After the runner exits, ``DatabaseState.database.connected`` is
|
||||
False — proving ``close()`` ran (the lifespan's finally block) even
|
||||
on a clean exit.
|
||||
"""
|
||||
runner = _AppRunner(temp_db_path)
|
||||
async with runner as _client:
|
||||
state: DatabaseState = runner.app.state.db
|
||||
assert state.database.connected
|
||||
assert not state.database.connected
|
||||
|
||||
|
||||
# --- startup failure ----------------------------------------------------
|
||||
|
||||
|
||||
async def test_startup_fails_on_unwritable_path(tmp_path: Path) -> None:
|
||||
"""A database path whose parent directory does not exist fails fast.
|
||||
|
||||
``sqlite3.connect`` raises ``OperationalError`` for a path under a
|
||||
non-existent directory; the lifespan surfaces that as a startup
|
||||
error rather than the first request. The connection is closed
|
||||
through the lifespan's finally block (``close`` is a no-op on a
|
||||
never-opened wrapper).
|
||||
"""
|
||||
bad_path = str(tmp_path / "does_not_exist" / "nestquest.db")
|
||||
runner = _AppRunner(bad_path)
|
||||
with pytest.raises(Exception):
|
||||
async with runner:
|
||||
pass
|
||||
|
||||
|
||||
async def test_startup_fails_on_non_sqlite_file(tmp_path: Path) -> None:
|
||||
"""A path pointing at a non-SQLite file fails fast on startup.
|
||||
|
||||
Opening an existing file that is not a SQLite database makes the
|
||||
``PRAGMA journal_mode=WAL`` run during :meth:`open` raise
|
||||
``DatabaseError: file is not a database``; the lifespan surfaces it.
|
||||
"""
|
||||
bad_file = tmp_path / "not_a_db.bin"
|
||||
bad_file.write_bytes(b"this is not a sqlite database")
|
||||
runner = _AppRunner(str(bad_file))
|
||||
with pytest.raises(Exception):
|
||||
async with runner:
|
||||
pass
|
||||
|
||||
|
||||
# --- no-HA subprocess ---------------------------------------------------
|
||||
|
||||
#: The no-HA subprocess script. Runs with ``sys.executable -c`` so it
|
||||
#: starts with a clean ``sys.modules`` and no ``homeassistant`` installed
|
||||
#: (the suite's conftest stubs are not present in a subprocess). It
|
||||
#: builds the app — which triggers the core bootstrap — and asserts
|
||||
#: neither ``homeassistant`` nor ``custom_components`` entered
|
||||
#: ``sys.modules``, and that the loaded core package's ``__file__``
|
||||
#: points at the bundled directory.
|
||||
_NO_HA_SCRIPT = textwrap.dedent(
|
||||
"""\
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
REPO_ROOT = Path(sys.argv[1])
|
||||
|
||||
# homeassistant must not be present before we touch the API.
|
||||
assert "homeassistant" not in sys.modules, (
|
||||
"homeassistant already in sys.modules before app import — the "
|
||||
"subprocess is not a clean no-HA process"
|
||||
)
|
||||
|
||||
from api.app import create_app
|
||||
from api.config import ApiConfig
|
||||
from api.nestquest_core import core
|
||||
|
||||
# The app build imports core via api.nestquest_core. core is a
|
||||
# Home-Assistant-free package; if it (or its loader) ever drags HA
|
||||
# in, this fails.
|
||||
assert "homeassistant" not in sys.modules, (
|
||||
"homeassistant imported while building the NestQuest API app — "
|
||||
"the core wiring is no longer Home-Assistant-free"
|
||||
)
|
||||
# The loader registers core under the distinct name "nestquest_core"
|
||||
# WITHOUT executing custom_components.nestquest.__init__, so the
|
||||
# parent integration package must never be registered, and no
|
||||
# sys.path mutation places the integration directory on sys.path.
|
||||
assert "custom_components" not in sys.modules, (
|
||||
"custom_components registered in sys.modules — the core loader "
|
||||
"executed the integration's HA-coupled __init__ instead of loading "
|
||||
"core by file location"
|
||||
)
|
||||
assert "nestquest_core" in sys.modules, "core was not imported"
|
||||
|
||||
# core.__file__ must point at the bundled directory inside the
|
||||
# integration, proving the file-location loader resolved against the
|
||||
# bundled package rather than some other "core" on sys.path.
|
||||
expected_core_dir = (
|
||||
REPO_ROOT / "custom_components" / "nestquest" / "core"
|
||||
)
|
||||
assert Path(core.__file__).resolve() == (
|
||||
expected_core_dir / "__init__.py"
|
||||
).resolve(), (
|
||||
f"core.__file__={core.__file__} does not point at the bundled "
|
||||
f"directory {expected_core_dir}"
|
||||
)
|
||||
|
||||
# Build the app (the lifespan is NOT run here, so the path is never
|
||||
# opened — it just needs to be a non-empty string to satisfy the
|
||||
# fail-fast config check). Use a temp path so nothing is created;
|
||||
# the token and OIDC values are irrelevant to this import-only check.
|
||||
app = create_app(ApiConfig(
|
||||
db_path=str(Path(tempfile.gettempdir()) / "nq-noha-buildonly.db"),
|
||||
panel_token="noha-import-token",
|
||||
oidc_issuer="https://authentik.example.com/application/o/nestquest/",
|
||||
oidc_audience="nestquest-api",
|
||||
oidc_jwks_url=(
|
||||
"https://authentik.example.com/application/o/nestquest/jwks/"
|
||||
),
|
||||
))
|
||||
assert app is not None
|
||||
|
||||
print("API NO-HA IMPORT OK")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_app_imports_core_without_importing_homeassistant() -> None:
|
||||
"""The app imports core without importing homeassistant (subprocess).
|
||||
|
||||
Runs in a subprocess because ``tests/conftest.py`` installs mock
|
||||
``homeassistant`` modules into the pytest process's ``sys.modules``
|
||||
before any test runs, so an in-process check could never
|
||||
distinguish "core never touched HA" from "conftest already stubbed
|
||||
HA for us". The subprocess is a genuine no-HA process; if the
|
||||
core loader (or core itself) ever grows a ``homeassistant``
|
||||
import, the subprocess exits non-zero with ``ModuleNotFoundError``.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", _NO_HA_SCRIPT, str(_REPO_ROOT)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AssertionError(
|
||||
f"no-HA app-import subprocess exited {result.returncode}.\n"
|
||||
f"--- stdout ---\n{result.stdout}\n"
|
||||
f"--- stderr ---\n{result.stderr}"
|
||||
)
|
||||
assert _SUCCESS_MARKER in result.stdout, (
|
||||
f"success marker {_SUCCESS_MARKER!r} not printed.\n"
|
||||
f"--- stdout ---\n{result.stdout}\n"
|
||||
f"--- stderr ---\n{result.stderr}"
|
||||
)
|
||||
@@ -0,0 +1,671 @@
|
||||
"""Admin-plane missed-sweep trigger tests (task 058c7b69).
|
||||
|
||||
These tests exercise ``POST /api/v1/admin/missed-sweep`` — the admin
|
||||
plane's trigger for the nightly missed-quest sweep — against a REAL
|
||||
server (uvicorn on an ephemeral port, the SSE tests' documented
|
||||
approach: httpx's ASGI transport buffers whole bodies, so a live
|
||||
stream needs a live server), plus the API service's daily scheduler:
|
||||
|
||||
- The trigger requires the router's ONE ``require_admin`` check: a
|
||||
non-admin JWT is 403 and an absent credential is 401, and neither
|
||||
refusal runs the sweep.
|
||||
- Seeded past-due open instances: ONE ``nestquest_quest_missed``
|
||||
transition per instance arrives on the LIVE SSE stream with the
|
||||
documented payload fields (child_id, child_name, instance_id,
|
||||
quest_title, window, due_date, due_time, occurred_at — §3), while
|
||||
the seeded TODAY-due instance publishes nothing (the sweep's rule
|
||||
is ``due_date`` strictly before ``today``).
|
||||
- A same-day rerun publishes nothing (the core watermark).
|
||||
- A run after downtime (``today`` advanced two days) publishes the
|
||||
accumulated window once — the instance due on day one fires on the
|
||||
catch-up run — and a further rerun is silent again.
|
||||
- Publishing is the api/scheduler.py shape — the sweep-BUILT
|
||||
``(event_type, payload)`` pairs go straight to the publisher, no
|
||||
per-event re-fetch: all events of one run share the run's ONE
|
||||
``occurred_at`` stamp, and a deterministic concurrent-deletion case
|
||||
(the sweep is wrapped to delete one swept instance after it built
|
||||
its events and committed its watermark) still answers 200 and
|
||||
streams every built event — a re-fetch there would 500 and lose the
|
||||
missed event forever.
|
||||
- The daily scheduler (:class:`api.scheduler.MissedSweepScheduler`)
|
||||
computes the delay to the configured ``day_rollover_time``, runs the
|
||||
sweep when the (injectable) clock reaches it, publishes the built
|
||||
events on its publisher, advances to the NEXT day's rollover, and
|
||||
cancels cleanly.
|
||||
|
||||
The household is seeded through the SAME core copy the app uses (the
|
||||
``nestquest_core.*`` package registered by ``api/nestquest_core.py``;
|
||||
see the two-copy caveat there) and the sweep's watermark is read back
|
||||
through the same copy's :class:`~nestquest_core.dao_meta.MetaStateDao`
|
||||
— on the SERVER's event loop for the uvicorn-hosted app (the database
|
||||
belongs to that thread's loop, so assertions schedule there), on the
|
||||
test's own loop for the scheduler test's standalone database.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import importlib
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import uvicorn
|
||||
|
||||
from api import routes_admin
|
||||
from api.app import create_app
|
||||
from api.auth import JwksCache
|
||||
from api.config import ApiConfig
|
||||
from api.database import _executor
|
||||
from api.nestquest_core import core_db as _core_db
|
||||
from api.nestquest_core import core_migrations as _core_migrations
|
||||
from api.scheduler import MissedSweepScheduler
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
AUDIENCE,
|
||||
ISSUER,
|
||||
JWKS_URL,
|
||||
KID,
|
||||
StubbedFetch,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
from tests.test_api_panel import PANEL_TOKEN
|
||||
from tests.test_api_sse import EVENT_QUEST_MISSED, READ_TIMEOUT, _free_port, _stream
|
||||
|
||||
# The bundled core submodules, through the SAME copy the app uses
|
||||
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy).
|
||||
_children = importlib.import_module("nestquest_core.children")
|
||||
_quest_definitions = importlib.import_module(
|
||||
"nestquest_core.quest_definitions"
|
||||
)
|
||||
_materialize = importlib.import_module("nestquest_core.materialize")
|
||||
_recurrence = importlib.import_module("nestquest_core.recurrence")
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
_dao_meta = importlib.import_module("nestquest_core.dao_meta")
|
||||
_settings_store = importlib.import_module("nestquest_core.settings_store")
|
||||
_core_sweep = importlib.import_module("nestquest_core.sweep")
|
||||
|
||||
#: The well-known ``nestquest_meta_state`` key holding the sweep
|
||||
#: watermark — asserted literally so the contract is pinned here.
|
||||
SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date"
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
async def _seed_missed_household(
|
||||
db_path: str, today: datetime.date
|
||||
) -> SimpleNamespace:
|
||||
"""Seed the missed-sweep household on ``db_path`` and return records.
|
||||
|
||||
Two children; two quests whose ONLY instances are due YESTERDAY
|
||||
(created via ``materialize`` with ``today`` pinned to yesterday —
|
||||
the no-past guard accepts the rows, and they land past-due with no
|
||||
completion events: the sweep's targets), and one quest due TODAY
|
||||
(not past-due: the sweep must NOT announce it). The yesterday
|
||||
rules carry ``end_date`` so the later today-walk cannot give them
|
||||
a second instance. Opens, migrates, seeds and CLOSES its own
|
||||
connection so the app's lifespan connection never overlaps it.
|
||||
"""
|
||||
database = _core_db.NestQuestDatabase(_executor)
|
||||
await database.open(db_path)
|
||||
try:
|
||||
await _core_migrations.apply_migrations(database)
|
||||
today_iso = today.isoformat()
|
||||
yesterday = today - datetime.timedelta(days=1)
|
||||
yesterday_iso = yesterday.isoformat()
|
||||
|
||||
ada = await _children.create_child(database, "Ada", sort_order=0)
|
||||
bo = await _children.create_child(database, "Bo", sort_order=1)
|
||||
|
||||
yesterday_rule = _recurrence.ScheduleRule.from_dict(
|
||||
{
|
||||
"rule_type": "daily",
|
||||
"start_date": yesterday_iso,
|
||||
"end_date": yesterday_iso,
|
||||
}
|
||||
)
|
||||
await _quest_definitions.create_quest_definition(
|
||||
database,
|
||||
"Stale chore",
|
||||
yesterday_rule,
|
||||
[ada.id],
|
||||
[("morning", "09:00")],
|
||||
)
|
||||
await _quest_definitions.create_quest_definition(
|
||||
database,
|
||||
"Old chore",
|
||||
yesterday_rule,
|
||||
[bo.id],
|
||||
[("evening", "18:30")],
|
||||
)
|
||||
# Past-due open instances for the two yesterday quests (the
|
||||
# walk pins ``today`` to yesterday so the rows are accepted).
|
||||
await _materialize.materialize(
|
||||
database, yesterday_iso, yesterday_iso, today=yesterday
|
||||
)
|
||||
# Today's quest: created after the yesterday walk so it has no
|
||||
# past-due instance, then materialized for today only.
|
||||
await _quest_definitions.create_quest_definition(
|
||||
database,
|
||||
"Recoverable chore",
|
||||
_recurrence.ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("afternoon", "17:00")],
|
||||
)
|
||||
await _materialize.materialize(
|
||||
database, today_iso, today_iso, today=today
|
||||
)
|
||||
|
||||
instances = _dao_instances.QuestInstancesDao(database)
|
||||
ada_yesterday = await instances.list_by_child_and_date(
|
||||
ada.id, yesterday_iso
|
||||
)
|
||||
assert len(ada_yesterday) == 1
|
||||
stale = ada_yesterday[0]
|
||||
bo_yesterday = await instances.list_by_child_and_date(
|
||||
bo.id, yesterday_iso
|
||||
)
|
||||
assert len(bo_yesterday) == 1
|
||||
old = bo_yesterday[0]
|
||||
ada_today = await instances.list_by_child_and_date(ada.id, today_iso)
|
||||
assert len(ada_today) == 1
|
||||
recoverable = ada_today[0]
|
||||
return SimpleNamespace(
|
||||
ada=ada,
|
||||
bo=bo,
|
||||
stale_instance=stale,
|
||||
old_instance=old,
|
||||
recoverable_instance=recoverable,
|
||||
yesterday_iso=yesterday_iso,
|
||||
today_iso=today_iso,
|
||||
)
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
class _AdminServer:
|
||||
"""Run the app with uvicorn on an ephemeral port, in a thread.
|
||||
|
||||
The SSE tests' documented approach (see tests/test_api_sse.py):
|
||||
uvicorn's factory mode runs ``create_app`` INSIDE the server
|
||||
thread, so the lifespan — database, publisher, the daily sweep
|
||||
scheduler — belongs to that thread's event loop. The app's JWKS
|
||||
cache is swapped for one built around the stubbed fetcher BEFORE
|
||||
the server starts, so admin JWTs genuinely verify with no network.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str) -> None:
|
||||
self.port = _free_port()
|
||||
self._server_loop: asyncio.AbstractEventLoop | None = None
|
||||
self._server = uvicorn.Server(
|
||||
uvicorn.Config(
|
||||
self._make_app_factory(db_path),
|
||||
host="127.0.0.1",
|
||||
port=self.port,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
self._thread = threading.Thread(target=self._server.run, daemon=True)
|
||||
|
||||
def _make_app_factory(self, db_path: str):
|
||||
"""The uvicorn app factory, closing over ``db_path``."""
|
||||
|
||||
def factory():
|
||||
app = create_app(
|
||||
ApiConfig(
|
||||
db_path=db_path,
|
||||
panel_token=PANEL_TOKEN,
|
||||
oidc_issuer=ISSUER,
|
||||
oidc_audience=AUDIENCE,
|
||||
oidc_jwks_url=JWKS_URL,
|
||||
)
|
||||
)
|
||||
app.state.jwks_cache = JwksCache(
|
||||
JWKS_URL, fetcher=StubbedFetch(jwks_for(ADMIN_KEY, KID))
|
||||
)
|
||||
self._server_loop = asyncio.get_running_loop()
|
||||
self.app = app
|
||||
return app
|
||||
|
||||
return factory
|
||||
|
||||
def __enter__(self) -> "_AdminServer":
|
||||
self._thread.start()
|
||||
deadline = time.monotonic() + READ_TIMEOUT
|
||||
while not self._server.started:
|
||||
if time.monotonic() > deadline or not self._thread.is_alive():
|
||||
raise RuntimeError("uvicorn did not start")
|
||||
time.sleep(0.01)
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self._server.should_exit = True
|
||||
self._thread.join(timeout=READ_TIMEOUT)
|
||||
|
||||
def url(self, path: str) -> str:
|
||||
"""The full URL for ``path`` on this server."""
|
||||
return f"http://127.0.0.1:{self.port}{path}"
|
||||
|
||||
def run_on_loop(self, coro) -> object:
|
||||
"""Run ``coro`` on the SERVER's event loop and wait for it.
|
||||
|
||||
The app (and its database) lives on the uvicorn thread's loop,
|
||||
so database assertions schedule there. Bounded by
|
||||
READ_TIMEOUT so a wedged schedule fails instead of hanging.
|
||||
"""
|
||||
return asyncio.run_coroutine_threadsafe(
|
||||
coro, self._server_loop
|
||||
).result(timeout=READ_TIMEOUT)
|
||||
|
||||
|
||||
def _watermark(server: _AdminServer) -> str | None:
|
||||
"""The stored sweep watermark, read on the server's own loop."""
|
||||
database = server.app.state.db.database
|
||||
return server.run_on_loop(
|
||||
_dao_meta.MetaStateDao(database).get(SWEEP_WATERMARK_KEY)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path: Path) -> str:
|
||||
"""A fresh per-test SQLite path under pytest's tmp_path."""
|
||||
return str(tmp_path / "nestquest.db")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def missed_app(temp_db_path: str) -> SimpleNamespace:
|
||||
"""The seeded household served by a live uvicorn app, clock pinned.
|
||||
|
||||
``routes_admin._local_now`` is pinned to today 12:00 (the same
|
||||
pinning discipline the panel/SSE tests use) BEFORE the server
|
||||
starts, so the trigger route's ``today`` matches the seed; the
|
||||
downtime test moves the pin forward mid-test. Restored in
|
||||
``finally`` even on failure.
|
||||
"""
|
||||
now_local = datetime.datetime.now().astimezone()
|
||||
pinned_now = now_local.replace(hour=12, minute=0, second=0, microsecond=0)
|
||||
today = pinned_now.date()
|
||||
seed = await _seed_missed_household(temp_db_path, today)
|
||||
original = routes_admin._local_now
|
||||
routes_admin._local_now = lambda: pinned_now
|
||||
try:
|
||||
with _AdminServer(temp_db_path) as server:
|
||||
client = httpx.AsyncClient(base_url=server.url(""))
|
||||
async with client:
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
seed=seed,
|
||||
server=server,
|
||||
today=today,
|
||||
pinned_now=pinned_now,
|
||||
)
|
||||
finally:
|
||||
routes_admin._local_now = original
|
||||
|
||||
|
||||
# --- the trigger: one missed transition per past-due open instance -----
|
||||
|
||||
|
||||
async def test_trigger_publishes_one_missed_event_per_instance(
|
||||
missed_app,
|
||||
) -> None:
|
||||
"""The trigger streams one documented missed transition per instance.
|
||||
|
||||
The stream is subscribed FIRST, the sweep is triggered through the
|
||||
route as an admin, and each emitted frame's type and every
|
||||
documented payload field are asserted; the TODAY-due instance must
|
||||
NOT appear (the sweep's rule is strictly past-due).
|
||||
"""
|
||||
ns = missed_app
|
||||
seed = ns.seed
|
||||
stream = _stream(ns.server.url("/api/v1/panel/events"))
|
||||
async with stream:
|
||||
response = await ns.client.post(
|
||||
"/api/v1/admin/missed-sweep", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"fired": 2}
|
||||
|
||||
event_type, payload = await stream.next_event()
|
||||
assert event_type == EVENT_QUEST_MISSED
|
||||
assert event_type == "nestquest_quest_missed"
|
||||
assert payload["child_id"] == seed.ada.id
|
||||
assert payload["child_name"] == "Ada"
|
||||
assert payload["instance_id"] == seed.stale_instance.id
|
||||
assert payload["quest_title"] == "Stale chore"
|
||||
assert payload["window"] == "morning"
|
||||
assert payload["due_date"] == seed.yesterday_iso
|
||||
assert payload["due_time"] == "09:00"
|
||||
assert payload["occurred_at"] # strict UTC ISO-8601 stamp
|
||||
run_stamp = payload["occurred_at"]
|
||||
|
||||
event_type, payload = await stream.next_event()
|
||||
assert event_type == EVENT_QUEST_MISSED
|
||||
assert payload["child_id"] == seed.bo.id
|
||||
assert payload["child_name"] == "Bo"
|
||||
assert payload["instance_id"] == seed.old_instance.id
|
||||
assert payload["quest_title"] == "Old chore"
|
||||
assert payload["window"] == "evening"
|
||||
assert payload["due_date"] == seed.yesterday_iso
|
||||
assert payload["due_time"] == "18:30"
|
||||
# One run, ONE stamp: the route publishes the sweep-built
|
||||
# payloads directly, so every event of this run carries the
|
||||
# same ``occurred_at`` the sweep computed for the whole run.
|
||||
assert payload["occurred_at"] == run_stamp
|
||||
|
||||
# Exactly one transition per instance: today's not-past-due
|
||||
# quest never arrives.
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(stream.queue.get(), timeout=0.3)
|
||||
|
||||
# The run recorded the watermark: the household-local date the
|
||||
# sweep ran for, under the well-known meta_state key.
|
||||
assert _watermark(ns.server) == seed.today_iso
|
||||
|
||||
|
||||
async def test_trigger_publishes_sweep_built_payloads_without_refetch(
|
||||
missed_app,
|
||||
) -> None:
|
||||
"""Publishing never re-fetches: a concurrently deleted instance
|
||||
cannot fail the trigger or lose its already-built missed event.
|
||||
|
||||
The core sweep commits the idempotency watermark BEFORE returning,
|
||||
so a publish loop that re-fetches each instance opens a loss
|
||||
window: an instance deleted inside that window (e.g. a concurrent
|
||||
regenerate) would raise ``ValueError`` uncaught — a bare 500 — and
|
||||
the missed event would be gone forever (the watermark already
|
||||
advanced, so no future sweep re-examines it). The route publishes
|
||||
the sweep-built ``(event_type, payload)`` pairs DIRECTLY (the
|
||||
api/scheduler.py shape), which this test exercises deterministically:
|
||||
the sweep is wrapped to delete one swept instance AFTER the sweep
|
||||
built its events and committed its watermark but BEFORE the route
|
||||
publishes, and the trigger must still answer 200 with ``fired: 2``
|
||||
and stream BOTH sweep-built payloads — the deleted instance's
|
||||
included, stamped with the run's ONE shared ``occurred_at``.
|
||||
"""
|
||||
ns = missed_app
|
||||
seed = ns.seed
|
||||
database = ns.server.app.state.db.database
|
||||
original_sweep = _core_sweep.run_missed_sweep
|
||||
|
||||
async def sweep_then_delete_stale(sweep_database, *, today):
|
||||
events = await original_sweep(sweep_database, today=today)
|
||||
# The concurrent deletion lands after the sweep built its
|
||||
# events and committed its watermark, but before the route
|
||||
# publishes: the row is gone while the built event survives.
|
||||
await sweep_database.execute(
|
||||
"DELETE FROM quest_instances WHERE id = ?",
|
||||
(seed.stale_instance.id,),
|
||||
)
|
||||
return events
|
||||
|
||||
_core_sweep.run_missed_sweep = sweep_then_delete_stale
|
||||
try:
|
||||
stream = _stream(ns.server.url("/api/v1/panel/events"))
|
||||
async with stream:
|
||||
response = await ns.client.post(
|
||||
"/api/v1/admin/missed-sweep", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"fired": 2}
|
||||
|
||||
# The FIRST event is the deleted instance's — published
|
||||
# from the payload built before the deletion, never
|
||||
# re-fetched after it (a re-fetch would 500 here).
|
||||
event_type, payload = await stream.next_event()
|
||||
assert event_type == EVENT_QUEST_MISSED
|
||||
assert payload["instance_id"] == seed.stale_instance.id
|
||||
assert payload["child_name"] == "Ada"
|
||||
assert payload["quest_title"] == "Stale chore"
|
||||
assert payload["window"] == "morning"
|
||||
assert payload["due_date"] == seed.yesterday_iso
|
||||
assert payload["occurred_at"]
|
||||
run_stamp = payload["occurred_at"]
|
||||
|
||||
event_type, payload = await stream.next_event()
|
||||
assert event_type == EVENT_QUEST_MISSED
|
||||
assert payload["instance_id"] == seed.old_instance.id
|
||||
assert payload["occurred_at"] == run_stamp
|
||||
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(stream.queue.get(), timeout=0.3)
|
||||
finally:
|
||||
_core_sweep.run_missed_sweep = original_sweep
|
||||
|
||||
# The watermark advanced even though one instance was deleted
|
||||
# mid-flight — nothing was lost to the publish-time failure window.
|
||||
assert _watermark(ns.server) == seed.today_iso
|
||||
|
||||
|
||||
async def test_same_day_rerun_publishes_nothing(missed_app) -> None:
|
||||
"""A second trigger the same day publishes nothing (the watermark)."""
|
||||
ns = missed_app
|
||||
headers = _admin_headers()
|
||||
first = await ns.client.post("/api/v1/admin/missed-sweep", headers=headers)
|
||||
assert first.status_code == 200
|
||||
assert first.json() == {"fired": 2}
|
||||
|
||||
stream = _stream(ns.server.url("/api/v1/panel/events"))
|
||||
async with stream:
|
||||
second = await ns.client.post(
|
||||
"/api/v1/admin/missed-sweep", headers=headers
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json() == {"fired": 0}
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
# The watermark makes the rerun an empty no-op: the stream
|
||||
# stays silent.
|
||||
await asyncio.wait_for(stream.queue.get(), timeout=0.3)
|
||||
|
||||
|
||||
async def test_run_after_downtime_publishes_accumulated_window_once(
|
||||
missed_app,
|
||||
) -> None:
|
||||
"""After days of downtime the catch-up run sweeps the window once.
|
||||
|
||||
The first trigger sweeps yesterday's instances (watermark := today).
|
||||
``today`` then jumps two days: the accumulated window
|
||||
``[watermark, today)`` holds exactly the seeded TODAY-due instance
|
||||
(no completion events), which fires once — and a same-day rerun is
|
||||
silent again.
|
||||
"""
|
||||
ns = missed_app
|
||||
headers = _admin_headers()
|
||||
first = await ns.client.post("/api/v1/admin/missed-sweep", headers=headers)
|
||||
assert first.status_code == 200
|
||||
assert first.json() == {"fired": 2}
|
||||
|
||||
# Advance the pinned clock two days: the app is "down" over the
|
||||
# rollovers in between.
|
||||
routes_admin._local_now = lambda: ns.pinned_now + datetime.timedelta(
|
||||
days=2
|
||||
)
|
||||
later_today = (ns.today + datetime.timedelta(days=2)).isoformat()
|
||||
stream = _stream(ns.server.url("/api/v1/panel/events"))
|
||||
async with stream:
|
||||
catchup = await ns.client.post(
|
||||
"/api/v1/admin/missed-sweep", headers=headers
|
||||
)
|
||||
assert catchup.status_code == 200
|
||||
assert catchup.json() == {"fired": 1}
|
||||
|
||||
event_type, payload = await stream.next_event()
|
||||
assert event_type == EVENT_QUEST_MISSED
|
||||
assert payload["instance_id"] == ns.seed.recoverable_instance.id
|
||||
assert payload["child_id"] == ns.seed.ada.id
|
||||
assert payload["quest_title"] == "Recoverable chore"
|
||||
assert payload["window"] == "afternoon"
|
||||
assert payload["due_date"] == ns.seed.today_iso
|
||||
assert payload["due_time"] == "17:00"
|
||||
assert payload["occurred_at"]
|
||||
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(stream.queue.get(), timeout=0.3)
|
||||
|
||||
# The watermark moved to the new today...
|
||||
assert _watermark(ns.server) == later_today
|
||||
# ...so a further rerun publishes nothing.
|
||||
again = await ns.client.post("/api/v1/admin/missed-sweep", headers=headers)
|
||||
assert again.status_code == 200
|
||||
assert again.json() == {"fired": 0}
|
||||
|
||||
|
||||
# --- authentication: the router's ONE require_admin dependency ----------
|
||||
|
||||
|
||||
async def test_trigger_refuses_non_admin_and_absent_credentials(
|
||||
missed_app,
|
||||
) -> None:
|
||||
"""A non-admin JWT is 403, an absent credential is 401; none sweep."""
|
||||
ns = missed_app
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
forbidden = await ns.client.post(
|
||||
"/api/v1/admin/missed-sweep",
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
assert forbidden.json()["detail"] == (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
absent = await ns.client.post("/api/v1/admin/missed-sweep")
|
||||
assert absent.status_code == 401
|
||||
|
||||
# Neither refusal ran the sweep: the watermark is still unset, and
|
||||
# the next admin trigger fires the full seeded set.
|
||||
assert _watermark(ns.server) is None
|
||||
after = await ns.client.post(
|
||||
"/api/v1/admin/missed-sweep", headers=_admin_headers()
|
||||
)
|
||||
assert after.status_code == 200
|
||||
assert after.json() == {"fired": 2}
|
||||
|
||||
|
||||
# --- the daily scheduler -------------------------------------------------
|
||||
|
||||
|
||||
class _RecordingPublisher:
|
||||
"""A transition-publisher stub recording every published event."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.published: list[tuple[str, dict]] = []
|
||||
|
||||
def publish(self, event_type: str, payload: dict) -> None:
|
||||
self.published.append((event_type, payload))
|
||||
|
||||
|
||||
async def test_scheduler_runs_sweep_at_rollover_and_cancels_cleanly(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""The scheduler sweeps at the configured rollover and stops clean.
|
||||
|
||||
The injectable clock starts at 10:00 and the settings pin the
|
||||
rollover to 10:05, so the first computed delay is five minutes;
|
||||
the injectable sleep fast-forwards the clock, the sweep runs for
|
||||
the (now advanced) clock's date, and the seeded past-due open
|
||||
instance is published on the recording publisher exactly once.
|
||||
The next cycle schedules for the NEXT day's rollover (23h55m).
|
||||
``stop`` cancels the loop task and the scheduler reports stopped.
|
||||
"""
|
||||
database = _core_db.NestQuestDatabase(_executor)
|
||||
await database.open(temp_db_path)
|
||||
try:
|
||||
await _core_migrations.apply_migrations(database)
|
||||
now_local = datetime.datetime.now().astimezone()
|
||||
start = now_local.replace(
|
||||
hour=10, minute=0, second=0, microsecond=0
|
||||
)
|
||||
ada = await _children.create_child(database, "Ada", sort_order=0)
|
||||
yesterday = (start.date() - datetime.timedelta(days=1)).isoformat()
|
||||
await _quest_definitions.create_quest_definition(
|
||||
database,
|
||||
"Stale chore",
|
||||
_recurrence.ScheduleRule.from_dict(
|
||||
{
|
||||
"rule_type": "daily",
|
||||
"start_date": yesterday,
|
||||
"end_date": yesterday,
|
||||
}
|
||||
),
|
||||
[ada.id],
|
||||
[("morning", "09:00")],
|
||||
)
|
||||
await _materialize.materialize(
|
||||
database, yesterday, yesterday, today=start.date() - datetime.timedelta(days=1)
|
||||
)
|
||||
await _settings_store.update_settings(
|
||||
database, {"day_rollover_time": "10:05"}
|
||||
)
|
||||
|
||||
clock_state = {"now": start}
|
||||
delays: list[float] = []
|
||||
|
||||
def clock() -> datetime.datetime:
|
||||
return clock_state["now"]
|
||||
|
||||
async def sleep_stub(seconds: float) -> None:
|
||||
delays.append(seconds)
|
||||
if len(delays) == 1:
|
||||
# Fast-forward the clock across the rollover wait ONCE,
|
||||
# so the first sweep runs at the rollover instant; the
|
||||
# clock then stays frozen at that instant, so the
|
||||
# follow-up cycles sweep the same (already-watermarked)
|
||||
# date and the assertions read a stable state.
|
||||
clock_state["now"] = clock_state["now"] + datetime.timedelta(
|
||||
seconds=seconds
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
publisher = _RecordingPublisher()
|
||||
scheduler = MissedSweepScheduler(
|
||||
database, publisher=publisher, clock=clock, sleep=sleep_stub
|
||||
)
|
||||
scheduler.start()
|
||||
try:
|
||||
deadline = time.monotonic() + READ_TIMEOUT
|
||||
while not publisher.published:
|
||||
if time.monotonic() > deadline:
|
||||
pytest.fail("scheduler never published a missed event")
|
||||
await asyncio.sleep(0.01)
|
||||
# The second cycle's scheduling decision may lag the first
|
||||
# publication by a few awaits; wait for it the same way.
|
||||
while len(delays) < 2:
|
||||
if time.monotonic() > deadline:
|
||||
pytest.fail("scheduler never scheduled a second cycle")
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
event_type, payload = publisher.published[0]
|
||||
assert event_type == "nestquest_quest_missed"
|
||||
assert payload["child_id"] == ada.id
|
||||
assert payload["quest_title"] == "Stale chore"
|
||||
assert payload["window"] == "morning"
|
||||
assert payload["due_date"] == yesterday
|
||||
assert payload["due_time"] == "09:00"
|
||||
assert payload["occurred_at"]
|
||||
|
||||
# The rollover delay was honored exactly...
|
||||
assert delays[0] == pytest.approx(300.0)
|
||||
# ...and the next cycle targets the NEXT day's rollover
|
||||
# (the just-passed instant schedules for tomorrow).
|
||||
assert delays[1] == pytest.approx(86400.0)
|
||||
# The watermark pins the run to the sweep-day's date.
|
||||
watermark = await _dao_meta.MetaStateDao(database).get(
|
||||
SWEEP_WATERMARK_KEY
|
||||
)
|
||||
assert watermark == start.date().isoformat()
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
assert not scheduler.running
|
||||
# Exactly one announcement: the watermark makes every further
|
||||
# cycle an empty no-op.
|
||||
assert len(publisher.published) == 1
|
||||
finally:
|
||||
await database.close()
|
||||
@@ -0,0 +1,709 @@
|
||||
"""Panel-plane route tests: service token + snapshot (task ae7e90ae).
|
||||
|
||||
These tests exercise the NestQuest API's panel plane against the ASGI
|
||||
app with httpx:
|
||||
|
||||
- ``GET /api/v1/panel/snapshot`` requires the panel service token
|
||||
(D-012): a request with NO ``Authorization`` header, a MALFORMED
|
||||
header, and a WRONG token all return 401; the correct Bearer token
|
||||
returns 200.
|
||||
- The 200 body is the documented snapshot shape: ``today_iso``,
|
||||
``cycle_day``, and ``children`` in the household's sort order, each
|
||||
carrying ``child_id``/``child_name``/``present``/``next_present``,
|
||||
the per-day rollup counts, and its instances in the documented panel
|
||||
payload shape (ENTITIES-AND-SERVICES.md §1) with missed instances
|
||||
OMITTED (D-009).
|
||||
- ``ApiConfig.from_env`` requires ``NESTQUEST_PANEL_TOKEN`` with the
|
||||
same fail-fast discipline as ``NESTQUEST_DB_PATH``.
|
||||
|
||||
The household is seeded through the SAME core copy the app uses — the
|
||||
``nestquest_core.*`` package registered by ``api/nestquest_core.py`` —
|
||||
NOT the conftest-stubbed ``custom_components.nestquest.core`` copy (see
|
||||
the two-copy caveat in api/nestquest_core.py's docstring: never compare
|
||||
class or exception identity across the two copies). The seed opens and
|
||||
migrates its own connection on the app's SQLite path and closes BEFORE
|
||||
the app's lifespan opens the file, so the two connections never
|
||||
overlap.
|
||||
|
||||
The route's single clock read (:func:`api.routes_panel._local_now`) is
|
||||
pinned with monkeypatch the same way the coordinator's ``_local_now``
|
||||
is pinned in test_core_snapshot.py, so the seeded rows and the
|
||||
snapshot's ``today`` can never straddle midnight.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from api import routes_panel
|
||||
from api.app import create_app
|
||||
from api.config import ApiConfig, ConfigError
|
||||
from api.database import _executor
|
||||
from api.nestquest_core import (
|
||||
core_db,
|
||||
core_migrations,
|
||||
core_snapshot,
|
||||
)
|
||||
|
||||
#: The panel service token the test apps are configured with.
|
||||
PANEL_TOKEN = "panel-service-token-test"
|
||||
|
||||
# The bundled core submodules, through the SAME copy the app uses
|
||||
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy).
|
||||
_children = importlib.import_module("nestquest_core.children")
|
||||
_quest_definitions = importlib.import_module(
|
||||
"nestquest_core.quest_definitions"
|
||||
)
|
||||
_materialize = importlib.import_module("nestquest_core.materialize")
|
||||
_recurrence = importlib.import_module("nestquest_core.recurrence")
|
||||
_dao_presence = importlib.import_module("nestquest_core.dao_presence")
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
_completion = importlib.import_module("nestquest_core.completion")
|
||||
|
||||
|
||||
async def _events_for_instance(database: object, instance_id: int) -> list:
|
||||
"""The instance's completion events, on the app's core copy.
|
||||
|
||||
Uses the SAME ``nestquest_core.*`` copy the app uses (the two-copy
|
||||
caveat: never mix copies) against the app's live connection.
|
||||
"""
|
||||
dao = _dao_instances.CompletionEventsDao(database)
|
||||
return await dao.list_by_instance(instance_id)
|
||||
|
||||
|
||||
async def _derived_state(
|
||||
database: object, instance: object, today: datetime.date
|
||||
) -> str:
|
||||
"""The instance's derived state on the app's core copy.
|
||||
|
||||
``derive_state(instance, latest, today)`` from the app's core copy;
|
||||
the latest event is fetched through the same copy's DAO against the
|
||||
app's live connection.
|
||||
"""
|
||||
dao = _dao_instances.CompletionEventsDao(database)
|
||||
latest = await dao.get_latest_for_instance(instance.id)
|
||||
return _completion.derive_state(instance, latest, today)
|
||||
|
||||
|
||||
async def _seed_household(
|
||||
db_path: str,
|
||||
today: datetime.date,
|
||||
pinned_now: datetime.datetime,
|
||||
) -> SimpleNamespace:
|
||||
"""Seed a two-child household on ``db_path`` and return its records.
|
||||
|
||||
Mirrors the test_core_snapshot.py household: an AWAY child (2-week
|
||||
custody cycle anchored yesterday, empty week 0, an override returns
|
||||
her today+2) with an open quest due 10:00 — overdue at the pinned
|
||||
12:00 — and a present child whose 17:00 quest is completed at
|
||||
12:00 (on time). A third quest's instance is materialized due
|
||||
YESTERDAY (via ``materialize`` with ``today`` pinned to yesterday,
|
||||
since the no-past guard refuses past-dated writes): a past-due OPEN
|
||||
instance, i.e. the "missed" view the panel payload must omit.
|
||||
|
||||
Opens, migrates, seeds, and CLOSES its own connection so the app's
|
||||
lifespan connection never overlaps it.
|
||||
"""
|
||||
database = core_db.NestQuestDatabase(_executor)
|
||||
await database.open(db_path)
|
||||
try:
|
||||
await core_migrations.apply_migrations(database)
|
||||
today_iso = today.isoformat()
|
||||
yesterday = today - datetime.timedelta(days=1)
|
||||
yesterday_iso = yesterday.isoformat()
|
||||
|
||||
ada = await _children.create_child(database, "Ada", sort_order=0)
|
||||
bo = await _children.create_child(database, "Bo", sort_order=1)
|
||||
|
||||
# Every rule starts YESTERDAY so the yesterday materialization
|
||||
# below (which pins ``today`` to yesterday) fires them.
|
||||
await _quest_definitions.create_quest_definition(
|
||||
database,
|
||||
"Pack bag",
|
||||
_recurrence.ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": yesterday_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("morning", "10:00")],
|
||||
)
|
||||
await _quest_definitions.create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
_recurrence.ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": yesterday_iso}
|
||||
),
|
||||
[bo.id],
|
||||
[("morning", "17:00")],
|
||||
)
|
||||
# Today's instances first, while Ada has no presence schedule
|
||||
# (she is present every day, so her instance IS generated).
|
||||
await _materialize.materialize(
|
||||
database,
|
||||
today_iso,
|
||||
(today + datetime.timedelta(days=3)).isoformat(),
|
||||
today=today,
|
||||
)
|
||||
# The stale quest is created AFTER the today walk, so its only
|
||||
# instance is the yesterday one written below.
|
||||
await _quest_definitions.create_quest_definition(
|
||||
database,
|
||||
"Stale chore",
|
||||
_recurrence.ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": yesterday_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("morning", "09:00")],
|
||||
)
|
||||
# Materialize yesterday with ``today`` pinned to yesterday: the
|
||||
# no-past guard accepts the rows, and they land past-due (no
|
||||
# completion events) — the missed view.
|
||||
await _materialize.materialize(
|
||||
database, yesterday_iso, yesterday_iso, today=yesterday
|
||||
)
|
||||
instances = _dao_instances.QuestInstancesDao(database)
|
||||
|
||||
ada_today = await instances.list_by_child_and_date(ada.id, today_iso)
|
||||
assert len(ada_today) == 1
|
||||
pack_bag_instance = ada_today[0]
|
||||
bo_today = await instances.list_by_child_and_date(bo.id, today_iso)
|
||||
assert len(bo_today) == 1
|
||||
brush_teeth_instance = bo_today[0]
|
||||
ada_yesterday = await instances.list_by_child_and_date(
|
||||
ada.id, yesterday_iso
|
||||
)
|
||||
stale_instance = next(
|
||||
record
|
||||
for record in ada_yesterday
|
||||
if record.due_time == "09:00"
|
||||
)
|
||||
|
||||
# Ada's custody schedule, added AFTER materialization (the
|
||||
# same order test_core_snapshot.py seeds): 2-week cycle
|
||||
# anchored yesterday, empty week 0 covers today => absent
|
||||
# today; an override returns her on today+2.
|
||||
await _dao_presence.PresenceSchedulesDao(database).upsert_by_child(
|
||||
ada.id,
|
||||
cycle_length_weeks=2,
|
||||
anchor_date=yesterday_iso,
|
||||
pattern="|0,1,2,3,4,5,6",
|
||||
)
|
||||
returns_date = today + datetime.timedelta(days=2)
|
||||
await _dao_presence.PresenceOverridesDao(database).create(
|
||||
ada.id,
|
||||
returns_date.isoformat(),
|
||||
returns_date.isoformat(),
|
||||
is_present=True,
|
||||
note="visit",
|
||||
)
|
||||
|
||||
# Complete Bo's today instance at the pinned 12:00 (17:00 due
|
||||
# => on time).
|
||||
await _completion.complete_instance(
|
||||
database,
|
||||
brush_teeth_instance.id,
|
||||
actor_source="panel",
|
||||
actor_child_id=bo.id,
|
||||
now=pinned_now,
|
||||
today=today,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
ada=ada,
|
||||
bo=bo,
|
||||
pack_bag_instance=pack_bag_instance,
|
||||
brush_teeth_instance=brush_teeth_instance,
|
||||
stale_instance=stale_instance,
|
||||
returns_date=returns_date,
|
||||
)
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
class _PanelRunner:
|
||||
"""Run the app's lifespan around an httpx AsyncClient.
|
||||
|
||||
Same pattern as test_api_health.py's ``_AppRunner``: httpx's ASGI
|
||||
transport does not run the lifespan, so it is driven explicitly and
|
||||
shut down in ``__aexit__``'s finally block even on failure.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, db_path: str, panel_token: str = PANEL_TOKEN
|
||||
) -> None:
|
||||
self.app = create_app(
|
||||
ApiConfig(
|
||||
db_path=db_path,
|
||||
panel_token=panel_token,
|
||||
oidc_issuer="https://authentik.example.com/application/o/nestquest/",
|
||||
oidc_audience="nestquest-api",
|
||||
oidc_jwks_url=(
|
||||
"https://authentik.example.com/application/o/nestquest/jwks/"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> tuple["_PanelRunner", httpx.AsyncClient]:
|
||||
self._lifespan = self.app.router.lifespan_context(self.app)
|
||||
await self._lifespan.__aenter__()
|
||||
self._client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=self.app),
|
||||
base_url="http://testserver",
|
||||
)
|
||||
await self._client.__aenter__()
|
||||
return self, self._client
|
||||
|
||||
async def __aexit__(self, *exc) -> None:
|
||||
try:
|
||||
await self._client.__aexit__(*exc)
|
||||
finally:
|
||||
await self._lifespan.__aexit__(*exc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path: Path) -> str:
|
||||
"""A fresh per-test SQLite path under pytest's tmp_path."""
|
||||
return str(tmp_path / "nestquest.db")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def panel_client(temp_db_path: str) -> httpx.AsyncClient:
|
||||
"""An app client with an empty (migrated) household database."""
|
||||
async with _PanelRunner(temp_db_path) as runner_and_client:
|
||||
_, client = runner_and_client
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seeded_panel(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
||||
"""A seeded household served by the app, with the clock pinned.
|
||||
|
||||
One host clock read anchors ``today``; the pinned instant is
|
||||
12:00 UTC that day (past Ada's 10:00 due, before Bo's 17:00). The
|
||||
route's ``_local_now`` is patched to return the pinned instant.
|
||||
|
||||
Yields the runner too so tests reach the app's live database (the
|
||||
SAME ``nestquest_core`` connection) for assertions.
|
||||
"""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
time_zone = ZoneInfo("UTC")
|
||||
pinned_now = datetime.datetime(
|
||||
today.year, today.month, today.day, 12, 0, 0, tzinfo=time_zone
|
||||
)
|
||||
seed = await _seed_household(temp_db_path, today, pinned_now)
|
||||
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
|
||||
async with _PanelRunner(temp_db_path) as runner_and_client:
|
||||
runner, client = runner_and_client
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
database=runner.app.state.db.database,
|
||||
seed=seed,
|
||||
today=today,
|
||||
pinned_now=pinned_now,
|
||||
pinned_utc_iso=pinned_now.astimezone(
|
||||
datetime.timezone.utc
|
||||
).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
# --- ApiConfig.from_env: the panel token --------------------------------
|
||||
|
||||
|
||||
def test_from_env_reads_panel_token() -> None:
|
||||
"""A set, non-empty NESTQUEST_PANEL_TOKEN is returned verbatim."""
|
||||
cfg = ApiConfig.from_env(
|
||||
{
|
||||
"NESTQUEST_DB_PATH": "/data/nq.db",
|
||||
"NESTQUEST_PANEL_TOKEN": "panel-token",
|
||||
"NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/",
|
||||
"NESTQUEST_OIDC_AUDIENCE": "nestquest-api",
|
||||
"NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/",
|
||||
}
|
||||
)
|
||||
assert cfg.panel_token == "panel-token"
|
||||
|
||||
|
||||
def test_from_env_requires_panel_token_when_missing() -> None:
|
||||
"""A missing panel token raises ConfigError (no silent default)."""
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env(
|
||||
{
|
||||
"NESTQUEST_DB_PATH": "/data/nq.db",
|
||||
"NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/",
|
||||
"NESTQUEST_OIDC_AUDIENCE": "nestquest-api",
|
||||
"NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_from_env_requires_panel_token_when_empty() -> None:
|
||||
"""An empty/whitespace panel token raises ConfigError."""
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env(
|
||||
{
|
||||
"NESTQUEST_DB_PATH": "/data/nq.db",
|
||||
"NESTQUEST_PANEL_TOKEN": " ",
|
||||
"NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/",
|
||||
"NESTQUEST_OIDC_AUDIENCE": "nestquest-api",
|
||||
"NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# --- service-token auth --------------------------------------------------
|
||||
|
||||
|
||||
async def test_missing_token_is_unauthorized(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A request with no Authorization header returns 401."""
|
||||
response = await panel_client.get("/api/v1/panel/snapshot")
|
||||
assert response.status_code == 401
|
||||
assert "www-authenticate" in {
|
||||
key.lower() for key in response.headers
|
||||
}
|
||||
|
||||
|
||||
async def test_wrong_token_is_unauthorized(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A wrong Bearer token returns 401."""
|
||||
response = await panel_client.get(
|
||||
"/api/v1/panel/snapshot",
|
||||
headers={"Authorization": "Bearer not-the-panel-token"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_malformed_authorization_header_is_unauthorized(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A malformed Authorization header returns 401.
|
||||
|
||||
Wrong scheme, empty credential, and an empty header all fail the
|
||||
same way — the response never reveals which part failed.
|
||||
"""
|
||||
for header in (
|
||||
"Basic dXNlcjpwYXNz",
|
||||
"Bearer",
|
||||
"Bearer ",
|
||||
"Token not-the-panel-token",
|
||||
"",
|
||||
):
|
||||
response = await panel_client.get(
|
||||
"/api/v1/panel/snapshot",
|
||||
headers={"Authorization": header},
|
||||
)
|
||||
assert response.status_code == 401, f"header {header!r} passed"
|
||||
|
||||
|
||||
# --- snapshot payload ----------------------------------------------------
|
||||
|
||||
|
||||
async def test_valid_token_returns_full_snapshot(
|
||||
seeded_panel: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The correct token returns 200 with the documented snapshot shape.
|
||||
|
||||
Children in sort order (Ada sort_order 0, Bo 1); per-child counts,
|
||||
presence, next_present, and cycle day all asserted as explicit
|
||||
literals; each child's instances are in the documented panel
|
||||
payload shape (done -> completed spelling, on_time passthrough).
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
response = await seeded_panel.client.get(
|
||||
"/api/v1/panel/snapshot",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
|
||||
assert body["today_iso"] == seeded_panel.today.isoformat()
|
||||
# Ada's 2-week cycle anchored yesterday => today is cycle day 2.
|
||||
assert body["cycle_day"] == 2
|
||||
|
||||
children = body["children"]
|
||||
assert [child["child_id"] for child in children] == [
|
||||
seed.ada.id,
|
||||
seed.bo.id,
|
||||
], "children appear in sort_order"
|
||||
|
||||
ada, bo = children
|
||||
|
||||
# Ada: away today, returns on the override date; her 10:00 quest
|
||||
# is open and overdue at the pinned 12:00.
|
||||
assert ada["child_name"] == "Ada"
|
||||
assert ada["present"] is False
|
||||
assert ada["next_present"] == seed.returns_date.isoformat()
|
||||
assert ada["due_today"] == 1
|
||||
assert ada["completed_today"] == 0
|
||||
assert ada["remaining_today"] == 1
|
||||
assert ada["completion_pct"] == 0
|
||||
assert ada["instances"] == [
|
||||
{
|
||||
"id": seed.pack_bag_instance.id,
|
||||
"definition_id": seed.pack_bag_instance.definition_id,
|
||||
"child_id": seed.ada.id,
|
||||
"title": "Pack bag",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "10:00",
|
||||
"state": "open",
|
||||
"overdue": True,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
}
|
||||
]
|
||||
|
||||
# Bo: present, completed his 17:00 quest at the pinned 12:00
|
||||
# (on time), so the panel spelling is "completed".
|
||||
assert bo["child_name"] == "Bo"
|
||||
assert bo["present"] is True
|
||||
assert bo["next_present"] is None
|
||||
assert bo["due_today"] == 1
|
||||
assert bo["completed_today"] == 1
|
||||
assert bo["remaining_today"] == 0
|
||||
assert bo["completion_pct"] == 100
|
||||
assert bo["instances"] == [
|
||||
{
|
||||
"id": seed.brush_teeth_instance.id,
|
||||
"definition_id": seed.brush_teeth_instance.definition_id,
|
||||
"child_id": seed.bo.id,
|
||||
"title": "Brush teeth",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "17:00",
|
||||
"state": "completed",
|
||||
"overdue": False,
|
||||
"completed_at": seeded_panel.pinned_utc_iso,
|
||||
"on_time": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_missed_instances_are_omitted_from_panel_payload(
|
||||
seeded_panel: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A missed instance never reaches the panel payload (D-009).
|
||||
|
||||
The household carries a past-due OPEN instance due yesterday (the
|
||||
state the panel reads as "missed"); it must not appear anywhere in
|
||||
the response, and Ada's payload carries exactly her ONE today
|
||||
instance — the past-due rows (the stale chore AND her pack bag due
|
||||
yesterday) are both omitted rather than rendered.
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
response = await seeded_panel.client.get(
|
||||
"/api/v1/panel/snapshot",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
|
||||
payload_instances = [
|
||||
instance
|
||||
for child in body["children"]
|
||||
for instance in child["instances"]
|
||||
]
|
||||
assert "missed" not in [
|
||||
instance["state"] for instance in payload_instances
|
||||
]
|
||||
past_due_ids = {seed.stale_instance.id}
|
||||
assert past_due_ids.isdisjoint(
|
||||
instance["id"] for instance in payload_instances
|
||||
), "the past-due open instance leaked into the panel payload"
|
||||
|
||||
ada = next(
|
||||
child
|
||||
for child in body["children"]
|
||||
if child["child_id"] == seed.ada.id
|
||||
)
|
||||
assert [instance["id"] for instance in ada["instances"]] == [
|
||||
seed.pack_bag_instance.id
|
||||
]
|
||||
|
||||
|
||||
def test_panel_shaper_drops_missed_views() -> None:
|
||||
"""The route's shaper omits a missed view (D-009), on the app's copy.
|
||||
|
||||
``instance_payload(include_missed=False)`` is what the snapshot
|
||||
route shapes every child's instances with; exercised here on the
|
||||
SAME core copy the app uses (the two-copy caveat makes this a
|
||||
distinct object from the conftest-stubbed copy's identical source).
|
||||
"""
|
||||
missed_view = core_snapshot.QuestInstanceView(
|
||||
instance_id=999,
|
||||
definition_id=1,
|
||||
child_id=1,
|
||||
title="Stale chore",
|
||||
icon=None,
|
||||
window="morning",
|
||||
due_date="2026-09-21",
|
||||
due_time="09:00",
|
||||
state="missed",
|
||||
overdue=False,
|
||||
completed_at=None,
|
||||
was_on_time=None,
|
||||
)
|
||||
assert core_snapshot.instance_payload(
|
||||
[missed_view], include_missed=False
|
||||
) == []
|
||||
|
||||
|
||||
# --- route documentation -------------------------------------------------
|
||||
|
||||
|
||||
async def test_snapshot_route_is_documented(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""The snapshot route and its response shape are in the OpenAPI doc."""
|
||||
response = await panel_client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
schema = response.json()
|
||||
assert "/api/v1/panel/snapshot" in schema["paths"]
|
||||
assert "get" in schema["paths"]["/api/v1/panel/snapshot"]
|
||||
documented = {
|
||||
model["title"]
|
||||
for model in schema["components"]["schemas"].values()
|
||||
}
|
||||
assert "PanelSnapshotResponse" in documented
|
||||
assert "PanelChildPayload" in documented
|
||||
assert "PanelInstancePayload" in documented
|
||||
|
||||
|
||||
# --- complete route -------------------------------------------------------
|
||||
|
||||
|
||||
async def test_complete_writes_panel_completion(
|
||||
seeded_panel: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The first POST completes the instance as the tapped profile.
|
||||
|
||||
Returns success; the completion event recorded is
|
||||
``actor_source='panel'`` with the tapped ``actor_child_id`` and NO
|
||||
``actor_user_id`` (the panel actor contract), and the instance's
|
||||
derived state becomes ``done``.
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
response = await seeded_panel.client.post(
|
||||
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
json={"actor_child_id": seed.ada.id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "done"}
|
||||
|
||||
# Assert the recorded event and derived state through the SAME core
|
||||
# copy the app uses (two-copy caveat: never mix copies), against the
|
||||
# app's live connection.
|
||||
events = await _events_for_instance(
|
||||
seeded_panel.database, seed.pack_bag_instance.id
|
||||
)
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert event.event_type == "completed"
|
||||
assert event.actor_source == "panel"
|
||||
assert event.actor_child_id == seed.ada.id
|
||||
assert event.actor_user_id is None
|
||||
|
||||
instance = await _dao_instances.QuestInstancesDao(
|
||||
seeded_panel.database
|
||||
).get_by_id(seed.pack_bag_instance.id)
|
||||
assert await _derived_state(
|
||||
seeded_panel.database, instance, seeded_panel.today
|
||||
) == "done"
|
||||
|
||||
|
||||
async def test_complete_is_idempotent(
|
||||
seeded_panel: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A second call is a no-op that still returns success.
|
||||
|
||||
The core completion layer's ``appended`` flag decides: the repeat
|
||||
call appends nothing, so exactly ONE completion event row exists
|
||||
after two calls.
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
headers = {"Authorization": f"Bearer {PANEL_TOKEN}"}
|
||||
url = f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete"
|
||||
body = {"actor_child_id": seed.ada.id}
|
||||
first = await seeded_panel.client.post(url, headers=headers, json=body)
|
||||
assert first.status_code == 200
|
||||
second = await seeded_panel.client.post(url, headers=headers, json=body)
|
||||
assert second.status_code == 200
|
||||
assert second.json() == {"status": "done"}
|
||||
|
||||
events = await _events_for_instance(
|
||||
seeded_panel.database, seed.pack_bag_instance.id
|
||||
)
|
||||
assert len(events) == 1, (
|
||||
"the idempotent call must not append a second event"
|
||||
)
|
||||
|
||||
|
||||
async def test_complete_unknown_instance_is_not_found(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""Completing a non-existent instance returns 404."""
|
||||
response = await panel_client.post(
|
||||
"/api/v1/panel/instances/999999/complete",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
json={"actor_child_id": 1},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_complete_without_token_is_unauthorized(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A complete request with no Authorization header returns 401."""
|
||||
response = await panel_client.post(
|
||||
"/api/v1/panel/instances/1/complete",
|
||||
json={"actor_child_id": 1},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_complete_wrong_child_is_not_found(
|
||||
seeded_panel: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Tapping the wrong child's profile answers 404, not success.
|
||||
|
||||
``complete_instance`` does not itself check that the tapped
|
||||
``actor_child_id`` is the child the instance belongs to; the route
|
||||
guards it so a wrong-profile tap can never complete another
|
||||
child's quest.
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
response = await seeded_panel.client.post(
|
||||
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
json={"actor_child_id": seed.bo.id},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
# And nothing was completed.
|
||||
events = await _events_for_instance(
|
||||
seeded_panel.database, seed.pack_bag_instance.id
|
||||
)
|
||||
assert events == []
|
||||
|
||||
|
||||
async def test_complete_route_is_documented(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""The complete route and its request shape are in the OpenAPI doc."""
|
||||
response = await panel_client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
schema = response.json()
|
||||
path = "/api/v1/panel/instances/{instance_id}/complete"
|
||||
assert path in schema["paths"]
|
||||
assert "post" in schema["paths"][path]
|
||||
documented = {
|
||||
model["title"]
|
||||
for model in schema["components"]["schemas"].values()
|
||||
}
|
||||
assert "PanelCompleteRequest" in documented
|
||||
@@ -0,0 +1,466 @@
|
||||
"""SSE endpoint tests: the panel event stream (task 4d7bae00).
|
||||
|
||||
These tests exercise ``GET /api/v1/panel/events`` — the panel plane's
|
||||
Server-Sent Events stream — against a REAL server:
|
||||
|
||||
- The stream requires the panel service token (D-012): no/wrong token
|
||||
returns 401 (the router-level shared dependency, no second check).
|
||||
- The response is ``text/event-stream`` and each transition arrives as
|
||||
an ``event: <type>`` / ``data: <json>`` frame.
|
||||
- Completing an instance through the panel complete route publishes
|
||||
the ``nestquest_quest_completed`` transition carrying the documented
|
||||
payload fields (design/ENTITIES-AND-SERVICES.md §3).
|
||||
- A no-op re-complete publishes nothing (no second completed event).
|
||||
|
||||
Streaming approach (documented): httpx's ASGITransport BUFFERS the
|
||||
whole response body before returning, so an endless SSE stream hangs
|
||||
it — exactly the awkward case this task anticipated. These tests
|
||||
therefore run the app with uvicorn on an ephemeral port (port 0, so
|
||||
the OS picks a free one) in a daemon thread, and httpx streams from
|
||||
``http://127.0.0.1:<port>``. Every read is bounded by a timeout so a
|
||||
wedged stream fails an assertion instead of hanging the suite; the
|
||||
server is shut down in the fixture's finally block.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import importlib
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import uvicorn
|
||||
|
||||
from api import routes_panel
|
||||
from api.app import create_app
|
||||
from api.config import ApiConfig
|
||||
from tests.test_api_panel import PANEL_TOKEN, _seed_household
|
||||
|
||||
#: The documented quest-completed event name (core const, §3).
|
||||
_core_const = importlib.import_module("nestquest_core.const")
|
||||
EVENT_QUEST_COMPLETED = _core_const.EVENT_QUEST_COMPLETED
|
||||
EVENT_QUEST_UNCOMPLETED = _core_const.EVENT_QUEST_UNCOMPLETED
|
||||
EVENT_QUEST_MISSED = _core_const.EVENT_QUEST_MISSED
|
||||
EVENT_CHILD_DAY_COMPLETE = _core_const.EVENT_CHILD_DAY_COMPLETE
|
||||
|
||||
#: Upper bound for any single stream read; bounds the whole test so a
|
||||
#: wedged stream fails the assertion instead of hanging the suite.
|
||||
READ_TIMEOUT = 5.0
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
"""Ask the OS for one free TCP port (bound then released)."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
class _Server:
|
||||
"""Run the app with uvicorn on an ephemeral port, in a thread.
|
||||
|
||||
``create_app`` runs INSIDE the server thread (uvicorn's factory
|
||||
mode), so the lifespan — including ``app.state.publisher`` — is
|
||||
set up per test on the server's own event loop. Daemon thread +
|
||||
explicit ``should_exit`` shutdown in the fixture's finally block.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str) -> None:
|
||||
self.port = _free_port()
|
||||
self._server_loop: asyncio.AbstractEventLoop | None = None
|
||||
self._server = uvicorn.Server(
|
||||
uvicorn.Config(
|
||||
# A closure factory that captures the app's event loop
|
||||
# as it is created INSIDE the server thread: the
|
||||
# lifespan's database and publisher belong to that
|
||||
# thread's loop, so tests driving an API-side
|
||||
# transition helper directly schedule it there.
|
||||
self._make_app_factory(db_path),
|
||||
host="127.0.0.1",
|
||||
port=self.port,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
self._thread = threading.Thread(
|
||||
target=self._server.run, daemon=True
|
||||
)
|
||||
|
||||
def _make_app_factory(self, db_path: str):
|
||||
"""The uvicorn app factory, closing over ``db_path``.
|
||||
|
||||
Records the running loop the first time uvicorn calls it (in
|
||||
the server thread) so :meth:`run_on_loop` can target it.
|
||||
"""
|
||||
|
||||
def factory():
|
||||
app = create_app(
|
||||
ApiConfig(
|
||||
db_path=db_path,
|
||||
panel_token=PANEL_TOKEN,
|
||||
oidc_issuer=(
|
||||
"https://authentik.example.com/application/o/nestquest/"
|
||||
),
|
||||
oidc_audience="nestquest-api",
|
||||
oidc_jwks_url=(
|
||||
"https://authentik.example.com/application/o/nestquest/jwks/"
|
||||
),
|
||||
)
|
||||
)
|
||||
self._server_loop = asyncio.get_running_loop()
|
||||
self.app = app
|
||||
return app
|
||||
|
||||
return factory
|
||||
|
||||
def __enter__(self) -> "_Server":
|
||||
self._thread.start()
|
||||
deadline = time.monotonic() + READ_TIMEOUT
|
||||
while not self._server.started:
|
||||
if time.monotonic() > deadline or not self._thread.is_alive():
|
||||
raise RuntimeError("uvicorn did not start")
|
||||
time.sleep(0.01)
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self._server.should_exit = True
|
||||
self._thread.join(timeout=READ_TIMEOUT)
|
||||
|
||||
def url(self, path: str) -> str:
|
||||
"""The full URL for ``path`` on this server."""
|
||||
return f"http://127.0.0.1:{self.port}{path}"
|
||||
|
||||
def run_on_loop(self, coro) -> None:
|
||||
"""Run ``coro`` on the SERVER's event loop and wait for it.
|
||||
|
||||
The app (its database and publisher) lives on the uvicorn
|
||||
thread's loop, so a test that drives an API-side transition
|
||||
helper directly must schedule it there: the helper awaits core
|
||||
DAO calls and calls ``publisher.publish`` synchronously, and
|
||||
both must run on the loop that owns them. Bounded by
|
||||
READ_TIMEOUT so a wedged schedule fails instead of hanging.
|
||||
"""
|
||||
return asyncio.run_coroutine_threadsafe(
|
||||
coro, self._server_loop
|
||||
).result(timeout=READ_TIMEOUT)
|
||||
|
||||
|
||||
class _EventStream:
|
||||
"""A subscribed SSE reader over a live httpx connection.
|
||||
|
||||
Parses frames (``event:``/``data:`` pairs) into
|
||||
``(event_type, payload)`` tuples; ``next_event`` awaits the next
|
||||
parsed frame with a timeout.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.queue: asyncio.Queue = asyncio.Queue()
|
||||
self._task: asyncio.Task | None = None
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._stream_cm = None
|
||||
self._response: httpx.Response | None = None
|
||||
|
||||
async def __aenter__(self) -> "_EventStream":
|
||||
self._client = httpx.AsyncClient(timeout=READ_TIMEOUT)
|
||||
self._stream_cm = self._client.stream(
|
||||
"GET",
|
||||
self.url,
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
)
|
||||
self._response = await self._stream_cm.__aenter__()
|
||||
assert self._response.status_code == 200
|
||||
assert self._response.headers["content-type"].startswith(
|
||||
"text/event-stream"
|
||||
)
|
||||
self._task = asyncio.create_task(self._pump())
|
||||
return self
|
||||
|
||||
def _set_url(self, url: str) -> "_EventStream":
|
||||
"""Record the stream URL (called before ``__aenter__``)."""
|
||||
self.url = url
|
||||
return self
|
||||
|
||||
async def _pump(self) -> None:
|
||||
"""Read the stream and push parsed frames onto the queue."""
|
||||
event_type: str | None = None
|
||||
data_lines: list[str] = []
|
||||
try:
|
||||
async for chunk in self._response.aiter_text():
|
||||
for line in chunk.splitlines():
|
||||
if line.startswith("event:"):
|
||||
event_type = line[len("event:"):].strip()
|
||||
elif line.startswith("data:"):
|
||||
data_lines.append(line[len("data:"):].strip())
|
||||
elif not line and event_type is not None:
|
||||
payload = json.loads("\n".join(data_lines))
|
||||
await self.queue.put((event_type, payload))
|
||||
event_type = None
|
||||
data_lines = []
|
||||
except Exception as error: # pragma: no cover - teardown path
|
||||
await self.queue.put(error)
|
||||
|
||||
async def next_event(self) -> tuple[str, dict]:
|
||||
"""The next parsed ``(event_type, payload)``, with a timeout."""
|
||||
item = await asyncio.wait_for(
|
||||
self.queue.get(), timeout=READ_TIMEOUT
|
||||
)
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
return item
|
||||
|
||||
async def __aexit__(self, *exc) -> None:
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await self._stream_cm.__aexit__(*exc)
|
||||
await self._client.__aexit__(*exc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path: Path) -> str:
|
||||
"""A fresh per-test SQLite path under pytest's tmp_path."""
|
||||
return str(tmp_path / "nestquest.db")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seeded_panel(temp_db_path: str) -> SimpleNamespace:
|
||||
"""A seeded household served by a live uvicorn app, clock pinned.
|
||||
|
||||
Same pinning discipline as the panel tests (12:00 UTC, past Ada's
|
||||
10:00 due, before Bo's 17:00): the route's ``_local_now`` is
|
||||
patched so the completion's ``was_on_time`` is deterministic. The
|
||||
app is created inside uvicorn's factory, so the patch must happen
|
||||
BEFORE the server starts.
|
||||
"""
|
||||
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)
|
||||
# The app is created inside uvicorn's factory (in the server
|
||||
# thread), so the patch must be applied BEFORE the server starts.
|
||||
original = routes_panel._local_now
|
||||
routes_panel._local_now = lambda: pinned_now
|
||||
try:
|
||||
with _Server(temp_db_path) as server:
|
||||
client = httpx.AsyncClient(base_url=server.url(""))
|
||||
async with client:
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
seed=seed,
|
||||
server=server,
|
||||
url=server.url,
|
||||
)
|
||||
finally:
|
||||
routes_panel._local_now = original
|
||||
|
||||
|
||||
def _stream(url: str) -> _EventStream:
|
||||
"""A subscribed SSE reader for ``url``."""
|
||||
return _EventStream()._set_url(url)
|
||||
|
||||
|
||||
# --- auth and content type ----------------------------------------------
|
||||
|
||||
|
||||
async def test_events_requires_panel_token(seeded_panel) -> None:
|
||||
"""The SSE route inherits the router's token check (no second one)."""
|
||||
response = await seeded_panel.client.get("/api/v1/panel/events")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# --- the quest-completed transition --------------------------------------
|
||||
|
||||
|
||||
async def test_complete_publishes_quest_completed_event(
|
||||
seeded_panel,
|
||||
) -> None:
|
||||
"""A panel completion emits the documented completed transition.
|
||||
|
||||
The stream is subscribed FIRST, the instance is completed through
|
||||
the panel route, and the emitted frame's type and every documented
|
||||
payload field (child_id, child_name, instance_id, quest_title,
|
||||
window, due_date, due_time, occurred_at, was_on_time) are asserted.
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
pack_bag = seed.pack_bag_instance
|
||||
stream = _stream(seeded_panel.url("/api/v1/panel/events"))
|
||||
async with stream:
|
||||
response = await seeded_panel.client.post(
|
||||
f"/api/v1/panel/instances/{pack_bag.id}/complete",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
json={"actor_child_id": seed.ada.id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
event_type, payload = await stream.next_event()
|
||||
assert event_type == EVENT_QUEST_COMPLETED
|
||||
assert event_type == "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"] # strict UTC ISO-8601 stamp
|
||||
assert payload["was_on_time"] is False # 10:00 due, 12:00 now
|
||||
|
||||
|
||||
async def test_no_op_recomplete_publishes_nothing(seeded_panel) -> None:
|
||||
"""A second complete call emits NO second completed event.
|
||||
|
||||
After the first (real) transition, a re-complete is a no-op in the
|
||||
core layer; the stream must stay silent.
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
url = f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete"
|
||||
body = {"actor_child_id": seed.ada.id}
|
||||
headers = {"Authorization": f"Bearer {PANEL_TOKEN}"}
|
||||
stream = _stream(seeded_panel.url("/api/v1/panel/events"))
|
||||
async with stream:
|
||||
first = await seeded_panel.client.post(url, headers=headers, json=body)
|
||||
assert first.status_code == 200
|
||||
event_type, _ = await stream.next_event()
|
||||
assert event_type == EVENT_QUEST_COMPLETED
|
||||
# Ada's only quest today: the completion also clears her whole
|
||||
# day, so the day-complete event follows (task 44c93cfd).
|
||||
event_type, _ = await stream.next_event()
|
||||
assert event_type == EVENT_CHILD_DAY_COMPLETE
|
||||
|
||||
second = await seeded_panel.client.post(
|
||||
url, headers=headers, json=body
|
||||
)
|
||||
assert second.status_code == 200
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
# Nothing arrives within the window: the no-op re-complete
|
||||
# must publish nothing.
|
||||
await asyncio.wait_for(stream.queue.get(), timeout=0.3)
|
||||
|
||||
|
||||
# --- the remaining documented transitions (task 44c93cfd) -----------------
|
||||
|
||||
|
||||
async def test_publish_quest_uncompleted_streams_event(seeded_panel) -> None:
|
||||
"""The reusable uncompleted publisher streams the documented
|
||||
uncompleted transition with its documented payload fields.
|
||||
|
||||
The admin uncomplete route does not exist yet (deferred to task
|
||||
da0226b3); the test drives the API-side helper the route will call.
|
||||
The helper runs on the SERVER's event loop (``run_coroutine_threadsafe``
|
||||
on the uvicorn thread's loop) against the app's own live database,
|
||||
exactly the way the later route handler will call it.
|
||||
"""
|
||||
from api import transitions
|
||||
|
||||
seed = seeded_panel.seed
|
||||
pack_bag = seed.pack_bag_instance
|
||||
app = seeded_panel.server.app
|
||||
database = app.state.db.database
|
||||
stream = _stream(seeded_panel.url("/api/v1/panel/events"))
|
||||
async with stream:
|
||||
seeded_panel.server.run_on_loop(
|
||||
transitions.publish_quest_uncompleted(
|
||||
database, pack_bag.id, app.state.publisher
|
||||
)
|
||||
)
|
||||
event_type, payload = await stream.next_event()
|
||||
assert event_type == EVENT_QUEST_UNCOMPLETED
|
||||
assert event_type == "nestquest_quest_uncompleted"
|
||||
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 "was_on_time" not in payload
|
||||
|
||||
|
||||
async def test_last_quest_of_day_publishes_day_complete(seeded_panel) -> None:
|
||||
"""Completing the child's LAST open quest of the day emits the
|
||||
completed event AND the child-day-complete event carrying
|
||||
``quests_due`` and ``quests_completed``.
|
||||
|
||||
Ada has exactly one instance today (Pack bag), so completing it
|
||||
clears her whole day; a no-op re-complete afterwards publishes
|
||||
nothing.
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
pack_bag = seed.pack_bag_instance
|
||||
stream = _stream(seeded_panel.url("/api/v1/panel/events"))
|
||||
async with stream:
|
||||
response = await seeded_panel.client.post(
|
||||
f"/api/v1/panel/instances/{pack_bag.id}/complete",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
json={"actor_child_id": seed.ada.id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
event_type, payload = await stream.next_event()
|
||||
assert event_type == EVENT_QUEST_COMPLETED
|
||||
completed_occurred_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
|
||||
# One transition, one shared timestamp (the core builder
|
||||
# reuses the completed event's stamp).
|
||||
assert payload["occurred_at"] == completed_occurred_at
|
||||
|
||||
# No-op re-complete: nothing further on the stream.
|
||||
second = await seeded_panel.client.post(
|
||||
f"/api/v1/panel/instances/{pack_bag.id}/complete",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
json={"actor_child_id": seed.ada.id},
|
||||
)
|
||||
assert second.status_code == 200
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(stream.queue.get(), timeout=0.3)
|
||||
|
||||
|
||||
async def test_publish_quest_missed_streams_event(seeded_panel) -> None:
|
||||
"""The reusable missed publisher streams the documented missed
|
||||
transition with its documented payload fields (the sweep payload).
|
||||
|
||||
The API-side missed sweep does not exist yet at this queue
|
||||
position; the test drives the API-side helper the sweep will call
|
||||
against the seeded past-due open instance, on the server's own
|
||||
event loop and database.
|
||||
"""
|
||||
from api import transitions
|
||||
|
||||
seed = seeded_panel.seed
|
||||
stale = seed.stale_instance
|
||||
app = seeded_panel.server.app
|
||||
database = app.state.db.database
|
||||
stream = _stream(seeded_panel.url("/api/v1/panel/events"))
|
||||
async with stream:
|
||||
seeded_panel.server.run_on_loop(
|
||||
transitions.publish_quest_missed(
|
||||
database, stale.id, app.state.publisher
|
||||
)
|
||||
)
|
||||
event_type, payload = await stream.next_event()
|
||||
assert event_type == EVENT_QUEST_MISSED
|
||||
assert event_type == "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"]
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Test the pure panel snapshot builder (task 9047a97b).
|
||||
|
||||
The snapshot assembly that lived inline in the coordinator's
|
||||
``_async_update_data`` was extracted into the Home-Assistant-free
|
||||
:func:`custom_components.nestquest.core.snapshot.build_snapshot`, and
|
||||
the panel payload shaper (:func:`instance_payload`) with it. These
|
||||
tests seed a household and assert the pure builder's output against
|
||||
EXPLICIT literal expected values — not self-equality — so the AWAY
|
||||
child's ``next_present``, a due-timed quest's ``overdue`` under a
|
||||
pinned ``now``, the custody ``cycle_day`` (> 1), and the panel payload
|
||||
dict shape (``done`` -> ``completed``, ``on_time`` passthrough, and
|
||||
``missed`` omission) are all exercised as literals.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.children import create_child
|
||||
from custom_components.nestquest.completion import complete_instance
|
||||
from custom_components.nestquest.core.snapshot import (
|
||||
ChildDaySnapshot,
|
||||
NestQuestSnapshot,
|
||||
QuestInstanceView,
|
||||
build_snapshot,
|
||||
instance_payload,
|
||||
)
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
PresenceSchedulesDao,
|
||||
)
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
from custom_components.nestquest.quest_definitions import create_quest_definition
|
||||
from custom_components.nestquest.recurrence import ScheduleRule
|
||||
|
||||
|
||||
async def test_instance_payload_shape_and_missed_omission() -> None:
|
||||
"""``instance_payload`` is the documented panel shape (§1).
|
||||
|
||||
``done`` maps to ``completed``; ``open`` passes through; ``on_time``
|
||||
is the builder's ``was_on_time`` verbatim; ``missed`` instances are
|
||||
omitted unless ``include_missed`` (the admin payload, D-009).
|
||||
"""
|
||||
def view(state: str, *, overdue: bool = False, on_time: bool | None = None,
|
||||
completed_at: str | None = None, instance_id: int = 1) -> QuestInstanceView:
|
||||
return QuestInstanceView(
|
||||
instance_id=instance_id,
|
||||
definition_id=10,
|
||||
child_id=3,
|
||||
title="Fill bird feeder",
|
||||
icon="bird",
|
||||
window="afternoon",
|
||||
due_date="2026-09-21",
|
||||
due_time="17:00",
|
||||
state=state,
|
||||
overdue=overdue,
|
||||
completed_at=completed_at,
|
||||
was_on_time=on_time,
|
||||
)
|
||||
|
||||
open_view = view("open", overdue=True)
|
||||
done_view = view("done", on_time=True, completed_at="2026-09-21T12:00:00+00:00")
|
||||
missed_view = view("missed", instance_id=2)
|
||||
|
||||
panel = instance_payload([open_view, done_view, missed_view], include_missed=False)
|
||||
assert panel == [
|
||||
{
|
||||
"id": 1,
|
||||
"definition_id": 10,
|
||||
"child_id": 3,
|
||||
"title": "Fill bird feeder",
|
||||
"icon": "bird",
|
||||
"window": "afternoon",
|
||||
"due_time": "17:00",
|
||||
"state": "open",
|
||||
"overdue": True,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"definition_id": 10,
|
||||
"child_id": 3,
|
||||
"title": "Fill bird feeder",
|
||||
"icon": "bird",
|
||||
"window": "afternoon",
|
||||
"due_time": "17:00",
|
||||
"state": "completed",
|
||||
"overdue": False,
|
||||
"completed_at": "2026-09-21T12:00:00+00:00",
|
||||
"on_time": True,
|
||||
},
|
||||
], "panel payload omits missed and spells done as completed"
|
||||
|
||||
admin = instance_payload(
|
||||
[open_view, done_view, missed_view], include_missed=True
|
||||
)
|
||||
assert len(admin) == 3
|
||||
assert admin[2]["state"] == "missed", (
|
||||
"admin payload keeps missed instances with their missed state"
|
||||
)
|
||||
|
||||
|
||||
async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> None:
|
||||
"""The builder produces the documented shape as literals, pinned in time.
|
||||
|
||||
Ada is AWAY today (a 2-week schedule whose absent week 0 covers today,
|
||||
plus a presence override marking her present on today+2) so
|
||||
``present`` is False and ``next_present`` is the override date. Her
|
||||
open quest due at 10:00 is OVERDUE under the pinned 12:00 ``now``.
|
||||
Bo's quest due at 17:00 is completed at 12:00 (on time) so its
|
||||
payload ``state`` is ``completed`` and ``on_time`` is True. The first
|
||||
scheduled active child is Ada, whose 2-week cycle anchored one day
|
||||
before today puts ``cycle_day`` at 2.
|
||||
"""
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
settings = entry.runtime_data.settings
|
||||
|
||||
# Single clock read for the seed/snapshot anchor (no midnight skew).
|
||||
today = coordinator._local_now().date()
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
# Pin the build instant at 12:00 local: past Ada's 10:00 due time
|
||||
# (overdue) and before Bo's 17:00, and the completion moment for Bo.
|
||||
pinned_now = datetime.datetime(
|
||||
today.year, today.month, today.day, 12, 0, 0, tzinfo=time_zone
|
||||
)
|
||||
today_iso = today.isoformat()
|
||||
|
||||
ada = await create_child(database, "Ada")
|
||||
bo = await create_child(database, "Bo")
|
||||
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Pack bag",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("morning", "10:00")],
|
||||
)
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today_iso}
|
||||
),
|
||||
[bo.id],
|
||||
[("morning", "17:00")],
|
||||
)
|
||||
end = (today + datetime.timedelta(days=3)).isoformat()
|
||||
await materialize(database, today_iso, end, today=today)
|
||||
|
||||
# Ada: 2-week cycle anchored yesterday. Week 0 (the empty segment)
|
||||
# covers today, so she is absent today; an override marks her present
|
||||
# on today+2, so next_present resolves to that date and cycle_day is
|
||||
# ((today - (today-1)) % 14) + 1 == 2.
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
ada.id,
|
||||
cycle_length_weeks=2,
|
||||
anchor_date=(today - datetime.timedelta(days=1)).isoformat(),
|
||||
pattern="|0,1,2,3,4,5,6",
|
||||
)
|
||||
returns_date = today + datetime.timedelta(days=2)
|
||||
await PresenceOverridesDao(database).create(
|
||||
ada.id,
|
||||
returns_date.isoformat(),
|
||||
returns_date.isoformat(),
|
||||
is_present=True,
|
||||
note="visit",
|
||||
)
|
||||
|
||||
# Complete Bo's instance at the pinned 12:00 (17:00 due => on time).
|
||||
bo_instances = await QuestInstancesDao(database).list_by_child_and_date(
|
||||
bo.id, today_iso
|
||||
)
|
||||
await complete_instance(
|
||||
database,
|
||||
bo_instances[0].id,
|
||||
actor_source="panel",
|
||||
actor_child_id=bo.id,
|
||||
now=pinned_now,
|
||||
today=today,
|
||||
)
|
||||
|
||||
built = await build_snapshot(database, settings, pinned_now)
|
||||
|
||||
assert isinstance(built, NestQuestSnapshot)
|
||||
assert built.today_iso == today_iso
|
||||
assert built.cycle_day == 2, (
|
||||
"Ada's 2-week cycle anchored yesterday => day 2 today"
|
||||
)
|
||||
|
||||
by_id = {child.child_id: child for child in built.children}
|
||||
assert list(by_id) == [ada.id, bo.id], (
|
||||
"active children appear in sort_order, id order"
|
||||
)
|
||||
ada_child = by_id[ada.id]
|
||||
bo_child = by_id[bo.id]
|
||||
|
||||
# Ada: away today, returns on the override date, one open+overdue quest.
|
||||
assert isinstance(ada_child, ChildDaySnapshot)
|
||||
assert ada_child.present is False
|
||||
assert ada_child.next_present == returns_date.isoformat()
|
||||
assert ada_child.due_today == 1
|
||||
assert ada_child.completed_today == 0
|
||||
assert ada_child.remaining_today == 1
|
||||
assert ada_child.completion_pct == 0
|
||||
ada_view = ada_child.instances[0]
|
||||
assert isinstance(ada_view, QuestInstanceView)
|
||||
assert ada_view.state == "open"
|
||||
assert ada_view.due_time == "10:00"
|
||||
assert ada_view.overdue is True, "12:00 now is past the 10:00 due time"
|
||||
assert ada_view.completed_at is None
|
||||
assert ada_view.was_on_time is None
|
||||
|
||||
# Bo: present, one completed instance, on time, not overdue.
|
||||
assert bo_child.present is True
|
||||
assert bo_child.next_present is None
|
||||
assert bo_child.due_today == 1
|
||||
assert bo_child.completed_today == 1
|
||||
assert bo_child.remaining_today == 0
|
||||
assert bo_child.completion_pct == 100
|
||||
bo_view = bo_child.instances[0]
|
||||
assert bo_view.state == "done"
|
||||
assert bo_view.due_time == "17:00"
|
||||
assert bo_view.overdue is False, "overdue is only computed for open state"
|
||||
assert bo_view.completed_at == pinned_now.astimezone(
|
||||
datetime.timezone.utc
|
||||
).isoformat()
|
||||
assert bo_view.was_on_time is True
|
||||
|
||||
# Panel payload shape: Ada open+overdue stays open; Bo done -> completed.
|
||||
ada_payload = instance_payload(ada_child.instances, include_missed=False)
|
||||
assert ada_payload == [
|
||||
{
|
||||
"id": ada_view.instance_id,
|
||||
"definition_id": ada_view.definition_id,
|
||||
"child_id": ada.id,
|
||||
"title": "Pack bag",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "10:00",
|
||||
"state": "open",
|
||||
"overdue": True,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
}
|
||||
]
|
||||
bo_payload = instance_payload(bo_child.instances, include_missed=False)
|
||||
assert bo_payload == [
|
||||
{
|
||||
"id": bo_view.instance_id,
|
||||
"definition_id": bo_view.definition_id,
|
||||
"child_id": bo.id,
|
||||
"title": "Brush teeth",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "17:00",
|
||||
"state": "completed",
|
||||
"overdue": False,
|
||||
"completed_at": pinned_now.astimezone(
|
||||
datetime.timezone.utc
|
||||
).isoformat(),
|
||||
"on_time": True,
|
||||
}
|
||||
]
|
||||
|
||||
# Missed omission is real: a missed view is dropped from the panel
|
||||
# payload and kept in the admin payload with its missed state.
|
||||
missed_view = QuestInstanceView(
|
||||
instance_id=999,
|
||||
definition_id=10,
|
||||
child_id=ada.id,
|
||||
title="Stale quest",
|
||||
icon=None,
|
||||
window="morning",
|
||||
due_date=(today - datetime.timedelta(days=1)).isoformat(),
|
||||
due_time="09:00",
|
||||
state="missed",
|
||||
overdue=False,
|
||||
completed_at=None,
|
||||
was_on_time=None,
|
||||
)
|
||||
assert instance_payload([missed_view], include_missed=False) == []
|
||||
assert (
|
||||
instance_payload([missed_view], include_missed=True)[0]["state"]
|
||||
== "missed"
|
||||
)
|
||||
|
||||
# REQUIRED wrapper tie-in: the coordinator's _async_update_data is a
|
||||
# thin WRAPPER over build_snapshot, so pinning its clock to the SAME
|
||||
# pinned_now the builder was called with must reproduce the builder's
|
||||
# snapshot byte-for-byte. The dataclasses are frozen, so == is a full
|
||||
# deep compare — a wrapper that mis-passed today vs now, or stopped
|
||||
# calling the builder, would diverge here even though the literal
|
||||
# assertions above still pass.
|
||||
monkeypatch.setattr(coordinator, "_local_now", lambda: pinned_now)
|
||||
await coordinator.async_refresh()
|
||||
assert coordinator.data == built
|
||||
@@ -186,18 +186,18 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
||||
original_refresh = coordinator._async_update_data
|
||||
|
||||
async def _future_dated_refresh():
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
import custom_components.nestquest.core.snapshot as snapshot_module
|
||||
|
||||
real_derive = coordinator_module.derive_state
|
||||
real_derive = snapshot_module.derive_state
|
||||
|
||||
def _derive(instance, latest, today):
|
||||
return real_derive(instance, latest, today + datetime.timedelta(days=1))
|
||||
|
||||
coordinator_module.derive_state = _derive
|
||||
snapshot_module.derive_state = _derive
|
||||
try:
|
||||
return await original_refresh()
|
||||
finally:
|
||||
coordinator_module.derive_state = real_derive
|
||||
snapshot_module.derive_state = real_derive
|
||||
|
||||
coordinator._async_update_data = _future_dated_refresh
|
||||
await coordinator.async_refresh()
|
||||
|
||||
@@ -791,3 +791,31 @@ async def test_shim_day_complete_uses_ha_local_timezone(
|
||||
f"{far_today.isoformat()}, UTC today {utc_today.isoformat()}); "
|
||||
f"got {len(day_complete)} events"
|
||||
)
|
||||
|
||||
|
||||
async def test_build_quest_missed_event_payload_key_set(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The missed-event payload carries exactly the documented keys —
|
||||
the same shape the nightly sweep hand-builds (§3), with no
|
||||
``was_on_time`` (completion-only)."""
|
||||
from custom_components.nestquest.core.events import (
|
||||
build_quest_missed_event,
|
||||
)
|
||||
|
||||
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
||||
database = entry.runtime_data.database
|
||||
event_type, payload = await build_quest_missed_event(
|
||||
database, instances[0].id
|
||||
)
|
||||
assert event_type == EVENT_QUEST_MISSED
|
||||
assert event_type == "nestquest_quest_missed"
|
||||
assert set(payload) == _UNCOMPLETED_KEYS, (
|
||||
f"missed payload keys drift: got {set(payload)} "
|
||||
f"expected {_UNCOMPLETED_KEYS}"
|
||||
)
|
||||
assert payload["child_id"] == child.id
|
||||
assert payload["child_name"] == "Ada"
|
||||
assert payload["instance_id"] == instances[0].id
|
||||
assert payload["quest_title"] == "Brush teeth"
|
||||
assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"]
|
||||
|
||||
@@ -238,16 +238,16 @@ async def test_due_today_payload_omits_missed_for_panel_includes_for_admin(
|
||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
import custom_components.nestquest.core.snapshot as snapshot_module
|
||||
|
||||
tomorrow = _today() + datetime.timedelta(days=1)
|
||||
real_derive_state = coordinator_module.derive_state
|
||||
real_derive_state = snapshot_module.derive_state
|
||||
|
||||
def _derive_against_pinned_future(instance, latest, today):
|
||||
return real_derive_state(instance, latest, tomorrow)
|
||||
|
||||
monkeypatch.setattr(
|
||||
coordinator_module, "derive_state", _derive_against_pinned_future
|
||||
snapshot_module, "derive_state", _derive_against_pinned_future
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user