Release: Re-imagined architecture - Features 15, 16, 18, 20 (2026-09-23) #185

Merged
overseer merged 17 commits from release/2026-09-23-reimagining into main 2026-09-23 14:55:39 -04:00
43 changed files with 12248 additions and 369 deletions
Showing only changes of commit d78ef196d0 - Show all commits
+19
View File
@@ -0,0 +1,19 @@
"""NestQuest API service.
A standalone FastAPI deployable that owns the NestQuest database and
exposes it over HTTP to the Home Assistant integration (panel plane) and
the admin PWA (admin plane). See Feature 16 (task cd03735b) in the
project tracker for the full contract.
This package imports the NestQuest domain ``core`` WITHOUT executing
``custom_components/nestquest/__init__.py`` — that parent package's
``__init__.py`` imports ``homeassistant``, which is absent from the
API's runtime. The coupling is encapsulated in
:mod:`api.nestquest_core`, which loads ``core`` by file location with
:mod:`importlib.util` and registers it under the distinct top-level name
``nestquest_core`` (no ``sys.path`` mutation, no generic top-level
names published). It is a TRANSITIONAL coupling that Feature 18 will
revisit: once the integration becomes a thin API client and ``core``
moves out of the integration bundle, this helper goes away.
"""
from __future__ import annotations
+139
View File
@@ -0,0 +1,139 @@
"""The NestQuest FastAPI application.
This module builds the ASGI app for the NestQuest API service (Feature
16). The skeleton here wires the bundled ``core`` (Feature 15) and
exposes the two endpoints every later task depends on:
- ``GET /health`` — returns ``200`` with a JSON status body once the
app has imported core, opened the database, and applied migrations;
a cheap ``SELECT 1`` confirms the connection is live without leaking
the filesystem path on an unauthenticated route.
- ``GET /api/v1/panel/snapshot`` — the panel plane's today snapshot
(service-token protected; see :mod:`api.routes_panel`, whose router
carries the shared :func:`api.dependencies.require_panel_token`
check).
- ``GET /api/v1/admin/ping`` — the admin plane's auth probe, protected
by the Authentik OIDC JWT check (see :mod:`api.routes_admin`, whose
router carries the shared :func:`api.auth.require_admin_jwt`
dependency; the JWKS document it verifies against is cached on
``app.state.jwks_cache``).
- ``GET /docs`` — FastAPI's auto-served OpenAPI document (Swagger UI);
``/openapi.json`` is the raw schema. No extra wiring is needed;
FastAPI serves both by default.
The database is opened and migrated in a FastAPI lifespan context so a
startup failure (e.g. an unwritable path) surfaces as a startup error
rather than the first request. The connection is closed on shutdown.
This module exports ONLY the :func:`create_app` factory — there is no
module-level app instance, because building one at import time would
read ``NESTQUEST_DB_PATH`` at import (and fail-fast on a missing value)
before any worker is ready to report it. Serve with::
uvicorn --factory api.app:create_app
so the factory runs on each worker startup and reads the environment
then.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from fastapi import FastAPI
from api.auth import JwksCache
from api.config import ApiConfig
from api.database import DatabaseState, make_database
from api.events import TransitionPublisher
from api.routes_admin import router as admin_router
from api.routes_panel import router as panel_router
from api.scheduler import MissedSweepScheduler
def _build_lifespan(db_path: str):
"""Return a lifespan that opens+migrates then closes one database.
Captured as a closure so the app factory can hand the configured
path in without threading it through FastAPI's lifespan signature.
The lifespan also installs the app's ONE in-process transition
publisher on ``app.state.publisher`` (see :mod:`api.events`) so
every route and SSE subscriber shares that single instance, and
starts the daily missed-sweep scheduler
(:class:`api.scheduler.MissedSweepScheduler`) against the same
database and publisher — cancelled before the connection closes on
shutdown.
"""
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
state = make_database(db_path)
app.state.db = state
app.state.publisher = TransitionPublisher()
scheduler = MissedSweepScheduler(
state.database, publisher=app.state.publisher
)
app.state.sweep_scheduler = scheduler
try:
await state.open_and_migrate()
scheduler.start()
yield
finally:
await scheduler.stop()
await state.close()
return lifespan
def create_app(config: ApiConfig | None = None) -> FastAPI:
"""Build the NestQuest API FastAPI app.
``config`` defaults to one built from the process environment, so
``uvicorn --factory api.app:create_app`` picks up
``NESTQUEST_DB_PATH`` (and fails fast on startup if it is missing).
Tests pass an explicit config so each test gets a hermetic temp
SQLite file.
"""
cfg = config if config is not None else ApiConfig.from_env()
app = FastAPI(
title="NestQuest API",
description=(
"Standalone HTTP service that owns the NestQuest database and "
"exposes it to the Home Assistant integration (panel plane) and "
"the admin PWA (admin plane)."
),
version="0.1.0",
lifespan=_build_lifespan(cfg.db_path),
)
# The resolved config rides on app.state so the reusable route
# dependencies (the panel service-token check, the admin JWT check)
# read the configured values from the app rather than module state.
app.state.config = cfg
# The admin plane's ONE per-app JWKS cache: fetched from the
# configured provider on first use, then reused until its TTL
# lapses (see api.auth.JwksCache). Tests replace it with a
# stub-backed cache so no request ever leaves the process.
app.state.jwks_cache = JwksCache(cfg.oidc_jwks_url)
app.include_router(panel_router)
app.include_router(admin_router)
@app.get("/health", summary="Service health")
async def health() -> dict[str, str]:
"""Return service health.
Returns ``200`` with ``{"status": "ok"}`` once the lifespan has
opened and migrated the database. A cheap ``SELECT 1`` against
the live connection confirms the database is reachable WITHOUT
leaking the filesystem path on an unauthenticated route; if the
connection is dead the query raises and the probe fails.
"""
state: DatabaseState = app.state.db
await state.database.fetch_one("SELECT 1")
return {"status": "ok"}
return app
__all__ = ["create_app"]
+490
View File
@@ -0,0 +1,490 @@
"""Admin-plane authentication for the NestQuest API service.
The admin plane (``/api/v1/admin/*``, D-012) is authenticated by
Authentik-issued OIDC access tokens: the admin PWA presents its JWT as
a Bearer credential and :func:`require_admin_jwt` — the ONE
implementation of that check — verifies it before any admin route runs.
Verification enforces, without exception:
- the signature, against the RSA signing key published in the
provider's JWKS document (fetched from the configured
``NESTQUEST_OIDC_JWKS_URL`` and cached per app — never per request);
- the issuer (``iss`` must equal the configured
``NESTQUEST_OIDC_ISSUER``);
- the audience (``aud`` must carry the configured
``NESTQUEST_OIDC_AUDIENCE``);
- expiry (``exp`` is required and enforced, and ``nbf`` is enforced
when the token carries it).
Every rejection raises the SAME :class:`~fastapi.HTTPException` with
the same detail — nothing in the response distinguishes which check
failed. The check FAILS CLOSED: a missing app config, a malformed or
missing ``Authorization`` header, a non-RS256 algorithm, an unknown
``kid``, and any PyJWT verification error all yield the one 401.
The token's header (``alg``, ``kid``) is parsed and checked BEFORE the
JWKS cache is consulted, so structurally invalid tokens can never
amplify load on the provider. An unknown ``kid`` forces ONE
TTL-bypassing refetch (throttled, see :class:`JwksCache`) so a rotated
key is picked up without waiting out the TTL, and a JWKS entry that
matches the ``kid`` but carries corrupt key material maps to the same
401. A JWKS FETCH failure is deliberately NOT mapped to 401: the
provider being unreachable is a server-side fault (an HTTP 500
surfaces) and never poisons the cache, so the plane stays closed while
the real cause is visible in the server log instead of disguised as a
bad credential; a short backoff window after a failed fetch fails fast
(re-raising the recorded fault) rather than fetching once per request.
Beyond authentication, :func:`require_admin` is the ONE dependency
every admin route uses: it REJECTS the panel service token outright —
that credential authenticates the panel plane only, so treating it as
a JWT candidate would blur two credential types into one check — and
then requires the verified JWT's ``groups`` claim to name the
``nestquest-admins`` group (authorization, 403, distinct from the
uniform authentication 401 so the two failure classes stay apart).
"""
from __future__ import annotations
import asyncio
import binascii
import json
import secrets
import time
from collections.abc import Awaitable, Callable
from typing import Annotated
import httpx
import jwt
from fastapi import Header, HTTPException, Request
#: The authorization scheme the admin plane expects. Matched
#: case-insensitively, as RFC 9110 requires for scheme names.
_BEARER_SCHEME = "bearer"
#: The ONLY JWT signing algorithm accepted. Pinned to one asymmetric
#: algorithm (Authentik's RS256 default) so neither the header's ``alg``
#: nor any JWKS key can talk the verifier into a weaker or symmetric
#: scheme; PyJWT re-enforces it at decode time.
_ALLOWED_ALGORITHM = "RS256"
#: The 401 detail shared by every rejection — deliberately uniform so
#: the response never reveals which part of the check failed.
_REJECTION_DETAIL = "Missing or invalid admin credentials"
#: Challenge header returned with a 401, per the Bearer auth scheme.
_WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"}
#: The authorization group a verified admin JWT must name in its
#: ``groups`` claim. A module constant (the value Authentik's
#: nestquest-admins group maps to) so it is clearly named in one place.
ADMIN_GROUP = "nestquest-admins"
#: Seconds a fetched JWKS document stays trusted before the next
#: fetch. Five minutes bounds how long a rotated-away key keeps
#: verifying while staying far above a per-request fetch.
_JWKS_TTL_SECONDS = 300.0
#: Seconds a FAILED JWKS fetch blocks further fetch attempts. Within
#: this window after a failure the cache fails fast — it re-raises the
#: recorded fault instead of touching the provider — so an outage is
#: never amplified into one fetch per request.
_JWKS_BACKOFF_SECONDS = 5.0
#: Seconds that must pass before ANOTHER forced (TTL-bypassing) refetch
#: may run. An unmatched ``kid`` forces one refetch to pick up rotated
#: keys; this interval bounds it, so a flood of unknown-``kid`` tokens
#: cannot turn each request into a provider fetch either.
_JWKS_KID_REFETCH_SECONDS = 5.0
#: Seconds before the real JWKS fetch gives up on the provider.
_JWKS_TIMEOUT_SECONDS = 5.0
#: An async callable that fetches the JWKS document from a URL. The
#: real one (:func:`_fetch_jwks`) uses httpx; tests inject a stub so
#: the suite stays hermetic (no network access).
JwksFetcher = Callable[[str], Awaitable[dict[str, object]]]
async def _fetch_jwks(url: str) -> dict[str, object]:
"""Fetch the provider's JWKS document over HTTP (the real fetch).
Uses httpx with a short timeout; a non-2xx response or a body that
is not a ``{"keys": [...]}`` object raises — the cache never stores
a failed or malformed fetch.
"""
async with httpx.AsyncClient(timeout=_JWKS_TIMEOUT_SECONDS) as client:
response = await client.get(url)
response.raise_for_status()
document = response.json()
if not isinstance(document, dict) or not isinstance(
document.get("keys"), list
):
raise ValueError("JWKS document must be an object with a keys list")
return document
class JwksCache:
"""One app's cache of the OIDC provider's JWKS document.
The document is fetched through ``fetcher`` (the real one uses
httpx against the configured JWKS URL) and held for ``ttl_seconds``
— the dependency NEVER fetches per request. A single fetch is
shared between concurrent requests via an :class:`asyncio.Lock`
(double-checked around it), and a failed fetch is never cached, so
the next request retries once the backoff window lapses rather than
being locked out until the TTL does.
Two bounds keep the provider out of an amplification loop:
- ``backoff_seconds``: after a FAILED fetch, :meth:`get` fails fast
for this long — re-raising the recorded fault WITHOUT touching
the fetcher or even the lock — instead of retrying per request.
- ``kid_refetch_seconds``: forced (TTL-bypassing) refetches — the
unmatched-``kid`` path — may run at most this often; a forced
refetch that is bounded out (or one inside the backoff window)
is served from the cached document as-is.
A successful fetch always clears the backoff; the document itself
is still trusted only for ``ttl_seconds`` — nothing is cached
indefinitely.
One instance lives on ``app.state.jwks_cache`` (installed by
:func:`api.app.create_app`); tests replace it with one built around
a stub fetcher, keeping the suite hermetic.
"""
def __init__(
self,
url: str,
*,
ttl_seconds: float = _JWKS_TTL_SECONDS,
backoff_seconds: float = _JWKS_BACKOFF_SECONDS,
kid_refetch_seconds: float = _JWKS_KID_REFETCH_SECONDS,
fetcher: JwksFetcher | None = None,
) -> None:
self._url = url
self._ttl_seconds = ttl_seconds
self._backoff_seconds = backoff_seconds
self._kid_refetch_seconds = kid_refetch_seconds
self._fetcher: JwksFetcher = (
fetcher if fetcher is not None else _fetch_jwks
)
self._document: dict[str, object] | None = None
self._fetched_at: float | None = None
self._failed_until: float | None = None
self._last_error: BaseException | None = None
self._forced_at: float | None = None
self._lock = asyncio.Lock()
async def get(self, *, force: bool = False) -> dict[str, object]:
"""Return the JWKS document, refetching it if the TTL lapsed.
With ``force=True`` the TTL is bypassed so an unmatched ``kid``
can pick up a rotated key without waiting it out; the forced
refetch is itself bounded (see the class docstring): inside the
backoff window or the ``kid_refetch_seconds`` interval the
cached document is served as-is instead. A failed fetch is
re-raised to the caller (a provider outage is a server fault,
never a 401) and starts the backoff window, during which every
caller — including forced ones with nothing cached — fails fast
on the recorded fault.
"""
if not force:
fresh = self._fresh_document()
if fresh is not None:
return fresh
error = self._backoff_error()
if error is not None:
# Fail fast OUTSIDE the lock: a request inside the backoff
# window must not queue behind an in-flight fetch.
if force:
document = self._document
if document is not None:
return document
raise error
async with self._lock:
if not force:
fresh = self._fresh_document()
if fresh is not None:
return fresh
error = self._backoff_error()
if error is not None:
if force:
document = self._document
if document is not None:
return document
raise error
if force and self._refetch_throttled():
document = self._document
if document is not None:
return document
return await self._fetch_locked(force=force)
def _fresh_document(self) -> dict[str, object] | None:
"""The cached document if one exists and is inside its TTL."""
document = self._document
fetched_at = self._fetched_at
if (
document is not None
and fetched_at is not None
and (time.monotonic() - fetched_at) < self._ttl_seconds
):
return document
return None
def _backoff_error(self) -> BaseException | None:
"""The recorded fetch fault while the backoff window is open."""
failed_until = self._failed_until
last_error = self._last_error
if (
failed_until is not None
and last_error is not None
and time.monotonic() < failed_until
):
return last_error
return None
def _refetch_throttled(self) -> bool:
"""Whether a forced refetch ran too recently to run another."""
forced_at = self._forced_at
return forced_at is not None and (
time.monotonic() - forced_at
) < self._kid_refetch_seconds
async def _fetch_locked(self, *, force: bool) -> dict[str, object]:
"""Run ONE fetch attempt (caller holds the lock) and record it.
A failed fetch starts the ``backoff_seconds`` window and is
re-raised; a successful one stores the document, restarts the
TTL and clears the backoff. A forced attempt stamps
``_forced_at`` so the ``kid_refetch_seconds`` interval bounds
how often forced refetches may run back-to-back.
"""
if force:
self._forced_at = time.monotonic()
try:
document = await self._fetcher(self._url)
except Exception as error:
self._failed_until = time.monotonic() + self._backoff_seconds
self._last_error = error
raise
self._document = document
self._fetched_at = time.monotonic()
self._failed_until = None
self._last_error = None
return document
#: 403 detail for the panel service token on an admin route. Deliberately
#: non-leaking: it names the credential TYPE, not its value or where it came
#: from. 403 (not 401) because this is a REFUSED credential type, not an
#: absent/invalid one — challenging re-authentication would suggest the same
#: credential could work with a retry, which it never can here.
_PANEL_TOKEN_REJECTION_DETAIL = "This credential type is not accepted for admin access"
#: 403 detail for a valid JWT that is not a member of the admin group.
#: Distinct from the uniform 401 on purpose: the token AUTHENTICATED, but
#: the caller is not AUTHORIZED, so the response may say so.
_FORBIDDEN_DETAIL = "Admin access requires membership in the nestquest-admins group"
def _reject() -> HTTPException:
"""Build the single 401 the dependency raises for every failure."""
return HTTPException(
status_code=401,
detail=_REJECTION_DETAIL,
headers=_WWW_AUTHENTICATE,
)
def _matching_key(jwks: dict[str, object], kid: str) -> object | None:
"""Return the RSA signing key named ``kid`` from the JWKS.
Only ``kty: RSA`` entries are considered (the pinned RS256 scheme
is asymmetric RSA); anything else — a missing ``keys`` list, no
entry with that ``kid`` — returns ``None`` rather than falling back
to "some" key. The key is built through PyJWT's JWKS decoder, and
an entry that MATCHES the ``kid`` but carries corrupt key material
(a malformed ``n``/``e`` raises ``ValueError`` — ``binascii.Error``
is its subclass — or ``TypeError`` instead of a ``PyJWTError``) is
mapped to the one 401 like every other rejection: the caller must
never see a half-built key.
"""
keys = jwks.get("keys")
if not isinstance(keys, list):
raise _reject()
for entry in keys:
if not isinstance(entry, dict):
continue
if entry.get("kid") != kid or entry.get("kty") != "RSA":
continue
try:
return jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(entry))
except (
jwt.PyJWTError,
binascii.Error,
ValueError,
TypeError,
KeyError,
):
raise _reject() from None
return None
async def verify_admin_token(
request: Request, token: str
) -> dict[str, object]:
"""Verify ONE admin bearer token and return its verified claims.
Reads the OIDC settings off ``app.state.config`` and the JWKS cache
off ``app.state.jwks_cache`` (both installed by
:func:`api.app.create_app`) so the check carries no module state.
A missing config or cache, an EMPTY configured issuer/audience/JWKS
URL, a non-RS256 ``alg`` header, an absent ``kid``, an unknown
``kid``, and any PyJWT verification failure (bad signature,
expired, wrong issuer, wrong audience, not-yet-valid, missing
``exp``) raise the SAME 401 — the failure mode is never leaked.
``exp`` is REQUIRED (a token that never expires is never accepted)
and PyJWT enforces ``nbf`` when the token carries it.
The token's HEADER (``alg``, ``kid``) is parsed and enforced BEFORE
the JWKS cache is consulted, so a structurally invalid token costs
no fetch, no lock, no provider load. When the cached document has
no entry for the ``kid``, ONE forced (TTL-bypassing, throttled)
refetch is made and the match retried once — a rotated key is
picked up without waiting out the TTL — before the 401.
A JWKS fetch failure propagates (see the module docstring): the
provider being down is a server fault, not a bad credential.
"""
config = getattr(request.app.state, "config", None)
if (
config is None
or not config.oidc_issuer
or not config.oidc_audience
or not config.oidc_jwks_url
):
raise _reject()
cache = getattr(request.app.state, "jwks_cache", None)
if cache is None:
raise _reject()
try:
header = jwt.get_unverified_header(token)
except jwt.PyJWTError:
raise _reject() from None
if header.get("alg") != _ALLOWED_ALGORITHM:
raise _reject()
kid = header.get("kid")
if not isinstance(kid, str) or not kid:
raise _reject()
jwks = await cache.get()
try:
key = _matching_key(jwks, kid)
if key is None:
# The kid is not in the cached document: force ONE
# TTL-bypassing refetch (bounded by the cache) and retry
# the match once before rejecting.
jwks = await cache.get(force=True)
key = _matching_key(jwks, kid)
if key is None:
raise _reject()
claims = jwt.decode(
token,
key=key,
algorithms=[_ALLOWED_ALGORITHM],
issuer=config.oidc_issuer,
audience=config.oidc_audience,
options={"require": ["exp"]},
)
except jwt.PyJWTError:
raise _reject() from None
return claims
async def require_admin_jwt(
request: Request,
authorization: Annotated[str | None, Header()] = None,
) -> dict[str, object]:
"""Reject the request with 401 unless it carries a valid admin JWT.
Extracts the Bearer credential — a missing or malformed
``Authorization`` header (wrong scheme, empty credential) raises
the one 401 — and delegates the verification to
:func:`verify_admin_token`, returning the verified claims for the
route to use (the probe route surfaces the token's ``sub``).
The nestquest-admins GROUP check lives in :func:`require_admin`,
which wraps this dependency; ``require_admin_jwt`` remains the plain
JWT-validation contract — the 401 path stays exactly this uniform.
"""
if authorization is None:
raise _reject()
scheme, _, credential = authorization.partition(" ")
if scheme.lower() != _BEARER_SCHEME or not credential.strip():
raise _reject()
return await verify_admin_token(request, credential.strip())
async def require_admin(
request: Request,
authorization: Annotated[str | None, Header()] = None,
) -> dict[str, object]:
"""The ONE admin-plane dependency: service-token refusal + group gate.
Order matters. FIRST the Bearer credential is compared against the
configured panel service token (``app.state.config.panel_token``):
a match is refused OUTRIGHT with 403 — BEFORE any JWT parsing — so
the panel credential is never even treated as a JWT candidate, let
alone a non-admin JWT. (403 rather than 401 because this is a
refused credential TYPE, not an invalid one: challenging
re-authentication would imply retrying could ever succeed; the
detail names the type only, never the value.) A config with an
EMPTY panel token never matches anything, so it cannot lock every
Bearer request out.
Otherwise the credential goes through the existing JWT path
(:func:`require_admin_jwt` → :func:`verify_admin_token`): a missing
or malformed header and any invalid JWT still yield the SAME
uniform 401 as before. A VALID JWT is then checked for
authorization: its ``groups`` claim must be a list containing
:data:`ADMIN_GROUP` — a missing, empty, or non-list ``groups`` or
one without the group raises 403 (distinct from the 401 on
purpose: the token authenticated, the caller did not authorize).
Returns the verified claims for the route.
"""
if authorization is not None:
scheme, _, credential = authorization.partition(" ")
if scheme.lower() == _BEARER_SCHEME and credential.strip():
config = getattr(request.app.state, "config", None)
if (
config is not None
and config.panel_token
# compare_digest on bytes (as require_panel_token does)
# so the refusal does not short-circuit on the first
# mismatching byte — a constant-time comparison keeps a
# timing side channel on the credential off the table.
and secrets.compare_digest(
credential.strip().encode("utf-8"),
config.panel_token.encode("utf-8"),
)
):
raise HTTPException(
status_code=403,
detail=_PANEL_TOKEN_REJECTION_DETAIL,
)
claims = await require_admin_jwt(request, authorization)
groups = claims.get("groups")
if not isinstance(groups, list) or ADMIN_GROUP not in groups:
raise HTTPException(status_code=403, detail=_FORBIDDEN_DETAIL)
return claims
__all__ = [
"ADMIN_GROUP",
"JwksCache",
"JwksFetcher",
"require_admin",
"require_admin_jwt",
"verify_admin_token",
]
+174
View File
@@ -0,0 +1,174 @@
"""Configuration for the NestQuest API service.
The API reads its configuration knobs from environment variables:
- ``NESTQUEST_DB_PATH`` — the SQLite database file. The API opens this
file on startup (creating it if absent) and applies migrations.
- ``NESTQUEST_PANEL_TOKEN`` — the static panel-plane service token
(D-012): the credential ``/api/v1/panel/*`` routes require via
:func:`api.dependencies.require_panel_token`. It is stored outside
the repo and handed to the service through the environment.
- ``NESTQUEST_OIDC_ISSUER`` — the Authentik OIDC issuer URL whose
``iss`` claim admin tokens must carry (D-012 admin plane).
- ``NESTQUEST_OIDC_AUDIENCE`` — the OIDC client id the admin tokens
must carry as their ``aud`` claim (the API service's client id).
- ``NESTQUEST_OIDC_JWKS_URL`` — the Authentik endpoint serving the
signing keys (the JWKS document) the admin tokens' signatures are
verified against. Cached per app (see :class:`api.auth.JwksCache`).
Unlike a dev convenience default, a missing or empty value is a
configuration ERROR, not a silent fallback: a default database path
would silently drop data at reboot, a default (or empty) service token
would either authenticate everyone or lock the panel plane out for
good, and a default (or empty) OIDC setting would silently leave the
admin plane trusting an issuer that was never configured. All five
variables are therefore REQUIRED — ``ApiConfig.from_env`` RAISES when
any of them is unset or empty. This mirrors the integration's
"explicit settings object" discipline (Feature 15's ``core.settings``):
the API never reads HA config; callers pass the resolved values into
the wiring.
Serving: the app is built with the factory ``api.app:create_app`` — run
it with ``uvicorn --factory api.app:create_app`` so the factory reads
the environment on each worker startup rather than at import time.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
#: Environment variable holding the SQLite file path. The API opens
#: this file on startup (creating it if absent) and applies migrations.
#: REQUIRED — unset or empty raises :class:`ConfigError` from
#: :meth:`ApiConfig.from_env`.
DB_PATH_ENV = "NESTQUEST_DB_PATH"
#: Environment variable holding the static panel-plane service token
#: (D-012), checked by :func:`api.dependencies.require_panel_token`.
#: REQUIRED — unset or empty raises :class:`ConfigError` from
#: :meth:`ApiConfig.from_env`; there is no silent default, because an
#: empty configured token would either authenticate every caller or
#: lock the panel plane out permanently.
PANEL_TOKEN_ENV = "NESTQUEST_PANEL_TOKEN"
#: Environment variable holding the Authentik OIDC issuer URL (D-012
#: admin plane): every admin JWT's ``iss`` claim must equal it. Checked
#: by :func:`api.auth.require_admin_jwt`. REQUIRED — unset or empty
#: raises :class:`ConfigError` from :meth:`ApiConfig.from_env`; a default
#: issuer would silently make the admin plane trust tokens from an
#: issuer nobody configured.
OIDC_ISSUER_ENV = "NESTQUEST_OIDC_ISSUER"
#: Environment variable holding the OIDC audience (the API service's
#: client id at Authentik): every admin JWT's ``aud`` claim must carry
#: it. Checked by :func:`api.auth.require_admin_jwt`. REQUIRED — unset
#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`.
OIDC_AUDIENCE_ENV = "NESTQUEST_OIDC_AUDIENCE"
#: Environment variable holding the Authentik JWKS URL: the endpoint
#: serving the signing keys admin JWT signatures are verified against.
#: Fetched and cached by :class:`api.auth.JwksCache`. REQUIRED — unset
#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`;
#: without the provider's keys no token can be verified, so a silent
#: default would only move the failure to request time.
OIDC_JWKS_URL_ENV = "NESTQUEST_OIDC_JWKS_URL"
class ConfigError(RuntimeError):
"""Raised when required API configuration is missing or invalid."""
@dataclass(frozen=True)
class ApiConfig:
"""Resolved API configuration.
Every field is required (no defaults): ``from_env`` rejects an
unset or empty value for each, and a caller building the dataclass
directly must pass explicit values — the token check and the JWT
checks themselves fail closed on empty configured values, but
expecting a caller to discover that at request time is a wiring
mistake, so every field is mandatory. Kept as a dataclass so later
tasks (SSE, settings persistence) can extend it without touching
the call sites that read ``db_path``.
"""
db_path: str
panel_token: str
#: The Authentik OIDC issuer URL admin tokens' ``iss`` must equal.
oidc_issuer: str
#: The OIDC audience (the API service's client id) admin tokens'
#: ``aud`` must carry.
oidc_audience: str
#: The Authentik JWKS URL the signing keys are fetched from.
oidc_jwks_url: str
@classmethod
def from_env(cls, env: dict[str, str] | None = None) -> "ApiConfig":
"""Build configuration from the process environment.
``env`` defaults to ``os.environ``; tests pass an explicit dict
so they are hermetic (no monkeypatch of the global environ).
Raises :class:`ConfigError` when ANY of the five variables is
unset or empty/whitespace-only — there is no silent default:
- ``NESTQUEST_DB_PATH``: a default in the temp directory would
silently lose data at reboot;
- ``NESTQUEST_PANEL_TOKEN``: a default token is never invented —
the panel plane is closed until a real one is configured;
- the three OIDC variables: an unconfigured issuer/audience/JWKS
URL must not silently leave the admin plane open (a default
JWKS URL or issuer would point at a provider nobody set up).
"""
source = env if env is not None else os.environ
def require(env_var: str, reason: str) -> str:
"""Return the stripped env value or raise ConfigError."""
value = source.get(env_var, "").strip()
if not value:
raise ConfigError(f"{env_var} {reason}")
return value
return cls(
db_path=require(
DB_PATH_ENV,
"must be set to a writable SQLite file path (it is unset "
"or empty — the API will not fall back to a temp directory "
"and silently lose data)",
),
panel_token=require(
PANEL_TOKEN_ENV,
"must be set to the panel service token (it is unset or "
"empty — the API will not start without one, and no "
"default token is invented)",
),
oidc_issuer=require(
OIDC_ISSUER_ENV,
"must be set to the Authentik OIDC issuer URL (it is "
"unset or empty — the admin plane will not trust tokens "
"from an issuer nobody configured)",
),
oidc_audience=require(
OIDC_AUDIENCE_ENV,
"must be set to the OIDC audience the admin tokens carry "
"(it is unset or empty — the admin plane will not accept "
"tokens without a configured audience)",
),
oidc_jwks_url=require(
OIDC_JWKS_URL_ENV,
"must be set to the Authentik JWKS URL (it is unset or "
"empty — admin token signatures cannot be verified "
"without the provider's signing keys)",
),
)
__all__ = [
"ApiConfig",
"ConfigError",
"DB_PATH_ENV",
"OIDC_AUDIENCE_ENV",
"OIDC_ISSUER_ENV",
"OIDC_JWKS_URL_ENV",
"PANEL_TOKEN_ENV",
]
+82
View File
@@ -0,0 +1,82 @@
"""Database wiring for the NestQuest API service.
The API owns the database (it is the writer of record per Feature 16's
contract). This module owns one application-lifetime
:class:`core.db.NestQuestDatabase`: it opens the SQLite file at the
configured path on startup, applies migrations (creating the schema on
a fresh file), and closes the connection on shutdown.
The executor callable passed into ``NestQuestDatabase`` mirrors the
contract of ``hass.async_add_executor_job``: schedule a synchronous
callable on a worker and await its result. HA's own version lives on
the event loop; the API is not HA, so it uses
:func:`asyncio.to_thread` for the same effect — sqlite3 calls run on a
worker thread and only plain data returns to the event loop.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from api.nestquest_core import core_db, core_migrations
LOGGER = logging.getLogger(__name__)
#: The async SQLite wrapper. Re-exported so route handlers and later
#: tasks reach it through the API package, not the bundled core.
NestQuestDatabase = core_db.NestQuestDatabase
#: The migration runner. Re-exported for the same reason.
apply_migrations = core_migrations.apply_migrations
async def _executor(fn: Any, *args: Any, **kwargs: Any) -> Any:
"""Run a synchronous callable on a worker thread and await its result.
Mirrors the contract of ``hass.async_add_executor_job`` so the
bundled ``NestQuestDatabase`` (written against that contract) works
unchanged in the API process: sqlite3 calls never block the event
loop, and only plain Python data (row tuples,
:class:`core.db.ExecutionResult`) crosses back.
"""
return await asyncio.to_thread(fn, *args, **kwargs)
class DatabaseState:
"""Holds the live database wrapper and its resolved path.
A small container so the lifespan and the route handlers share one
authoritative reference; FastAPI's lifespan stores one of these on
``app.state.db`` and the health route reads it back.
"""
__slots__ = ("database", "db_path")
def __init__(self, database: NestQuestDatabase, db_path: str) -> None:
self.database = database
self.db_path = db_path
async def open_and_migrate(self) -> None:
"""Open the connection and apply migrations to the latest version."""
LOGGER.info("Opening NestQuest database at %s", self.db_path)
await self.database.open(self.db_path)
version = await apply_migrations(self.database)
LOGGER.info("NestQuest schema at version %d", version)
async def close(self) -> None:
"""Close the connection; safe to call even if open failed."""
await self.database.close()
def make_database(db_path: str) -> DatabaseState:
"""Construct a :class:`DatabaseState` for the given path (not yet open)."""
return DatabaseState(NestQuestDatabase(_executor), db_path)
__all__ = [
"DatabaseState",
"NestQuestDatabase",
"apply_migrations",
"make_database",
]
+81
View File
@@ -0,0 +1,81 @@
"""Reusable FastAPI dependencies for the NestQuest API routes.
The panel plane (``/api/v1/panel/*``) is authenticated by the static
service token configured through ``NESTQUEST_PANEL_TOKEN`` (D-012): the
HA integration presents it as a Bearer credential on every panel
request. :func:`require_panel_token` is the ONE implementation of that
check — every panel route (this task's snapshot route and each later
panel route) declares it as a dependency rather than re-implementing
token handling, so the credential, the failure behaviour, and the
401 contract stay in exactly one place.
The dependency FAILS CLOSED: a missing ``Authorization`` header, a
malformed one (wrong scheme or empty credential), a token that does not
match the configured value, and an empty configured token all yield
HTTP 401. The comparison is constant-time (:func:`secrets.compare_digest`)
so the wall-clock difference between "wrong length" and "wrong value"
cannot be used to probe the token byte by byte, and every rejection
carries the same detail — nothing in the response distinguishes which
part of the check failed.
"""
from __future__ import annotations
import secrets
from typing import Annotated
from fastapi import Header, HTTPException, Request
#: The authorization scheme the panel plane expects. Matched
#: case-insensitively, as RFC 9110 requires for scheme names.
_BEARER_SCHEME = "bearer"
#: The 401 detail shared by every rejection — deliberately uniform so
#: the response never reveals which part of the check failed.
_REJECTION_DETAIL = "Missing or invalid panel service token"
#: Challenge header returned with a 401, per the Bearer auth scheme.
_WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"}
def _reject() -> HTTPException:
"""Build the single 401 the dependency raises for every failure."""
return HTTPException(
status_code=401,
detail=_REJECTION_DETAIL,
headers=_WWW_AUTHENTICATE,
)
async def require_panel_token(
request: Request,
authorization: Annotated[str | None, Header()] = None,
) -> None:
"""Reject the request with 401 unless it carries the panel token.
Reads the configured token off ``app.state.config`` (set by
:func:`api.app.create_app`) so the dependency stays reusable across
routes and carries no module state of its own. A missing or
malformed ``Authorization`` header, a non-matching credential, and
an EMPTY configured token (a wiring mistake) all raise the same
``HTTPException(401)`` — the check is closed even when the service
is misconfigured.
Returns ``None`` on success; FastAPI discards a ``None`` return,
the route handler simply proceeds.
"""
config = getattr(request.app.state, "config", None)
expected = config.panel_token if config is not None else ""
if not expected or authorization is None:
raise _reject()
scheme, _, credential = authorization.partition(" ")
if scheme.lower() != _BEARER_SCHEME or not credential.strip():
raise _reject()
# compare_digest on bytes so a non-ASCII token raises nothing and a
# timing side channel on the str path is not available.
if not secrets.compare_digest(
credential.strip().encode("utf-8"), expected.encode("utf-8")
):
raise _reject()
__all__ = ["require_panel_token"]
+78
View File
@@ -0,0 +1,78 @@
"""The ONE in-process transition-event publisher for the API service.
Route handlers call :meth:`TransitionPublisher.publish` synchronously
(no ``await``, callable from any async handler); SSE subscribers stream
those transitions through an async generator. The scope of "ONE" is
deliberate: a single :class:`TransitionPublisher` instance is created
in the app lifespan and stored on ``app.state.publisher`` — per-app,
so tests build their own hermetic app without module-level state, and
both the panel complete route and the SSE endpoint resolve the SAME
instance off ``request.app.state`` (exactly one publisher exists per
running app; nothing else may create one).
Design (small and dependency-free, asyncio only):
- Each subscriber gets its own ``asyncio.Queue(maxsize=64)``.
- ``publish`` is synchronous and non-blocking: it puts the event on
every live queue with ``put_nowait``. A slow subscriber that lets
its queue fill gets the event DROPPED (oldest overflow) — never a
blocked publisher, never unbounded growth.
- A disconnected subscriber's queue is removed in the generator's
``finally`` block, so teardown is clean and a closed consumer cannot
leak its queue.
Fan-out is fire-and-forget by design: the SSE plane mirrors transition
events for the panel UI; a dropped event costs the panel one UI
refresh, and the documented recovery path is the next snapshot poll.
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
#: Per-subscriber queue capacity. Bounded so a stalled consumer can
#: never make the publisher or another subscriber wait; beyond this
#: the slow subscriber drops the event (see module docstring).
_QUEUE_MAXSIZE = 64
__all__ = ["TransitionPublisher"]
class TransitionPublisher:
"""A single in-process fan-out point for transition events."""
def __init__(self) -> None:
"""Create the publisher with no subscribers."""
self._subscribers: set[asyncio.Queue] = set()
def publish(self, event_type: str, payload: dict) -> None:
"""Fan one transition event out to every current subscriber.
Synchronous and non-blocking: each subscriber's bounded queue
is filled with ``put_nowait``, and a FULL queue silently drops
the event for that (slow) subscriber rather than blocking the
route handler or growing without bound.
"""
for queue in tuple(self._subscribers):
try:
queue.put_nowait((event_type, payload))
except asyncio.QueueFull:
# The subscriber is not keeping up; drop the event for
# it and move on (the panel recovers by polling).
pass
async def subscribe(self) -> AsyncIterator[tuple[str, dict]]:
"""Yield ``(event_type, payload)`` tuples until the consumer breaks.
Registers ONE bounded queue for the duration of the iteration
and removes it in the ``finally`` block, so a client disconnect
(or any break out of the loop) tears the subscription down
cleanly with no leaked queue.
"""
queue: asyncio.Queue = asyncio.Queue(maxsize=_QUEUE_MAXSIZE)
self._subscribers.add(queue)
try:
while True:
yield await queue.get()
finally:
self._subscribers.discard(queue)
+228
View File
@@ -0,0 +1,228 @@
"""Transitional coupling: import ``core`` without importing Home Assistant.
The NestQuest domain ``core`` package (Feature 15) is bundled INSIDE the
Home Assistant integration at ``custom_components/nestquest/core/``.
Importing it the obvious way — ``import custom_components.nestquest.core``
— would execute ``custom_components/nestquest/__init__.py``, which
imports ``homeassistant``. Home Assistant is NOT installed in the API
service's runtime, so that import raises ``ModuleNotFoundError`` and the
API cannot start.
``core`` itself is a clean, Home-Assistant-free package: its
``__init__.py`` is a bare docstring and every internal import is
relative (``from .db import ...``). It can therefore be imported by
file location as a standalone package.
This module loads ``core`` with :func:`importlib.util.spec_from_file_location`
— registering it under the distinct top-level name ``nestquest_core``
with ``submodule_search_locations`` set to the bundled ``core``
directory — so its relative imports resolve against that directory
without touching ``sys.path``. No generic top-level name (``db``,
``const``, ``schema``, ...) is published, so nothing here can silently
shadow a future dependency, and the integration's HA-coupled
``__init__.py`` is never executed: ``custom_components.nestquest`` is
never imported.
Usage::
from api.nestquest_core import core_db, core_migrations
# core_db.NestQuestDatabase, core_migrations.apply_migrations, ...
This is a TRANSITIONAL coupling. Feature 18 (task 430f7f98) converts
the integration into a thin API client and moves ``core`` out of the
integration bundle; once that lands, this bootstrap helper goes away and
the API imports ``core`` as an ordinary installed dependency.
NOTE on the pytest process: the suite's ``conftest.py`` stubs the
parent integration package and imports ``custom_components.nestquest.core.*``
BEFORE any API test runs, so two copies of the core package coexist in
the pytest process — the one registered here as ``nestquest_core.*``
(loaded by file location) and the conftest-stubbed
``custom_components.nestquest.core.*``. Later tasks must NOT compare
class or exception identity across these two copies: a
``nestquest_core.db.NestQuestDatabase`` is a DIFFERENT class object from
``custom_components.nestquest.core.db.NestQuestDatabase`` even though
they share source. Operate on a single copy within any one code path.
"""
from __future__ import annotations
import importlib
import importlib.util
import sys
from pathlib import Path
from types import ModuleType
#: The repository root (this file is ``<root>/api/nestquest_core.py``).
_REPO_ROOT: Path = Path(__file__).resolve().parent.parent
#: The bundled ``core`` package directory, inside the HA integration.
_CORE_DIR: Path = _REPO_ROOT / "custom_components" / "nestquest" / "core"
#: The ``core`` package's ``__init__.py`` — a bare docstring, so loading
#: it by file location executes no imports of its own.
_CORE_INIT: Path = _CORE_DIR / "__init__.py"
#: The distinct top-level name under which the bundled ``core`` package
#: is registered in ``sys.modules``. A distinctive name (rather than the
#: generic ``core``) avoids publishing shadow-able top-level names and
#: keeps the bundled copy separate from the conftest-stubbed
#: ``custom_components.nestquest.core`` present in the pytest process.
_CORE_MODULE_NAME = "nestquest_core"
def _load_core() -> ModuleType:
"""Load the bundled ``core`` package by file location and return it.
Registers the package under :data:`_CORE_MODULE_NAME` in
``sys.modules`` with ``submodule_search_locations`` pointing at the
bundled directory, so its relative imports (``from .db import ...``)
resolve to ``nestquest_core.<submodule>`` against that directory
without mutating ``sys.path``. Idempotent: a second call returns
the already-registered module.
"""
existing = sys.modules.get(_CORE_MODULE_NAME)
if existing is not None:
return existing
spec = importlib.util.spec_from_file_location(
_CORE_MODULE_NAME,
_CORE_INIT,
submodule_search_locations=[str(_CORE_DIR)],
)
if spec is None or spec.loader is None:
raise ImportError(f"could not load core from {_CORE_INIT}")
module = importlib.util.module_from_spec(spec)
sys.modules[_CORE_MODULE_NAME] = module
spec.loader.exec_module(module)
return module
#: The bundled core package, loaded by file location and registered as
#: ``nestquest_core`` in ``sys.modules``. Held here so callers import the
#: submodules through it (``nestquest_core.db``, ``nestquest_core.migrations``)
#: the same relative way the integration does.
core: ModuleType = _load_core()
#: Re-exported for convenience; route handlers import these names from
#: here rather than reaching into the core package directly.
core_db = importlib.import_module(f"{_CORE_MODULE_NAME}.db")
core_migrations = importlib.import_module(f"{_CORE_MODULE_NAME}.migrations")
#: The panel snapshot builder and its payload shaper
#: (``core.snapshot.build_snapshot`` / ``instance_payload``) — the pure
#: assembly the panel routes call.
core_snapshot = importlib.import_module(f"{_CORE_MODULE_NAME}.snapshot")
#: The completion service (``core.completion.complete_instance`` /
#: ``uncomplete_instance``) the panel's write routes call.
core_completion = importlib.import_module(f"{_CORE_MODULE_NAME}.completion")
#: The read-only history layer (``core.history.query_history``,
#: ``export_history_csv``, ``rows_to_csv``) the admin plane's history
#: query and CSV export routes call.
core_history = importlib.import_module(f"{_CORE_MODULE_NAME}.history")
#: The transition-event builders (``core.events.build_quest_completed_event``
#: et al.) the panel's write routes build their SSE events with.
core_events = importlib.import_module(f"{_CORE_MODULE_NAME}.events")
#: The explicit settings object (``core.settings.NestQuestSettings``)
#: the snapshot builder's ``settings`` parameter takes.
core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings")
#: The durable settings store (``core.settings_store.load_settings`` /
#: ``update_settings``) the admin plane's settings routes read and
#: write — the API's OWN settings source: a validated JSON document in
#: the existing ``nestquest_meta_state`` table, resolved through the
#: same core settings validators (the integration's HA config-entry
#: options remain a temporary separate source until Feature 18 wires
#: the client to the API).
core_settings_store = importlib.import_module(
f"{_CORE_MODULE_NAME}.settings_store"
)
#: The children business layer (``core.children.create_child`` et al.)
#: the admin plane's children routes call.
core_children = importlib.import_module(f"{_CORE_MODULE_NAME}.children")
#: The quest-definition business layer (``core.quest_definitions`` —
#: ``create_quest_definition``, ``edit_quest_definition``,
#: ``set_quest_definition_active``) the admin plane's quest-definition
#: routes call.
core_quest_definitions = importlib.import_module(
f"{_CORE_MODULE_NAME}.quest_definitions"
)
#: The recurrence model (``core.recurrence.ScheduleRule`` and
#: ``RuleValidationError``) the quest-definition routes build request
#: rules with — the model validates at construction, the routes never
#: re-implement rule validation.
core_recurrence = importlib.import_module(f"{_CORE_MODULE_NAME}.recurrence")
#: The presence model (``core.presence.PresenceEngine``,
#: ``PresenceSchedule``, ``PresenceOverride``) the admin plane's
#: presence routes serialize and the tests resolve presence with.
core_presence = importlib.import_module(f"{_CORE_MODULE_NAME}.presence")
#: The presence-write business layer
#: (``core.presence_management.set_presence_schedule`` et al.) the
#: admin plane's presence routes call — the ONE implementation the HA
#: services share.
core_presence_management = importlib.import_module(
f"{_CORE_MODULE_NAME}.presence_management"
)
#: The typed presence DAOs (``core.dao_presence``) the admin-plane tests
#: read stored schedules and overrides back through.
core_dao_presence = importlib.import_module(
f"{_CORE_MODULE_NAME}.dao_presence"
)
#: The materialization walk (``core.materialize.materialize``,
#: ``regenerate_for_definition``, ``regenerate_for_child``) the admin
#: plane's regenerate route calls — the same walk the integration's
#: config/presence changes trigger.
core_materialize = importlib.import_module(f"{_CORE_MODULE_NAME}.materialize")
#: The core constants module (``core.const.DEFAULT_HORIZON_DAYS``) the
#: regenerate route sizes the household rolling horizon with.
core_const = importlib.import_module(f"{_CORE_MODULE_NAME}.const")
#: The typed child DAO (``core.dao_children.ChildrenDao.get``) the
#: regenerate route reads a child's existence with before delegating.
core_dao_children = importlib.import_module(
f"{_CORE_MODULE_NAME}.dao_children"
)
#: The typed definition DAO (``core.dao_rules.QuestDefinitionsDao.get``)
#: the regenerate route reads a definition's existence with before
#: delegating.
core_dao_rules = importlib.import_module(f"{_CORE_MODULE_NAME}.dao_rules")
#: The HA-free missed sweep (``core.sweep.run_missed_sweep`` +
#: ``SWEEP_WATERMARK_KEY``) the admin plane's trigger route calls and
#: the daily scheduler runs — the ONE sweep policy the integration's
#: bus shim shares.
core_sweep = importlib.import_module(f"{_CORE_MODULE_NAME}.sweep")
__all__ = [
"core",
"core_children",
"core_completion",
"core_const",
"core_dao_children",
"core_dao_presence",
"core_dao_rules",
"core_db",
"core_events",
"core_history",
"core_materialize",
"core_migrations",
"core_presence",
"core_presence_management",
"core_quest_definitions",
"core_recurrence",
"core_snapshot",
"core_settings",
"core_settings_store",
"core_sweep",
]
+1732
View File
File diff suppressed because it is too large Load Diff
+373
View File
@@ -0,0 +1,373 @@
"""Panel-plane routes for the NestQuest API service.
The panel plane (``/api/v1/panel/*``, D-012) serves the wall panel's
reads. Every route on :data:`router` requires the panel service token
through the ONE reusable dependency :func:`api.dependencies.require_panel_token`
(declared once at the router level, so each later panel route added to
this router inherits the check).
The snapshot route is a thin adapter over the bundled core: it performs
NO business logic. It reads the live database off ``app.state.db``,
resolves ONE timezone-aware ``now`` (the API host's local time — the
API is the household's "local" host now that the domain no longer lives
inside Home Assistant), calls the pure
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
instances through :func:`nestquest_core.snapshot.instance_payload` with
``include_missed=False`` (D-009: missed instances are OMITTED from the
panel payload), and returns the documented JSON shape:
.. code-block:: json
{
"today_iso": "2026-09-22",
"cycle_day": 2,
"children": [
{
"child_id": 1,
"child_name": "Ada",
"present": false,
"next_present": "2026-09-24",
"due_today": 1,
"completed_today": 0,
"remaining_today": 1,
"completion_pct": 0,
"instances": [
{"id": 7, "definition_id": 3, "child_id": 1,
"title": "Pack bag", "icon": null, "window": "morning",
"due_time": "10:00", "state": "open", "overdue": true,
"completed_at": null, "on_time": null}
]
}
]
}
``children`` follows the household's sort order (the core builder lists
active children ``ORDER BY sort_order, id``); ``state`` is the
documented panel spelling ``open`` | ``completed`` (§1 of
design/ENTITIES-AND-SERVICES.md); ``cycle_day`` is today's 1-based day
in the first scheduled child's custody cycle (0 when no child has one).
The same :class:`PanelSnapshotResponse` Pydantic model is the route's
``response_model``, so the OpenAPI document carries the shape too.
"""
from __future__ import annotations
import datetime
import importlib
import json
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, field_validator
from api.database import DatabaseState
from api.dependencies import require_panel_token
from api.nestquest_core import (
core_completion,
core_events,
core_settings,
core_snapshot,
)
from api import transitions as api_transitions
#: The instance DAO, imported on the app's core copy for the
#: child-mismatch guard on the complete route.
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
#: All panel-plane routes share this router; the service-token check is
#: attached HERE so every panel route (current and later) requires it.
router = APIRouter(
prefix="/api/v1/panel",
dependencies=[Depends(require_panel_token)],
)
class PanelInstancePayload(BaseModel):
"""One instance in the documented panel payload shape (§1).
Mirrors :func:`nestquest_core.snapshot.instance_payload`:
``state`` is ``open`` | ``completed`` (missed instances never reach
the panel payload), ``on_time`` passes the completion event's flag
through verbatim (``None`` for an open instance), and
``completed_at`` is the event's UTC timestamp when completed.
"""
id: int
definition_id: int
child_id: int
title: str
icon: str | None
window: str
due_time: str | None
state: str
overdue: bool
completed_at: str | None
on_time: bool | None
class PanelChildPayload(BaseModel):
"""One child's day: presence, rollup counts, and today's instances."""
child_id: int
child_name: str
present: bool
#: ISO date of the child's next present day while they are away
#: (the away plate renders ``Returns <weekday>, <Mon D>`` from it);
#: ``None`` when the child is present today.
next_present: str | None
due_today: int
completed_today: int
remaining_today: int
completion_pct: int
instances: list[PanelInstancePayload]
class PanelSnapshotResponse(BaseModel):
"""The whole household snapshot the panel renders from.
``today_iso`` anchors the payload's day; ``cycle_day`` is today's
1-based day in the first scheduled child's custody cycle (0 when no
child has a schedule); ``children`` is in the household's sort
order.
"""
today_iso: str
cycle_day: int
children: list[PanelChildPayload]
class PanelCompleteRequest(BaseModel):
"""The body of ``POST /api/v1/panel/instances/{id}/complete``.
Matches the panel's ``nestquest.complete_quest`` payload (§2 of
design/ENTITIES-AND-SERVICES.md): ``actor_child_id`` is the tapped
profile. A positive integer is required — the core completion
layer rejects non-int (and bool) ids anyway, but rejecting them
here with 422 keeps the contract visible in the OpenAPI document
and fails before any database work.
"""
actor_child_id: int
model_config = {
"json_schema_extra": {
"description": (
"The tapped child profile the completion is "
"attributed to (decision 9)."
)
}
}
@field_validator("actor_child_id")
@classmethod
def _positive(cls, value: int) -> int:
"""Reject zero and negative ids with a validation error."""
if value < 1:
raise ValueError("actor_child_id must be a positive integer")
return value
def _local_now() -> datetime.datetime:
"""Return the API host's local time as ONE timezone-aware clock read.
``now().astimezone()`` attaches the host's local timezone to a
single clock read, so the calendar date and wall-clock time the
builder derives can never disagree. Kept as a module function (not
inlined) so tests pin the instant the same way the coordinator's
``_local_now`` is pinned.
"""
return datetime.datetime.now().astimezone()
@router.get(
"/snapshot",
summary="Today's household snapshot",
response_model=PanelSnapshotResponse,
)
async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
"""Return today's full household snapshot for the panel.
Requires the panel service token (checked by the router's shared
:func:`~api.dependencies.require_panel_token` dependency). Builds
the snapshot through the pure core builder against the live
database and shapes each child's instances with
``instance_payload(include_missed=False)`` — missed instances are
OMITTED from the panel payload (D-009) — then returns the shape
documented in this module's docstring and
:class:`PanelSnapshotResponse`.
"""
state: DatabaseState = request.app.state.db
snapshot = await core_snapshot.build_snapshot(
state.database, core_settings.NestQuestSettings(), _local_now()
)
return PanelSnapshotResponse(
today_iso=snapshot.today_iso,
cycle_day=snapshot.cycle_day,
children=[
PanelChildPayload(
child_id=child.child_id,
child_name=child.child_name,
present=child.present,
next_present=child.next_present,
due_today=child.due_today,
completed_today=child.completed_today,
remaining_today=child.remaining_today,
completion_pct=child.completion_pct,
instances=[
PanelInstancePayload.model_validate(instance)
for instance in core_snapshot.instance_payload(
child.instances, include_missed=False
)
],
)
for child in snapshot.children
],
)
@router.post(
"/instances/{instance_id}/complete",
summary="Complete one quest instance",
status_code=200,
)
async def panel_complete_instance(
instance_id: int, body: PanelCompleteRequest, request: Request
) -> dict[str, str]:
"""Complete one quest instance as the tapped child profile.
Requires the panel service token (checked by the router's shared
:func:`~api.dependencies.require_panel_token` dependency — the ONE
token check; this handler performs NO auth of its own).
Thin adapter only: resolves the live database off ``app.state.db``,
reads ONE timezone-aware clock (:func:`_local_now`), and delegates
to :func:`nestquest_core.completion.complete_instance` with
``actor_source="panel"`` and the tapped ``actor_child_id`` — the
append/idempotence logic, the lock, and the actor-shape validation
all live in the core layer, never here.
Response and idempotence: ``complete_instance`` returns a
:class:`~nestquest_core.completion.CompletionResult` whose
``appended`` flag reports whether THIS call wrote an event; a
repeat call for an already-completed instance is a no-op inside
the core layer (no duplicate event) and this route still answers
``{"status": "done"}`` either way, so a retried panel tap succeeds.
Error mapping (deliberately narrow): the ONLY ``ValueError`` mapped
to 404 is the unknown-``instance_id`` one, identified by the
``instance_id:`` prefix ``complete_instance`` names the failing
field with. Every other ``ValueError`` (an invalid actor shape,
which the body model and the panel actor contract already make
unreachable) re-raises to the framework's 500 — nothing else is
turned into a 404 or a 500 of our own making.
Child mismatch: ``complete_instance`` verifies the tapped
``actor_child_id`` exists but does NOT check it is the child the
instance belongs to. The route performs that check here (one
``QuestInstancesDao.get_by_id`` read) and answers 404 when the
instance's ``child_id`` differs — completing another child's quest
from the wrong profile must not succeed, and 404 (not 403) keeps
the instance's existence from leaking to a wrong-profile tap.
"""
state: DatabaseState = request.app.state.db
database = state.database
# Child-mismatch guard (see docstring): the instance must belong to
# the tapped profile before the completion layer is entered.
instance = await _dao_instances.QuestInstancesDao(database).get_by_id(
instance_id
)
if instance is not None and instance.child_id != body.actor_child_id:
raise HTTPException(
status_code=404,
detail="Quest instance not found for this child",
)
now = _local_now()
try:
result = await core_completion.complete_instance(
database,
instance_id,
actor_source="panel",
actor_child_id=body.actor_child_id,
now=now,
)
except ValueError as error:
message = str(error)
if message.startswith("instance_id:"):
# complete_instance names the field in its ValueError:
# "instance_id: quest instance N does not exist".
raise HTTPException(
status_code=404,
detail="Quest instance not found",
) from error
raise
# The derived state is "done" in both the appended and no-op cases;
# ``result.appended`` distinguishes them for callers that care —
# the panel route returns the same success either way.
if result.appended:
# An ACTUAL transition: publish the SSE transition event. The
# payload is built by the core builder (never hand-built here)
# and fan-out is fire-and-forget (see api/events.py).
event_type, payload = await core_events.build_quest_completed_event(
database, instance_id, was_on_time=result.was_on_time
)
request.app.state.publisher.publish(event_type, payload)
# THEN evaluate the day-complete rule off the completed event's
# payload (the core builder decides when the child's whole day
# is cleared — a zero-quest day and a non-today instance never
# fire it). ``today`` comes from the route's single clock read
# so the calendar date can never disagree with the completion's.
await api_transitions.publish_child_day_complete(
database,
payload,
now.date(),
request.app.state.publisher,
)
return {"status": str(result)}
async def _sse_stream(request: Request) -> AsyncIterator[str]:
"""Yield the SSE frames for every published transition event.
One subscriber of the app's single publisher (resolved off
``app.state.publisher``); each event becomes one
``event: <type>\\ndata: <json>\\n\\n`` frame. Breaking out of the
publisher's subscription (on client disconnect) tears the
subscription down cleanly — no queue is leaked.
"""
publisher = request.app.state.publisher
async for event_type, payload in publisher.subscribe():
yield (
f"event: {event_type}\n"
f"data: {json.dumps(payload)}\n\n"
)
@router.get(
"/events",
summary="Stream panel transition events (SSE)",
)
async def panel_events(request: Request) -> StreamingResponse:
"""Stream the app's transition events to the panel (Server-Sent
Events).
Requires the panel service token (checked by the router's shared
:func:`~api.dependencies.require_panel_token` dependency — the ONE
token check; this handler performs NO auth of its own).
Returns a ``text/event-stream`` response whose body is produced by
:func:`_sse_stream` off the app's single in-process publisher
(``app.state.publisher``, installed in the lifespan). The stream
ends when the client disconnects, at which point the subscription
is torn down (its queue removed) with nothing leaked.
"""
return StreamingResponse(
_sse_stream(request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache"},
)
__all__ = ["router"]
+173
View File
@@ -0,0 +1,173 @@
"""Daily missed-sweep background scheduler for the API service.
The API service has no Home Assistant, so nobody re-fires the
integration's day-rollover listener for it: this module owns the API
plane's own background job. One :class:`MissedSweepScheduler` per app
(installed on ``app.state`` by the lifespan) sleeps until the
configured ``day_rollover_time`` — read fresh from the settings store
at the top of EVERY cycle, so an admin's settings change applies on
the next scheduling decision without a restart — then runs the
HA-free core's :func:`nestquest_core.sweep.run_missed_sweep` for the
API host's local date and publishes each returned
``nestquest_quest_missed`` event on the app's ONE transition publisher
(the same SSE stream every route publishes through).
All sweep policy lives in the core (the watermark, the
``[watermark, today)`` window, the no-completion-event rule): this
scheduler only decides WHEN. A same-night rerun — e.g. two workers,
or a settings change moving the rollover past itself — fires nothing:
the core's watermark makes the run an empty no-op.
Testability: ``clock`` (an aware-now callable) and ``sleep`` (an
awaitable delay) are injectable, so tests pin the time, fast-forward
through the wait, and observe the computed delays without waiting a
real day. Cancellation: :meth:`stop` cancels the loop task and waits
for it, so the lifespan's shutdown is clean and a mid-run sweep is
interrupted before the connection closes. A failed iteration (a
database hiccup, a rejected setting) is logged and retried after a
fixed backoff rather than silently killing the job.
"""
from __future__ import annotations
import asyncio
import datetime
import logging
from api.nestquest_core import core_settings_store, core_sweep
LOGGER = logging.getLogger(__name__)
#: Backoff after a failed scheduling cycle (settings read or sweep
#: error), so a persistent failure retries every minute instead of
#: spinning or dying.
_RETRY_BACKOFF_SECONDS = 60.0
def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float:
"""Seconds from ``now`` until the next occurrence of ``target``.
``target`` is a plain ``HH:MM`` time of day: the next wall-clock
occurrence strictly AFTER ``now`` (an equal time schedules for
tomorrow, so the just-run sweep is never instantly re-run).
"""
scheduled = now.replace(
hour=target.hour, minute=target.minute, second=0, microsecond=0
)
if scheduled <= now:
scheduled += datetime.timedelta(days=1)
return (scheduled - now).total_seconds()
class MissedSweepScheduler:
"""Run the missed sweep once per day at ``day_rollover_time``."""
def __init__(
self,
database: object,
*,
publisher: object | None = None,
clock: object | None = None,
sleep: object | None = None,
) -> None:
"""Create the scheduler (not yet running).
``database`` is the app's live :class:`~api.database.NestQuestDatabase`.
``publisher`` is the app's ONE
:class:`~api.events.TransitionPublisher` (publishing is skipped
when ``None``). ``clock`` defaults to the API host's local
aware now (``datetime.datetime.now().astimezone()``) — the same
single-clock discipline the routes' ``_local_now`` uses;
``sleep`` defaults to :func:`asyncio.sleep`.
"""
self._database = database
self._publisher = publisher
self._clock = clock if clock is not None else (
lambda: datetime.datetime.now().astimezone()
)
self._sleep = sleep if sleep is not None else asyncio.sleep
self._task: asyncio.Task | None = None
def start(self) -> None:
"""Start the background loop (idempotent: a running one stays)."""
if self._task is None or self._task.done():
self._task = asyncio.create_task(
self._run_forever(), name="nestquest-missed-sweep"
)
async def stop(self) -> None:
"""Cancel the loop and wait for it to finish (safe if never started)."""
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
@property
def running(self) -> bool:
"""Whether the background loop task is alive."""
return self._task is not None and not self._task.done()
async def _run_forever(self) -> None:
"""Schedule → sweep → repeat, forever (until cancelled)."""
while True:
try:
settings = await core_settings_store.load_settings(
self._database
)
rollover = datetime.time.fromisoformat(
settings.day_rollover_time
)
except Exception:
# Not silent: the failure is logged and retried after
# the backoff, so one bad settings document or a
# transient database error never kills the scheduler.
LOGGER.exception(
"NestQuest missed-sweep scheduler failed to resolve "
"the day rollover time; retrying in %gs",
_RETRY_BACKOFF_SECONDS,
)
await self._sleep(_RETRY_BACKOFF_SECONDS)
continue
delay = _delay_seconds(rollover, self._clock())
LOGGER.info(
"NestQuest missed sweep scheduled for %s (%.0fs from now)",
settings.day_rollover_time,
delay,
)
await self._sleep(delay)
try:
await self._run_once(self._clock().date())
except Exception:
# Not silent either: the sweep error is logged and the
# loop backs off before the next cycle, whose watermark
# re-read makes an already-swept day a no-op.
LOGGER.exception(
"NestQuest scheduled missed sweep failed; retrying in "
"%gs",
_RETRY_BACKOFF_SECONDS,
)
await self._sleep(_RETRY_BACKOFF_SECONDS)
async def _run_once(self, today: datetime.date) -> None:
"""Run ONE sweep for ``today`` and publish what it built.
A failure propagates to :meth:`_run_forever`, which logs it and
backs off before the next cycle; the watermark makes a re-run
of an already-swept day announce nothing twice.
"""
events = await core_sweep.run_missed_sweep(
self._database, today=today
)
for event_type, payload in events:
if self._publisher is not None:
self._publisher.publish(event_type, payload)
LOGGER.info(
"NestQuest scheduled missed sweep for %s published %d event(s)",
today.isoformat(),
len(events),
)
__all__ = ["MissedSweepScheduler"]
+83
View File
@@ -0,0 +1,83 @@
"""Reusable SSE transition publishers for the API service (task 44c93cfd).
Each helper wraps the ONE publisher on ``app.state.publisher`` with a
single documented transition: it builds the payload through the core
event builders (``nestquest_core.events``) — never hand-built here —
and fans it out with :meth:`~api.events.TransitionPublisher.publish`.
The uncomplete route (follow-up task da0226b3) and the API-side missed
sweep do not exist yet; these helpers are the functions they will call,
and the SSE tests exercise them directly. Fan-out stays fire-and-forget
(see api/events.py): a slow subscriber drops the event and recovers by
polling the snapshot.
"""
from __future__ import annotations
import datetime
from api.nestquest_core import core_events
async def publish_quest_uncompleted(
database: object, instance_id: int, publisher: object
) -> None:
"""Publish ONE ``nestquest_quest_uncompleted`` event for an actual
reversal of ``instance_id``.
The payload is built by the core builder
(:func:`nestquest_core.events.build_quest_uncompleted_events`);
this helper only picks the single event out of the builder's list
and publishes it.
"""
events = await core_events.build_quest_uncompleted_events(
database, instance_id
)
for event_type, payload in events:
publisher.publish(event_type, payload)
async def publish_quest_missed(
database: object, instance_id: int, publisher: object
) -> None:
"""Publish ONE ``nestquest_quest_missed`` event for ``instance_id``.
The payload is built by the core builder
(:func:`nestquest_core.events.build_quest_missed_event`), which
mirrors the documented sweep payload exactly (§3 of
design/ENTITIES-AND-SERVICES.md).
"""
event_type, payload = await core_events.build_quest_missed_event(
database, instance_id
)
publisher.publish(event_type, payload)
async def publish_child_day_complete(
database: object,
completed_payload: dict,
today: datetime.date,
publisher: object,
) -> bool:
"""Publish ``nestquest_child_day_complete`` when a completion
cleared the child's whole day; return whether one was published.
The day-complete rule lives entirely in the core builder
(:func:`nestquest_core.events.build_child_day_complete_event`):
it returns ``None`` when the day did not clear (a zero-quest day
or a non-today instance) and this helper publishes nothing then.
"""
event = await core_events.build_child_day_complete_event(
database, completed_payload, today=today
)
if event is None:
return False
event_type, payload = event
publisher.publish(event_type, payload)
return True
__all__ = [
"publish_child_day_complete",
"publish_quest_missed",
"publish_quest_uncompleted",
]
+1
View File
@@ -496,6 +496,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass,
entry_id=entry.entry_id,
database=database,
settings=settings,
update_interval_seconds=entry.options.get(
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
),
+26 -196
View File
@@ -11,6 +11,15 @@ refresh updates the whole board coherently.
host clock and never UTC, matching the materialization and completion
layers.
The snapshot assembly itself lives in the Home-Assistant-free
:mod:`custom_components.nestquest.core.snapshot` module
(:func:`~core.snapshot.build_snapshot`); this coordinator is now a thin
HA wrapper that resolves the local timezone and reads the settings handed
into its constructor, then delegates to that builder, passing the SAME
HA-local ``now`` it resolved in one clock read. The API service's panel
snapshot route shares the same builder, so both consumers render ONE
snapshot shape.
Presence is resolved through the SAME engine the materializer uses
(:class:`~.presence.PresenceEngine`): schedules and overrides are read
with the public DAO methods and the engine's anchor-date arithmetic
@@ -27,13 +36,11 @@ from __future__ import annotations
import asyncio
import datetime
from dataclasses import dataclass
from zoneinfo import ZoneInfo
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .completion import derive_state
from .const import (
CONF_UPDATE_INTERVAL,
DEFAULT_UPDATE_INTERVAL,
@@ -41,66 +48,21 @@ from .const import (
LOGGER,
MIN_UPDATE_INTERVAL,
)
from .dao_children import ChildrenDao
from .dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
)
from .dao_rules import QuestDefinitionsDao
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
from .db import NestQuestDatabase
from .presence import (
MAX_PREVIEW_SCAN_DAYS,
PresenceEngine,
PresenceOverride,
PresenceSchedule,
from .core.db import NestQuestDatabase
from .core.settings import NestQuestSettings
from .core.snapshot import (
ChildDaySnapshot,
NestQuestSnapshot,
QuestInstanceView,
build_snapshot,
)
@dataclass(frozen=True)
class QuestInstanceView:
"""One instance plus everything the entities render from it."""
instance_id: int
definition_id: int
child_id: int
title: str
icon: str | None
window: str
due_date: str
due_time: str | None
state: str # open | done | missed
overdue: bool
completed_at: str | None
was_on_time: bool | None
@dataclass(frozen=True)
class ChildDaySnapshot:
"""One active child's day: instances, presence, and rollup counts."""
child_id: int
child_name: str
instances: tuple[QuestInstanceView, ...]
present: bool
due_today: int
completed_today: int
remaining_today: int
completion_pct: int
#: For a child away today: the ISO date of their next present day
#: (the panel's away plate renders ``Returns <weekday>, <Mon D>``
#: from it). ``None`` when the child is present today or no
#: present day exists within the presence engine's scan cap.
next_present: str | None = None
@dataclass(frozen=True)
class NestQuestSnapshot:
"""The coordinator's whole-entity data payload."""
today_iso: str
children: tuple[ChildDaySnapshot, ...]
cycle_day: int
__all__ = [
"ChildDaySnapshot",
"NestQuestCoordinator",
"NestQuestSnapshot",
"QuestInstanceView",
]
class NestQuestCoordinator(DataUpdateCoordinator):
@@ -121,6 +83,7 @@ class NestQuestCoordinator(DataUpdateCoordinator):
*,
entry_id: str,
database: NestQuestDatabase,
settings: NestQuestSettings,
update_interval_seconds: int | None = None,
) -> None:
interval = update_interval_seconds
@@ -138,6 +101,7 @@ class NestQuestCoordinator(DataUpdateCoordinator):
)
self.entry_id = entry_id
self.database = database
self.settings = settings
self._refresh_lock = asyncio.Lock()
async def async_refresh(self) -> None:
@@ -153,140 +117,6 @@ class NestQuestCoordinator(DataUpdateCoordinator):
async def _async_update_data(self) -> NestQuestSnapshot:
now = self._local_now()
today = now.date()
today_iso = today.isoformat()
children = await ChildrenDao(self.database).list_active()
schedules: dict[int, PresenceSchedule] = {}
overrides: dict[int, list[PresenceOverride]] = {}
for child in children:
schedule = await PresenceSchedulesDao(self.database).get_by_child(
child.id
)
if schedule is not None:
schedules[child.id] = PresenceSchedule.decode(
child.id, schedule.anchor_date, schedule.pattern
)
window_end = today + datetime.timedelta(
days=MAX_PREVIEW_SCAN_DAYS
)
window_end_iso = window_end.isoformat()
ranged = await PresenceOverridesDao(
self.database
).list_by_child_and_range(child.id, today_iso, window_end_iso)
if ranged:
# The engine validates MODEL objects, not DAO records;
# each record converts losslessly (the schema guarantees
# end >= start and a real is_present).
overrides[child.id] = [
PresenceOverride(
record.child_id,
record.start_date,
record.end_date,
record.is_present,
record.note,
)
for record in ranged
]
engine = PresenceEngine(schedules, overrides)
instances_dao = QuestInstancesDao(self.database)
events_dao = CompletionEventsDao(self.database)
definitions_dao = QuestDefinitionsDao(self.database)
snapshots: list[ChildDaySnapshot] = []
for child in children:
instances = await instances_dao.list_by_date_range(
child.id, today_iso, today_iso
)
views: list[QuestInstanceView] = []
for instance in instances:
latest = await events_dao.get_latest_for_instance(instance.id)
definition = await definitions_dao.get(instance.definition_id)
state = derive_state(instance, latest, today)
overdue = False
if state == "open" and instance.due_date == today_iso:
if instance.due_time is not None:
due_moment = datetime.datetime.strptime(
instance.due_time, "%H:%M"
).time()
overdue = now.time() > due_moment
views.append(
QuestInstanceView(
instance_id=instance.id,
definition_id=instance.definition_id,
child_id=instance.child_id,
title=definition.title if definition else "Quest",
icon=definition.icon if definition else None,
window=instance.window,
due_date=instance.due_date,
due_time=instance.due_time,
state=state,
overdue=overdue,
completed_at=(
latest.occurred_at
if latest is not None
and latest.event_type == "completed"
else None
),
was_on_time=(
latest.was_on_time
if latest is not None
and latest.event_type == "completed"
else None
),
)
)
due = len(views)
completed = sum(1 for view in views if view.state == "done")
remaining = due - completed
child_present = engine.is_present(child.id, today)
next_present: str | None = None
if not child_present:
try:
next_present = engine.next_present_dates(
child.id, today, 1
)[0].isoformat()
except ValueError:
next_present = None
snapshots.append(
ChildDaySnapshot(
child_id=child.id,
child_name=child.display_name,
instances=tuple(views),
present=child_present,
due_today=due,
completed_today=completed,
remaining_today=remaining,
completion_pct=(
round(completed * 100 / due) if due else 100
),
next_present=next_present,
)
)
return NestQuestSnapshot(
today_iso=today_iso,
children=tuple(snapshots),
cycle_day=_cycle_day(schedules, children, today),
return await build_snapshot(
self.database, self.settings, now
)
def _cycle_day(
schedules: dict[int, PresenceSchedule], children, today: datetime.date
) -> int:
"""Return today's 1-based day in the first scheduled child's cycle.
Anchor-date arithmetic ONLY (D-004): the day offset is computed from
the schedule's own anchor, never ISO week numbers or parity, so a
53-week year cannot silently invert the cycle.
"""
for child in children:
schedule = schedules.get(child.id)
if schedule is None:
continue
days = (today - schedule.anchor_date).days
cycle_length = schedule.cycle_length_weeks * 7
return (days % cycle_length) + 1
return 0
+21 -1
View File
@@ -14,7 +14,9 @@ fires nothing), and ``nestquest_child_day_complete`` fires when a
completion clears the child's whole day (quests were owed and none
remain — a zero-quest day can never fire it, matching the all-done
binary sensor's rule). ``nestquest_quest_missed`` is the documented
contract Feature 11's nightly sweep fires; nothing here builds it.
contract Feature 11's nightly sweep fires; :func:`build_quest_missed_event`
builds it with exactly the payload the sweep hand-builds (§3), so the
API plane can publish the same shape without re-deriving it.
The completed-event and day-complete-event builders are split so the
shim can fire ``nestquest_quest_completed`` BEFORE it evaluates the
@@ -50,6 +52,7 @@ from .completion import derive_state
from .const import (
EVENT_CHILD_DAY_COMPLETE,
EVENT_QUEST_COMPLETED,
EVENT_QUEST_MISSED,
EVENT_QUEST_UNCOMPLETED,
)
from .dao_children import ChildrenDao
@@ -188,3 +191,20 @@ async def build_quest_uncompleted_events(
"""
payload = await _instance_payload(database, instance_id, _now_stamp())
return [(EVENT_QUEST_UNCOMPLETED, payload)]
async def build_quest_missed_event(
database: NestQuestDatabase, instance_id: int
) -> tuple[str, dict]:
"""Build the ``nestquest_quest_missed`` event for one instance.
The payload mirrors the nightly sweep's hand-built payload exactly
(the shared :func:`_instance_payload` shape: ``child_id``,
``child_name``, ``instance_id``, ``quest_title``, ``window``,
``due_date``, ``due_time``, ``occurred_at``), so the API plane's
missed path publishes the same event shape the integration's sweep
fires. Like the sweep, this builder never mutates the instance or
writes a completion event — it reads only.
"""
payload = await _instance_payload(database, instance_id, _now_stamp())
return (EVENT_QUEST_MISSED, payload)
+405
View File
@@ -0,0 +1,405 @@
"""Read-only history layer for the admin plane (Admin spec §5, task 2d2dda53).
Assembles the History screen's event rows over the append-only
completion log: a query over a CLOSED date range ``[start_date,
end_date]`` for one of three filters —
- ``all`` — every completion event (completed AND uncompleted) whose
instance is due in the range;
- ``reversals`` — the uncompleted events only;
- ``missed`` — the DERIVED missed instances in the range.
Range semantics (Feature 13's rule, kept): the range scopes by the
INSTANCE's ``due_date`` — the day the task was owed — not the event's
wall-clock ``occurred_at``, because history reporting asks "what
happened to the tasks due that day". ``missed`` is derived, never
stored: rows come from :func:`~.completion.list_missed_for_child`
(past-due open instances, the same ``_derived_state`` rule the
coordinator uses), and nothing is ever appended for them.
Row shape: the Admin spec §5 event-row fields, fixed and documented in
:data:`CSV_COLUMNS` — ``occurred_at``, ``event_type``,
``child_name``, ``child_id``, ``quest_title``, ``instance_id``,
``window``, ``due_date``, ``due_time``, ``actor``, ``was_on_time``.
Derived missed rows carry ``event_type`` ``'missed'`` (never a stored
event_type), no ``occurred_at`` (there is no event), ``was_on_time``
``None`` and the spec's ``nightly sweep`` actor. A stored event's
``actor`` follows the spec's meta shape: the ``actor_user_id`` string
for a user event (``joshua``), ``panel (Child)`` naming the tapped
profile for a panel event.
Validation policy (module-level, shared by both the JSON query and the
CSV export): the filter must be one of :data:`FILTERS` and the range
must be two strict ``YYYY-MM-DD`` dates with ``end >= start``;
violations raise ``ValueError`` naming the offending field
(``filter`` / ``start`` / ``end``) BEFORE any read. The whole
household read runs under the task-reentrant connection lock so every
row of one answer is one snapshot — the same discipline
:mod:`.completion` gives its read-decide-write paths.
:func:`rows_to_csv` serializes rows with the :mod:`csv` module (proper
quoting, one stable header row) and :func:`export_history_csv` runs
query + serialization as ONE call, so the CSV export can never drift
from the JSON query's filtered range. No permission check lives here
(that is the admin router's single ``require_admin`` dependency).
"""
from __future__ import annotations
import csv
import datetime
import io
from dataclasses import dataclass
from .completion import _reentrant_connection_lock, list_missed_for_child
from .dao_children import ChildRecord, ChildrenDao
from .dao_instances import (
EVENT_UNCOMPLETED,
CompletionEventRecord,
CompletionEventsDao,
QuestInstanceRecord,
QuestInstancesDao,
)
from .dao_rules import QuestDefinitionsDao, _validate_date
from .db import NestQuestDatabase
#: The ``all`` filter: every completion event in the range.
FILTER_ALL = "all"
#: The ``reversals`` filter: uncompleted events only.
FILTER_REVERSALS = "reversals"
#: The ``missed`` filter: derived missed instances in the range.
FILTER_MISSED = "missed"
#: The filters the query accepts, in the Admin spec's chip order.
FILTERS = (FILTER_ALL, FILTER_REVERSALS, FILTER_MISSED)
#: The ``event_type`` a derived missed row reports. ``missed`` is a
#: DERIVED state, never a stored event_type (Feature 08).
EVENT_MISSED = "missed"
#: The actor a derived missed row reports (Admin spec §5 meta:
#: ``missed · nightly sweep``).
ACTOR_NIGHTLY_SWEEP = "nightly sweep"
#: The CSV column set, in header order — the Admin spec §5 event-row
#: fields. The header row is exactly this tuple, always, so a consumer
#: can key on column names.
CSV_COLUMNS = (
"occurred_at",
"event_type",
"child_name",
"child_id",
"quest_title",
"instance_id",
"window",
"due_date",
"due_time",
"actor",
"was_on_time",
)
@dataclass(frozen=True)
class HistoryRow:
"""One history row: a stored event or a derived missed instance.
The fields are :data:`CSV_COLUMNS` in order. For a derived missed
row ``occurred_at`` is ``None`` (no event exists) and ``was_on_time``
is ``None``; for a stored event ``event_type`` is
``'completed'``/``'uncompleted'`` and the fields come from the event
joined with its instance, child and definition.
"""
occurred_at: str | None
event_type: str
child_name: str
child_id: int
quest_title: str
instance_id: int
window: str
due_date: str
due_time: str | None
actor: str
was_on_time: bool | None
def _validate_filter(filter_name: str) -> str:
"""Return the filter, or raise ValueError naming the field."""
if filter_name not in FILTERS:
raise ValueError(
f"filter must be one of {', '.join(repr(f) for f in FILTERS)}, "
f"got {filter_name!r}"
)
return filter_name
def _validate_range(start_date: str, end_date: str) -> tuple[str, str]:
"""Validate the closed range and return it, else raise naming the field.
Both bounds must be strict ``YYYY-MM-DD`` dates (the shared
:func:`~.dao_rules._validate_date`, so non-padded or re-ordered
shapes are rejected and a non-string raises naming the field) and
``end`` must be on or after ``start``.
"""
_validate_date(start_date, "start")
_validate_date(end_date, "end")
if end_date < start_date:
raise ValueError(
f"end must be on or after start, got {end_date!r} < "
f"{start_date!r}"
)
return start_date, end_date
def _resolve_today(today: datetime.date | None) -> datetime.date:
"""Return the threaded HA-local date, or the host calendar date."""
if today is None:
return datetime.date.today()
return today
def _event_actor(
event: CompletionEventRecord, actor_child: ChildRecord | None
) -> str:
"""Return the spec-meta actor string for one stored event.
A user event reports the ``actor_user_id`` string (the spec's
``uncompleted · joshua (admin)`` shape); a panel event reports
``panel (Child)`` naming the tapped profile. The tapped child is
never hard-deleted, so the lookup below is a belt-and-braces
fallback to the stored id, never a silent rewrite of policy.
"""
if event.actor_source == "user":
return event.actor_user_id or ""
if actor_child is not None:
return f"panel ({actor_child.display_name})"
return f"panel ({event.actor_child_id})"
def _event_row(
event: CompletionEventRecord,
instance: QuestInstanceRecord,
child: ChildRecord,
quest_title: str,
actor_child: ChildRecord | None,
) -> HistoryRow:
"""Join one stored event with its instance, child and definition."""
return HistoryRow(
occurred_at=event.occurred_at,
event_type=event.event_type,
child_name=child.display_name,
child_id=event.child_id,
quest_title=quest_title,
instance_id=event.instance_id,
window=instance.window,
due_date=instance.due_date,
due_time=instance.due_time,
actor=_event_actor(event, actor_child),
was_on_time=event.was_on_time,
)
def _missed_row(
instance: QuestInstanceRecord,
child: ChildRecord,
quest_title: str,
) -> HistoryRow:
"""Build the derived row for one past-due open instance."""
return HistoryRow(
occurred_at=None,
event_type=EVENT_MISSED,
child_name=child.display_name,
child_id=instance.child_id,
quest_title=quest_title,
instance_id=instance.id,
window=instance.window,
due_date=instance.due_date,
due_time=instance.due_time,
actor=ACTOR_NIGHTLY_SWEEP,
was_on_time=None,
)
def _csv_values(row: HistoryRow) -> list[str]:
"""Return one CSV row's cell values in :data:`CSV_COLUMNS` order.
``None`` cells serialize as empty (a missed row has no
``occurred_at``); ``was_on_time`` serializes as ``true``/``false``
when the event carries the flag and empty when it does not.
"""
return [
row.occurred_at or "",
row.event_type,
row.child_name,
str(row.child_id),
row.quest_title,
str(row.instance_id),
row.window,
row.due_date,
row.due_time or "",
row.actor,
"" if row.was_on_time is None else str(row.was_on_time).lower(),
]
def rows_to_csv(rows: list[HistoryRow]) -> str:
"""Serialize history rows to CSV text with the documented header.
The FIRST row is always :data:`CSV_COLUMNS`, so the header is
stable and deterministic; the :mod:`csv` module handles quoting
(titles or names containing commas or quotes are quoted per RFC
4180) and CRLF line endings.
"""
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(CSV_COLUMNS)
for row in rows:
writer.writerow(_csv_values(row))
return buffer.getvalue()
async def query_history(
database: NestQuestDatabase,
filter_name: str,
start_date: str,
end_date: str,
*,
today: datetime.date | None = None,
) -> list[HistoryRow]:
"""Return the history rows for ``filter_name`` over the closed range.
Validates the filter and the strict ``YYYY-MM-DD`` range (raising
``ValueError`` naming ``filter`` / ``start`` / ``end``) BEFORE any
read, then builds the rows:
- ``all`` / ``reversals``: every child's events whose instances are
due in the range (:meth:`~.dao_instances.CompletionEventsDao.list_by_child_and_date_range`,
the Feature 13 due-date scope), ``reversals`` keeping only the
``uncompleted`` rows.
- ``missed``: :func:`~.completion.list_missed_for_child` per child —
the SAME derived-state rule the coordinator uses, never a new one,
and never a stored event.
Rows are ordered deterministically by ``due_date``, then instance,
then event id (the per-instance append order, so a completion and
its reversal keep their order). ``today`` pins the HA-local date
the missed derivation compares due dates against (the host calendar
date when omitted). The whole read runs under the task-reentrant
connection lock — :func:`list_missed_for_child` re-enters it — so
one answer is one snapshot.
"""
filter_name = _validate_filter(filter_name)
start_date, end_date = _validate_range(start_date, end_date)
today_date = _resolve_today(today)
keyed: list[tuple[tuple[str, int, int], HistoryRow]] = []
async with _reentrant_connection_lock(database):
children = await ChildrenDao(database).list_all()
children_by_id = {child.id: child for child in children}
instances = QuestInstancesDao(database)
definitions = QuestDefinitionsDao(database)
events = CompletionEventsDao(database)
titles: dict[int, str] = {}
if filter_name == FILTER_MISSED:
for child in children:
for instance in await list_missed_for_child(
database, child.id, start_date, end_date, today=today_date
):
if instance.definition_id not in titles:
definition = await definitions.get(
instance.definition_id
)
assert definition is not None, (
"a materialized instance's definition exists"
)
titles[instance.definition_id] = definition.title
keyed.append(
(
(instance.due_date, instance.id, 0),
_missed_row(
instance,
child,
titles[instance.definition_id],
),
)
)
keyed.sort(key=lambda pair: pair[0])
return [row for _, row in keyed]
for child in children:
for event in await events.list_by_child_and_date_range(
child.id, start_date, end_date
):
if filter_name == FILTER_REVERSALS and (
event.event_type != EVENT_UNCOMPLETED
):
continue
instance = await instances.get_by_id(event.instance_id)
assert instance is not None, (
"an event's instance is never deleted (append-only "
"history keeps its references)"
)
actor_child = (
children_by_id.get(event.actor_child_id)
if event.actor_child_id is not None
else None
)
if instance.definition_id not in titles:
definition = await definitions.get(instance.definition_id)
assert definition is not None, (
"a materialized instance's definition exists"
)
titles[instance.definition_id] = definition.title
keyed.append(
(
(instance.due_date, instance.id, event.id),
_event_row(
event,
instance,
child,
titles[instance.definition_id],
actor_child,
),
)
)
keyed.sort(key=lambda pair: pair[0])
return [row for _, row in keyed]
async def export_history_csv(
database: NestQuestDatabase,
filter_name: str,
start_date: str,
end_date: str,
*,
today: datetime.date | None = None,
) -> str:
"""Return the filtered history as CSV text, header row included.
ONE query + serialization call so the CSV export carries exactly
the JSON query's filtered range and order — the same
:func:`query_history` validation, rows and :func:`rows_to_csv`
serializer, never a second implementation.
"""
rows = await query_history(
database, filter_name, start_date, end_date, today=today
)
return rows_to_csv(rows)
__all__ = [
"ACTOR_NIGHTLY_SWEEP",
"CSV_COLUMNS",
"EVENT_MISSED",
"EVENT_UNCOMPLETED",
"FILTERS",
"FILTER_ALL",
"FILTER_MISSED",
"FILTER_REVERSALS",
"HistoryRow",
"export_history_csv",
"query_history",
"rows_to_csv",
]
@@ -0,0 +1,161 @@
"""Business layer for presence writes (Feature 09, API admin plane).
Sits between the write callers (the Feature 09 HA service handlers and
the API admin plane's presence routes) and the typed presence DAOs plus
the pure presence model: callers get the model validation, the upsert /
create / delete operations and the regeneration side effect here, and
never touch the DAO or SQL directly. This is the ONE implementation of
these business rules — every caller constructs the same
:class:`~.presence.PresenceSchedule` / :class:`~.presence.PresenceOverride`
models (whose constructors are total, so no re-validation happens in a
handler) and gets the same post-write
:func:`~.materialize.regenerate_for_child`, so a child's future quest
instances track its changed presence no matter which plane issued the
write. Like the model it is Home-Assistant-free: ``today`` is a plain
calendar date threaded through to the regeneration, defaulting to the
host clock there.
Upsert policy (Feature 05, unchanged): a child has AT MOST ONE presence
schedule — setting again REPLACES the existing row rather than
duplicating it. Overrides are deletable by design (children are not —
the children layer owns that guardrail). Deleting a nonexistent
override raises ValueError prefixed ``override_id:`` so a caller can
distinguish "unknown id" from a rejected argument the same way the
quest-definitions layer prefixes ``definition_id:``.
"""
from __future__ import annotations
import datetime
from .dao_presence import (
PresenceOverrideRecord,
PresenceOverridesDao,
PresenceSchedulesDao,
)
from .db import NestQuestDatabase
from .materialize import regenerate_for_child
from .presence import PresenceOverride, PresenceSchedule
def _validate_override_id(value: object) -> int:
"""Reject non-int override ids (bools would silently address id 1)."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"override_id must be an integer, got {value!r}")
return value
async def set_presence_schedule(
database: NestQuestDatabase,
child_id: int,
cycle_length_weeks: int,
anchor_date: object,
pattern: dict[int, object],
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> PresenceSchedule:
"""Set (upsert) a child's repeating presence schedule.
The :class:`~.presence.PresenceSchedule` model validates at
construction — cycle length 1..4, a strict calendar anchor date,
and a pattern covering week indices 0..n-1 with weekday sets — so
no invalid schedule reaches the DAO. The encoded pattern is
UPSERTed by child (a second set REPLACES the child's existing
schedule; the schema's UNIQUE(child_id) makes duplication
unrepresentable), then :func:`~.materialize.regenerate_for_child`
re-materializes the child's future instances against the new
presence. ``today``/``horizon_days`` thread through to that
regeneration; raising ValueError names what to fix (an unknown
child reads ``child N does not exist``).
Returns the STORED schedule decoded back through the model — the
round-trip proves the encoded pattern the database now holds is
exactly the one the caller asked for.
"""
schedule = PresenceSchedule(
child_id, cycle_length_weeks, anchor_date, pattern
)
record = await PresenceSchedulesDao(database).upsert_by_child(
schedule.child_id,
schedule.cycle_length_weeks,
schedule.anchor_date.isoformat(),
schedule.encode(),
)
await regenerate_for_child(
database, schedule.child_id, today=today, horizon_days=horizon_days
)
return PresenceSchedule.decode(
record.child_id, record.anchor_date, record.pattern
)
async def create_presence_override(
database: NestQuestDatabase,
child_id: int,
start_date: object,
end_date: object,
is_present: bool,
note: str | None = None,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> PresenceOverrideRecord:
"""Create one date-range presence override and return it as stored.
The :class:`~.presence.PresenceOverride` model validates at
construction — strict calendar dates, ``end_date`` on or after
``start_date``, a real ``is_present`` bool, a string-or-None note —
then the DAO insert applies the same-child no-overlap policy (a
conflicting range raises ValueError naming the existing override)
and :func:`~.materialize.regenerate_for_child` re-materializes the
child's future instances against the override's presence answer.
An unknown child reads ``child N does not exist``.
Returns the stored record WITH its ``id`` — the handle a later
:func:`delete_presence_override` takes.
"""
override = PresenceOverride(
child_id, start_date, end_date, is_present, note=note
)
record = await PresenceOverridesDao(database).create(
override.child_id,
override.start_date.isoformat(),
override.end_date.isoformat(),
override.is_present,
note=override.note,
)
await regenerate_for_child(
database, override.child_id, today=today, horizon_days=horizon_days
)
return record
async def delete_presence_override(
database: NestQuestDatabase,
override_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> PresenceOverrideRecord:
"""Delete one presence override by id and return it as it was stored.
The override is looked up FIRST and an unknown id raises ValueError
prefixed ``override_id:`` (naming the id) BEFORE any delete, so a
mistyped id can never be silently swallowed by a no-op. The
deletion is followed by :func:`~.materialize.regenerate_for_child`
for the override's own child — the id decides the child, never a
caller-supplied one. ``today``/``horizon_days`` thread through to
that regeneration.
"""
_validate_override_id(override_id)
dao = PresenceOverridesDao(database)
override = await dao.get(override_id)
if override is None:
raise ValueError(
f"override_id: presence override {override_id} does not exist"
)
await dao.delete(override_id)
await regenerate_for_child(
database, override.child_id, today=today, horizon_days=horizon_days
)
return override
@@ -0,0 +1,210 @@
"""Durable settings store for the NestQuest API service.
The API service has NO Home Assistant config entry, so it cannot build
:class:`~.settings.NestQuestSettings` from ``entry.options`` the way the
integration does. This module gives the API its own durable settings
source: a JSON document kept in the EXISTING ``nestquest_meta_state``
key/value table (:class:`~.dao_meta.MetaStateDao`) under the well-known
key :data:`SETTINGS_META_KEY` — no schema change, no new table —
validated through the SAME field table the settings object is built
from (:data:`~.settings._FIELDS`, the table driving
``from_options``/``from_options_resilient``). No validation rule is
re-implemented here.
OWNERSHIP: the API now owns settings in the database. The
integration's Home-Assistant config-entry options remain a temporary,
SEPARATE source until Feature 18 converts the integration into a thin
API client; the two are deliberately NOT unified here, and neither
side reads the other's values.
STORED SERIALIZATION — the document at ``SETTINGS_META_KEY`` is a JSON
object holding exactly the TEN settings fields, keyed by field name
(which equals the ``CONF_*`` option key for every field), complete
after the first update:
- ``horizon_days`` — int >= 1,
- ``day_rollover_time``, ``morning_summary_time``,
``afternoon_reminder_time``, ``end_of_day_report_time`` — strict
24-hour ``HH:MM`` strings,
- ``notify_target`` — a string (a padded value is stripped by the
core's normalizer),
- ``morning_summary_enabled``, ``afternoon_reminder_enabled``,
``end_of_day_report_enabled``, ``celebration_enabled`` — real
booleans.
Only values whose core validators ACCEPTED are ever written, so a
stored document always round-trips through the core's option builders.
An absent key (a fresh database) resolves to the all-defaults
settings, and a corrupt document degrades to defaults or per-field
defaults rather than crashing the service (see :func:`load_settings`).
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import Mapping
from typing import Any
from .const import LOGGER
from .dao_meta import MetaStateDao
from .db import NestQuestDatabase
from .settings import NestQuestSettings, _FIELDS
#: The well-known ``nestquest_meta_state`` key holding the settings
#: document. One key, one JSON object — the same table the missed
#: sweep's watermark already uses, so no migration is needed.
SETTINGS_META_KEY = "settings"
#: The per-field resolution table keyed by field name: each row is the
#: ``(default, validator)`` pair the settings module's ``_FIELDS`` table
#: carries for that field. ``update_settings`` resolves each PROVIDED
#: field through its row's validator — the SAME core validators
#: ``from_options`` applies — and never re-implements one here.
#: (Field names equal the ``CONF_*`` option keys, so one dict serves
#: both the API's field names and the stored document's keys.)
_RESOLUTIONS: dict[str, tuple[Any, Any]] = {
key: (default, validator) for _field, key, default, validator in _FIELDS
}
#: The known field names, in the settings module's field order — the
#: allowed keys of an update body and the stored document's key set.
_FIELD_NAMES: tuple[str, ...] = tuple(
key for _field, key, _default, _validator in _FIELDS
)
#: One asyncio.Lock per (connection wrapper, running loop), the missed
#: sweep's pattern: the whole stored-document read → merge → write span
#: is one critical section, so two concurrent updates queue instead of
#: one losing the other's change — the loser re-reads the document
#: INSIDE the lock and merges onto the winner's settings.
_SETTINGS_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
def _settings_lock(database: NestQuestDatabase) -> asyncio.Lock:
"""Return the settings lock bound to ``database``'s running loop."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _SETTINGS_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_SETTINGS_LOCKS[key] = lock
return lock
async def _load_document(database: NestQuestDatabase) -> dict[str, Any]:
"""Read and decode the stored settings document (empty when unusable).
An absent key — a fresh database that never updated settings — and
an unparseable value (invalid JSON, or JSON that is not an object)
return an empty mapping, which the option builders resolve to the
all-defaults settings; the corrupt cases log a warning first. A
parseable object is returned as stored (its per-field validity is
the builder's concern, not this reader's).
"""
raw = await MetaStateDao(database).get(SETTINGS_META_KEY)
if raw is None:
return {}
try:
document = json.loads(raw)
except ValueError:
LOGGER.warning(
"NestQuest stored settings (meta_state key %r) are not valid "
"JSON; falling back to the default settings",
SETTINGS_META_KEY,
)
return {}
if not isinstance(document, dict):
LOGGER.warning(
"NestQuest stored settings (meta_state key %r) are not a JSON "
"object; falling back to the default settings",
SETTINGS_META_KEY,
)
return {}
return document
def _named(field: str, error: ValueError) -> str:
"""Return a field-named message for a rejected validator value.
The horizon, time and notify-target validators already name their
field in the message; the boolean validator's message is generic
(``expected a boolean, got ...``), so it gets the field prefixed.
Every rejected update's error therefore names its field either way.
"""
message = str(error)
if message.startswith(f"{field} "):
return message
return f"{field}: {message}"
async def load_settings(database: NestQuestDatabase) -> NestQuestSettings:
"""Return the current effective settings stored for ``database``.
Reads :data:`SETTINGS_META_KEY` and resolves the stored document
through the core settings validators, with each absent field
falling back to its default. An absent key and an unparseable
document fall back to the all-defaults settings (via
:func:`_load_document`), and a parseable document with a single
invalid FIELD falls back PER FIELD — through
:meth:`NestQuestSettings.from_options_resilient`, the core's own
resilient builder — so a corrupt sibling never resets the stored
values around it. This never raises: the service must keep
serving reads even over a corrupt store.
"""
document = await _load_document(database)
return NestQuestSettings.from_options_resilient(document)
async def update_settings(
database: NestQuestDatabase, changes: Mapping[str, Any]
) -> NestQuestSettings:
"""Validate ``changes`` and persist them as the effective settings.
``changes`` maps settings field names to new values. EVERY name is
checked against the known field table BEFORE anything is validated
or written: an unknown name raises a ``ValueError`` naming it (and
the known names), leaving the stored settings untouched. Each
PROVIDED field is then resolved from_options-style — its
:data:`~.settings._FIELDS` validator applied, with a JSON ``null``
mapping to the field's default (the settings module's uniform
None-as-absent rule) — and an invalid value raises a ``ValueError``
naming the field, also BEFORE anything is written, so a rejected
update can never leave a half-applied document behind.
The validated changes are merged onto the current effective
settings and the merged (complete, ten-field) document is persisted
in one atomic upsert; the updated settings are returned. The whole
read → merge → write span runs inside the per-database settings
lock, so concurrent updates queue instead of interleaving.
"""
if not isinstance(changes, Mapping):
raise ValueError(
"settings changes must be a JSON object mapping field names "
"to values"
)
unknown = sorted(
str(name) for name in changes if name not in _RESOLUTIONS
)
if unknown:
listed = ", ".join(repr(name) for name in unknown)
known = ", ".join(_FIELD_NAMES)
raise ValueError(
f"unknown settings field(s): {listed}; known fields: {known}"
)
async with _settings_lock(database):
current = await load_settings(database)
document: dict[str, Any] = {
field: getattr(current, field) for field in _FIELD_NAMES
}
for name, raw_value in changes.items():
default, validator = _RESOLUTIONS[name]
try:
document[name] = validator(
default if raw_value is None else raw_value
)
except ValueError as error:
raise ValueError(_named(name, error)) from error
await MetaStateDao(database).set(
SETTINGS_META_KEY, json.dumps(document, sort_keys=True)
)
return NestQuestSettings(**document)
@@ -0,0 +1,306 @@
"""Pure, Home-Assistant-free panel snapshot builder.
The household snapshot the NestQuest panel renders — active children in
sort order, each child's today-instances in the documented payload shape
(missed instances OMITTED from the entity-facing attribute payload, but
retained on the snapshot for the count rollups), per-child counts and
presence, and the custody cycle day — used to be assembled inline inside
:mod:`custom_components.nestquest.coordinator`'s
``_async_update_data``. That assembly is HA-coupled only because its
host is a :class:`~homeassistant.helpers.update_coordinator.DataUpdateCoordinator`;
the assembly itself touches no HA surface.
This module holds the pure pieces — the snapshot dataclasses
(:class:`QuestInstanceView`, :class:`ChildDaySnapshot`,
:class:`NestQuestSnapshot`), the cycle-day helper (:func:`_cycle_day`),
the panel payload shaper (:func:`instance_payload`), and the async
:func:`build_snapshot` builder — so the HA coordinator and the API
service's panel snapshot route share ONE implementation of both the
snapshot assembly and the entity-facing attribute payload shape.
Nothing here imports :mod:`homeassistant`; the builder takes the
database, the settings object, and the HA-local ``now`` (a single
clock read the caller already resolved, from which it derives
``today``) and returns the snapshot, mirroring exactly what the
coordinator used to do inline.
``settings`` is accepted because the API route hands in the entry's
:class:`~.settings.NestQuestSettings`; it is CURRENTLY UNUSED — the
assembly reads nothing from it — and is retained so the builder's
contract stays stable if a future panel feature derives scan caps or
rollup behaviour from configuration. No speculative read is wired in.
"""
from __future__ import annotations
import datetime
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
from .completion import derive_state
from .dao_children import ChildrenDao
from .dao_instances import CompletionEventsDao, QuestInstancesDao
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
from .dao_rules import QuestDefinitionsDao
from .db import NestQuestDatabase
from .presence import (
MAX_PREVIEW_SCAN_DAYS,
PresenceEngine,
PresenceOverride,
PresenceSchedule,
)
from .settings import NestQuestSettings
@dataclass(frozen=True)
class QuestInstanceView:
"""One instance plus everything the entities render from it."""
instance_id: int
definition_id: int
child_id: int
title: str
icon: str | None
window: str
due_date: str
due_time: str | None
state: str # open | done | missed
overdue: bool
completed_at: str | None
was_on_time: bool | None
@dataclass(frozen=True)
class ChildDaySnapshot:
"""One active child's day: instances, presence, and rollup counts."""
child_id: int
child_name: str
instances: tuple[QuestInstanceView, ...]
present: bool
due_today: int
completed_today: int
remaining_today: int
completion_pct: int
#: For a child away today: the ISO date of their next present day
#: (the panel's away plate renders ``Returns <weekday>, <Mon D>``
#: from it). ``None`` when the child is present today or no
#: present day exists within the presence engine's scan cap.
next_present: str | None = None
@dataclass(frozen=True)
class NestQuestSnapshot:
"""The coordinator's whole-entity data payload."""
today_iso: str
children: tuple[ChildDaySnapshot, ...]
cycle_day: int
def _cycle_day(
schedules: dict[int, PresenceSchedule], children, today: datetime.date
) -> int:
"""Return today's 1-based day in the first scheduled child's cycle.
Anchor-date arithmetic ONLY (D-004): the day offset is computed from
the schedule's own anchor, never ISO week numbers or parity, so a
53-week year cannot silently invert the cycle.
"""
for child in children:
schedule = schedules.get(child.id)
if schedule is None:
continue
days = (today - schedule.anchor_date).days
cycle_length = schedule.cycle_length_weeks * 7
return (days % cycle_length) + 1
return 0
def instance_payload(
instances: Iterable[QuestInstanceView], *, include_missed: bool
) -> list[dict[str, Any]]:
"""Shape instance dicts per ENTITIES-AND-SERVICES.md §1.
The panel payload ``state`` is the documented ``open`` | ``completed``
spelling, so the builder's derived ``done`` maps to ``completed`` here;
``open`` passes through. ``missed`` instances are OMITTED from the
entity-facing attribute payload (decision 5) unless ``include_missed``
is set — the admin payload (D-009) keeps them with their ``missed``
state. ``on_time`` passes the builder's ``was_on_time`` through
verbatim (``True`` / ``False`` for a completed instance, ``None`` for
an open one). This is the SINGLE shared implementation both the HA
sensor layer and the API panel route read; it touches no Home
Assistant surface.
"""
payload: list[dict[str, Any]] = []
for view in instances:
if view.state == "missed" and not include_missed:
continue
payload.append(
{
"id": view.instance_id,
"definition_id": view.definition_id,
"child_id": view.child_id,
"title": view.title,
"icon": view.icon,
"window": view.window,
"due_time": view.due_time,
"state": (
"completed" if view.state == "done" else view.state
),
"overdue": view.overdue,
"completed_at": view.completed_at,
"on_time": view.was_on_time,
}
)
return payload
async def build_snapshot(
database: NestQuestDatabase,
settings: NestQuestSettings,
now: datetime.datetime,
) -> NestQuestSnapshot:
"""Build the household panel snapshot for ``today`` at the ``now`` instant.
Mirrors the assembly that lived in the coordinator's
``_async_update_data``: active children in sort order, each child's
today-instances with derived state (open / done / missed via
:func:`~.completion.derive_state`) and the overdue flag, per-child
presence (resolved through :class:`~.presence.PresenceEngine`), the
per-child rollup counts, and the custody cycle day of the first
scheduled active child. Missed instances are retained on the
snapshot (the rollup counts and the panel's missed plate need them)
but the entity-facing attribute payload omits them at the
:func:`instance_payload` layer — this builder produces the shared
shape both consumers read.
``now`` is the HA-local timezone-aware datetime the caller already
resolved (a SINGLE clock read); the overdue check compares
``now.time()`` against the instance's ``due_time`` for today's open
instances. The builder performs NO internal clock read — it never
calls :func:`datetime.datetime.now` — so the instant is pinned in
tests. ``today`` is derived INSIDE the builder as ``now.date()``,
so the calendar date that anchors the due-date and cycle-day
arithmetic is always the SAME clock read as the wall-clock time;
the two can never disagree. ``settings`` is accepted but currently
unused (see the module docstring).
"""
today = now.date()
today_iso = today.isoformat()
children = await ChildrenDao(database).list_active()
schedules: dict[int, PresenceSchedule] = {}
overrides: dict[int, list[PresenceOverride]] = {}
for child in children:
schedule = await PresenceSchedulesDao(database).get_by_child(
child.id
)
if schedule is not None:
schedules[child.id] = PresenceSchedule.decode(
child.id, schedule.anchor_date, schedule.pattern
)
window_end = today + datetime.timedelta(
days=MAX_PREVIEW_SCAN_DAYS
)
window_end_iso = window_end.isoformat()
ranged = await PresenceOverridesDao(
database
).list_by_child_and_range(child.id, today_iso, window_end_iso)
if ranged:
# The engine validates MODEL objects, not DAO records;
# each record converts losslessly (the schema guarantees
# end >= start and a real is_present).
overrides[child.id] = [
PresenceOverride(
record.child_id,
record.start_date,
record.end_date,
record.is_present,
record.note,
)
for record in ranged
]
engine = PresenceEngine(schedules, overrides)
instances_dao = QuestInstancesDao(database)
events_dao = CompletionEventsDao(database)
definitions_dao = QuestDefinitionsDao(database)
snapshots: list[ChildDaySnapshot] = []
for child in children:
instances = await instances_dao.list_by_date_range(
child.id, today_iso, today_iso
)
views: list[QuestInstanceView] = []
for instance in instances:
latest = await events_dao.get_latest_for_instance(instance.id)
definition = await definitions_dao.get(instance.definition_id)
state = derive_state(instance, latest, today)
overdue = False
if state == "open" and instance.due_date == today_iso:
if instance.due_time is not None:
due_moment = datetime.datetime.strptime(
instance.due_time, "%H:%M"
).time()
overdue = now.time() > due_moment
views.append(
QuestInstanceView(
instance_id=instance.id,
definition_id=instance.definition_id,
child_id=instance.child_id,
title=definition.title if definition else "Quest",
icon=definition.icon if definition else None,
window=instance.window,
due_date=instance.due_date,
due_time=instance.due_time,
state=state,
overdue=overdue,
completed_at=(
latest.occurred_at
if latest is not None
and latest.event_type == "completed"
else None
),
was_on_time=(
latest.was_on_time
if latest is not None
and latest.event_type == "completed"
else None
),
)
)
due = len(views)
completed = sum(1 for view in views if view.state == "done")
remaining = due - completed
child_present = engine.is_present(child.id, today)
next_present: str | None = None
if not child_present:
try:
next_present = engine.next_present_dates(
child.id, today, 1
)[0].isoformat()
except ValueError:
next_present = None
snapshots.append(
ChildDaySnapshot(
child_id=child.id,
child_name=child.display_name,
instances=tuple(views),
present=child_present,
due_today=due,
completed_today=completed,
remaining_today=remaining,
completion_pct=(
round(completed * 100 / due) if due else 100
),
next_present=next_present,
)
)
return NestQuestSnapshot(
today_iso=today_iso,
children=tuple(snapshots),
cycle_day=_cycle_day(schedules, children, today),
)
+143
View File
@@ -0,0 +1,143 @@
"""The nightly missed-quest sweep, Home-Assistant-free (Feature 11).
This is the HA-free heart of the missed sweep, shared by the two
planes: the Home Assistant integration's :mod:`~..sweep` shim (which
fires the returned events on ``hass.bus``) and the API service (which
publishes them on the SSE stream and can also trigger the run on
demand). It owns the ONE sweep policy:
At the configured day-rollover time (or on an explicit trigger) this
job finds every instance whose ``due_date`` is before the
household-local date, that has NO completion event, and that falls
after the persisted sweep watermark — and RETURNS one
``nestquest_quest_missed`` event per instance with the documented
payload. Like every core module, it never touches the Home Assistant
event bus and never mutates an instance: ``missed`` is derived state,
never an event_type (Feature 08's contract), so the sweep is read-only
over the domain tables.
Idempotency is the watermark: after a run, ``nestquest_meta_state``
records the household-local date the sweep ran, and the next run only
sweeps instances due in ``[watermark, today)``. A second run the same
night therefore fires nothing new, and a run after days of downtime
sweeps exactly the accumulated window once — no per-instance journal,
no re-announcing old misses. An instance swept as missed that is
later completed simply gains its completion event; history keeps both
rows (append-only), and the sweep's later runs skip it via the
no-completion-event rule.
The whole watermark read → query → build → write span runs inside the
per-database sweep lock (one lock per connection wrapper and running
loop, the settings store's pattern): an overlapping startup sweep and
a rollover sweep queue instead of interleaving, and the loser re-reads
the watermark INSIDE the lock — sees the winner's date — and announces
nothing twice.
The caller supplies ``today`` as a plain :class:`datetime.date`: the
household-local calendar date, read from ``hass.config.time_zone`` by
the integration or from the API host's local clock by the API. This
module never reads a timezone database or config of its own.
"""
from __future__ import annotations
import asyncio
import datetime
import logging
from .const import EVENT_QUEST_MISSED
from .dao_meta import MetaStateDao
from .dao_instances import QuestInstancesDao
from .db import NestQuestDatabase
# Intentional intra-package reuse of the shared payload builder: the
# missed event's payload is the documented §3 instance payload, and
# building it through the ONE builder keeps this sweep's payloads from
# drifting from the completion/uncompleted builders' shape.
from .events import _instance_payload
LOGGER = logging.getLogger(__name__)
#: meta_state key holding the household-local date the sweep last ran.
SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date"
#: One asyncio.Lock per (connection wrapper, running loop), mirroring
#: the settings store's pattern: the whole watermark read → query →
#: build → write span is one critical section, so an overlapping
#: startup sweep and rollover sweep queue instead of double-announcing.
#: The loser re-reads the watermark INSIDE the lock, sees the winner's
#: date, and fires nothing.
_SWEEP_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
def _sweep_lock(database: NestQuestDatabase) -> asyncio.Lock:
"""Return the sweep lock bound to ``database``'s running loop."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _SWEEP_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_SWEEP_LOCKS[key] = lock
return lock
#: Strict UTC ISO-8601 timestamp (the DAO layers' policy): one shape,
#: explicit offset, second precision.
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
async def run_missed_sweep(
database: NestQuestDatabase,
*,
today: datetime.date,
) -> list[tuple[str, dict]]:
"""Build one missed event per unswept past-due open instance.
``today`` is the household-local calendar date the sweep runs for
(the caller owns the clock: the integration reads it from
``hass.config.time_zone``, the API from its host's local time).
Returns the ``(event_type, payload)`` tuples to announce, EMPTY
when the watermark already covers ``today`` (a same-night rerun) —
the caller decides how to publish them and announces nothing on
its own.
The whole watermark read → query → build → write span runs inside
the per-database sweep lock: an overlapping startup sweep and a
rollover sweep queue instead of interleaving, and the loser
re-reads the watermark inside the lock — sees the winner's date —
and announces nothing twice. Every event in one run shares one
``occurred_at`` stamp (one run, one announcement moment), and the
payload comes from the shared
:func:`~.events._instance_payload` builder so the shape can never
drift from the documented §3 contract.
"""
today_iso = today.isoformat()
dao = MetaStateDao(database)
async with _sweep_lock(database):
watermark = await dao.get(SWEEP_WATERMARK_KEY)
if watermark == today_iso:
LOGGER.debug(
"NestQuest missed sweep already ran for %s", today_iso
)
return []
instances_dao = QuestInstancesDao(database)
rows = await instances_dao.list_missed_unswept(today_iso, watermark)
now_stamp = datetime.datetime.now(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
events: list[tuple[str, dict]] = []
for instance in rows:
events.append(
(
EVENT_QUEST_MISSED,
await _instance_payload(
database, instance.id, now_stamp
),
)
)
await dao.set(SWEEP_WATERMARK_KEY, today_iso)
LOGGER.info(
"NestQuest missed sweep for %s built %d event(s)",
today_iso,
len(events),
)
return events
@@ -0,0 +1,10 @@
"""Re-export of :mod:`custom_components.nestquest.core.presence_management` for the integration package.
The presence-write business layer lives in
:mod:`custom_components.nestquest.core.presence_management` (added for
task e4a9d31b); this module re-exports it so the integration's service
handlers call the ONE implementation the API admin plane shares.
"""
from __future__ import annotations
from .core.presence_management import * # noqa: F401,F403
+3 -36
View File
@@ -39,6 +39,7 @@ from .coordinator import (
NestQuestCoordinator,
QuestInstanceView,
)
from .core.snapshot import instance_payload
#: Home Assistant's state machine refuses states longer than 255
#: characters (its hard recorder/state limit). These sensors' states
@@ -63,40 +64,6 @@ def _state_within_limit(state: Any) -> Any:
return state
def _instance_payload(
instances: Iterable[QuestInstanceView], *, include_missed: bool
) -> list[dict[str, Any]]:
"""Shape instance dicts per ENTITIES-AND-SERVICES.md §1.
``state`` maps the coordinator's derived ``done`` to the panel's
``completed`` spelling; ``missed`` instances are omitted unless
``include_missed`` (the admin payload, D-009, keeps them with
their ``missed`` state).
"""
payload: list[dict[str, Any]] = []
for view in instances:
if view.state == "missed" and not include_missed:
continue
payload.append(
{
"id": view.instance_id,
"definition_id": view.definition_id,
"child_id": view.child_id,
"title": view.title,
"icon": view.icon,
"window": view.window,
"due_time": view.due_time,
"state": (
"completed" if view.state == "done" else view.state
),
"overdue": view.overdue,
"completed_at": view.completed_at,
"on_time": view.was_on_time,
}
)
return payload
#: State reported by the next-quest sensor when the child owes nothing
#: else today. A documented sentinel, never an empty string (an empty
#: state reads as "sensor went unavailable" in HA).
@@ -215,10 +182,10 @@ class NestQuestQuestsDueTodaySensor(_NestQuestChildDaySensor):
if child is None:
return None
return {
"instances": _instance_payload(
"instances": instance_payload(
child.instances, include_missed=False
),
"admin_instances": _instance_payload(
"admin_instances": instance_payload(
child.instances, include_missed=True
),
"child_id": child.child_id,
+12 -32
View File
@@ -4,9 +4,11 @@ Registration is domain-scoped and happens once (see
:func:`async_register_services`); handlers resolve a live database at
call time through the injected ``find_runtime`` callable. Permission
checks are Feature 09 later tasks — this module only validates payload
shape and forwards to the Feature 03/06/08 business layers (and the
presence DAOs). ``export_history_csv`` is registered with a schema but
raises until Feature 13. ``regenerate`` is supplied by the caller so
shape and forwards to the Feature 03/06/08 business layers (the
presence writes included, through :mod:`.presence_management` — the
ONE implementation the API admin plane shares).
``export_history_csv`` is registered with a schema but raises until
Feature 13. ``regenerate`` is supplied by the caller so
the Feature 07 handler stays the one in ``__init__``.
"""
from __future__ import annotations
@@ -22,6 +24,7 @@ from homeassistant.exceptions import HomeAssistantError
from . import children as children_layer
from . import completion as completion_layer
from . import presence_management as presence_management_layer
from . import quest_definitions as quest_definitions_layer
from .const import (
DOMAIN,
@@ -39,11 +42,8 @@ from .const import (
SERVICE_UNCOMPLETE_QUEST,
SERVICE_UPDATE_QUEST_DEFINITION,
)
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
from .events import fire_quest_completed, fire_quest_uncompleted
from .materialize import regenerate_for_child
from .permissions import permission_gate
from .presence import PresenceSchedule
from .recurrence import ScheduleRule
from .service_policy import SERVICE_POLICY
@@ -405,21 +405,12 @@ def _set_presence_pattern(
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
data = call.data
schedule = PresenceSchedule(
await presence_management_layer.set_presence_schedule(
runtime.database,
data["child_id"],
data["cycle_length_weeks"],
data["anchor_date"],
data["pattern"],
)
await PresenceSchedulesDao(runtime.database).upsert_by_child(
schedule.child_id,
schedule.cycle_length_weeks,
schedule.anchor_date.isoformat(),
schedule.encode(),
)
await regenerate_for_child(
runtime.database,
schedule.child_id,
**_generation_kwargs(hass, runtime),
)
@@ -432,16 +423,13 @@ def _create_presence_override(
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
data = call.data
await PresenceOverridesDao(runtime.database).create(
await presence_management_layer.create_presence_override(
runtime.database,
data["child_id"],
data["start_date"],
data["end_date"],
data["is_present"],
note=data.get("note"),
)
await regenerate_for_child(
runtime.database,
data["child_id"],
**_generation_kwargs(hass, runtime),
)
@@ -453,17 +441,9 @@ def _delete_presence_override(
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
dao = PresenceOverridesDao(runtime.database)
override_id = call.data["override_id"]
override = await dao.get(override_id)
if override is None:
raise HomeAssistantError(
f"override_id: presence override {override_id} does not exist"
)
await dao.delete(override_id)
await regenerate_for_child(
await presence_management_layer.delete_presence_override(
runtime.database,
override.child_id,
call.data["override_id"],
**_generation_kwargs(hass, runtime),
)
+36 -96
View File
@@ -1,70 +1,40 @@
"""The nightly missed-quest sweep (Feature 11).
"""The nightly missed-quest sweep (Feature 11) — the Home Assistant shim.
At the configured day-rollover time (the same HA-local listener that
re-materializes the rolling horizon) this job finds every instance
whose ``due_date`` is before the HA-local date, that has NO completion
event, and that falls after the persisted sweep watermark — and fires
ONE ``nestquest_quest_missed`` event per instance with the documented
payload. The sweep is read-only over the domain tables: it never
mutates an instance and never writes a completion event (``missed``
is derived state, never an event_type — Feature 08's contract).
The sweep's policy is the HA-free core's :mod:`~.core.sweep`: the
watermark read → ``list_missed_unswept`` query → payload build →
watermark span, all inside the per-database sweep lock, shared with
the API plane. This module is the integration's thin adapter over it:
at the configured day-rollover time (the same HA-local listener that
re-materializes the rolling horizon) and once at setup it calls the
core's :func:`~.core.sweep.run_missed_sweep` and fires each returned
event on ``hass.bus`` — ONE ``nestquest_quest_missed`` per past-due
open instance with the documented payload. The core never touches
Home Assistant; the firing lives here.
Idempotency is the watermark: after a run, ``nestquest_meta_state``
records the HA-local date the sweep ran, and the next run only
sweeps instances due in ``[watermark, today)``. A second run the
same night therefore fires nothing new, and a run after days of
downtime sweeps exactly the accumulated window once — no
per-instance journal, no re-announcing old misses. An instance swept
as missed that is later completed simply gains its completion event;
history keeps both rows (append-only), and the sweep's later runs
skip it via the no-completion-event rule.
Idempotency is the core's watermark: after a run, ``nestquest_meta_state``
records the HA-local date the sweep ran, and the next run only sweeps
instances due in ``[watermark, today)`` — a second run the same night
fires nothing new, and a run after days of downtime sweeps exactly the
accumulated window once. An instance swept as missed that is later
completed simply gains its completion event; the sweep is read-only
over the domain tables (``missed`` is derived state, never an
event_type — Feature 08's contract).
"""
from __future__ import annotations
import asyncio
import datetime
import logging
from zoneinfo import ZoneInfo
from .const import EVENT_QUEST_MISSED
from .dao_children import ChildrenDao
from .dao_instances import QuestInstancesDao
from .dao_meta import MetaStateDao
from .dao_rules import QuestDefinitionsDao
from .core.sweep import (
SWEEP_WATERMARK_KEY,
run_missed_sweep as _run_core_missed_sweep,
)
from .db import NestQuestDatabase
LOGGER = logging.getLogger(__name__)
#: meta_state key holding the HA-local date the sweep last ran.
SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date"
#: One asyncio.Lock per (connection wrapper, running loop), mirroring
#: the migration runner's pattern: the whole watermark read → query →
#: fire → write span is one critical section, so an overlapping startup
#: sweep and rollover sweep queue instead of double-announcing. The
#: loser re-reads the watermark INSIDE the lock, sees the winner's
#: date, and fires nothing.
_SWEEP_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
def _sweep_lock(database: NestQuestDatabase) -> asyncio.Lock:
"""Return the sweep lock bound to ``database``'s running loop."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _SWEEP_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_SWEEP_LOCKS[key] = lock
return lock
#: UTC ISO-8601 timestamp policy shared with the DAO layers.
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
def _now_stamp() -> str:
return datetime.datetime.now(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
__all__ = ["SWEEP_WATERMARK_KEY", "run_missed_sweep"]
async def run_missed_sweep(
@@ -79,54 +49,24 @@ async def run_missed_sweep(
materialization and completion layers); when omitted it is read
from ``hass.config.time_zone``. Returns the number of events
fired. Fires nothing and returns 0 when the watermark already
covers ``today`` (a same-night rerun).
The whole watermark read → query → fire → write span runs inside
the per-database sweep lock: an overlapping startup sweep and a
rollover sweep queue instead of interleaving, and the loser
re-reads the watermark inside the lock — sees the winner's date —
and announces nothing twice.
covers ``today`` (a same-night rerun) — the watermark read, the
query, the payload build and the watermark write all live in the
core's locked
:func:`~.core.sweep.run_missed_sweep`; this shim only fires each
returned event on the bus and counts them.
"""
today_date = (
today
if today is not None
else datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
)
today_iso = today_date.isoformat()
dao = MetaStateDao(database)
async with _sweep_lock(database):
watermark = await dao.get(SWEEP_WATERMARK_KEY)
if watermark == today_iso:
LOGGER.debug(
"NestQuest missed sweep already ran for %s", today_iso
)
return 0
instances_dao = QuestInstancesDao(database)
rows = await instances_dao.list_missed_unswept(today_iso, watermark)
children_dao = ChildrenDao(database)
definitions_dao = QuestDefinitionsDao(database)
now_stamp = _now_stamp()
fired = 0
for instance in rows:
child = await children_dao.get(instance.child_id)
definition = await definitions_dao.get(instance.definition_id)
hass.bus.async_fire(
EVENT_QUEST_MISSED,
{
"child_id": instance.child_id,
"child_name": child.display_name if child else None,
"instance_id": instance.id,
"quest_title": definition.title if definition else None,
"window": instance.window,
"due_date": instance.due_date,
"due_time": instance.due_time,
"occurred_at": now_stamp,
},
)
fired += 1
await dao.set(SWEEP_WATERMARK_KEY, today_iso)
events = await _run_core_missed_sweep(database, today=today_date)
for event_type, payload in events:
hass.bus.async_fire(event_type, payload)
fired = len(events)
LOGGER.info(
"NestQuest missed sweep for %s fired %d event(s)", today_iso, fired
"NestQuest missed sweep for %s fired %d event(s)",
today_date.isoformat(),
fired,
)
return fired
+10
View File
@@ -7,11 +7,21 @@ requires-python = ">=3.11"
dependencies = []
[dependency-groups]
# fastapi and uvicorn live in the DEV group only so the monorepo's test
# venv can exercise the API app (tests under tests/test_api_health.py).
# They are NOT runtime dependencies of the NestQuest HA integration.
# When the API deployable is packaged (Feature 17), its runtime
# dependencies — fastapi, uvicorn, and any others — MUST be declared in
# that deployable's own dependency manifest, not here.
dev = [
"fastapi>=0.115",
"httpx>=0.28",
"jinja2>=3",
"pyjwt[crypto]>=2.9",
"pytest>=8.0.0",
"pytest-asyncio>=0.24",
"pyyaml>=6",
"uvicorn>=0.30",
"voluptuous>=0.14",
]
+189
View File
@@ -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
View File
@@ -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
+760
View File
@@ -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
+464
View File
@@ -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",
}
+547
View File
@@ -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
+571
View File
@@ -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
+611
View File
@@ -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
+398
View File
@@ -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
+694
View File
@@ -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
+355
View File
@@ -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}"
)
+671
View File
@@ -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()
+709
View File
@@ -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
+466
View File
@@ -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"]
+306
View File
@@ -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
+4 -4
View File
@@ -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()
+28
View File
@@ -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"]
+3 -3
View File
@@ -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()
Generated
+451
View File
@@ -2,6 +2,153 @@ version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "annotated-doc"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "anyio"
version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
{ url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
{ url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
{ url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" },
{ url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" },
{ url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" },
{ url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" },
{ url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" },
{ url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" },
{ url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" },
{ url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" },
{ url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
@@ -11,6 +158,124 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "50.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" },
{ url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" },
{ url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" },
{ url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" },
{ url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" },
{ url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" },
{ url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" },
{ url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" },
{ url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" },
{ url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" },
{ url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" },
{ url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" },
{ url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" },
{ url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" },
{ url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" },
{ url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" },
{ url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" },
{ url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" },
{ url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" },
{ url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" },
{ url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" },
{ url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" },
{ url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" },
{ url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" },
{ url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" },
{ url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" },
{ url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" },
{ url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" },
{ url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" },
{ url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" },
{ url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" },
{ url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" },
{ url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" },
{ url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" },
{ url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" },
{ url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" },
{ url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" },
{ url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" },
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
{ url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" },
{ url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" },
{ url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" },
{ url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" },
{ url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" },
{ url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" },
]
[[package]]
name = "fastapi"
version = "0.141.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -113,10 +378,14 @@ source = { virtual = "." }
[package.dev-dependencies]
dev = [
{ name = "fastapi" },
{ name = "httpx" },
{ name = "jinja2" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pyyaml" },
{ name = "uvicorn" },
{ name = "voluptuous" },
]
@@ -124,10 +393,14 @@ dev = [
[package.metadata.requires-dev]
dev = [
{ name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.28" },
{ name = "jinja2", specifier = ">=3" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.9" },
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.24" },
{ name = "pyyaml", specifier = ">=6" },
{ name = "uvicorn", specifier = ">=0.30" },
{ name = "voluptuous", specifier = ">=0.14" },
]
@@ -149,6 +422,132 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" },
{ url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" },
{ url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" },
{ url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" },
{ url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" },
{ url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" },
{ url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" },
{ url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" },
{ url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" },
{ url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" },
{ url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" },
{ url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" },
{ url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" },
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
{ url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" },
{ url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" },
{ url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" },
{ url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" },
{ url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" },
{ url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" },
{ url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" },
{ url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" },
{ url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" },
{ url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" },
{ url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" },
{ url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" },
{ url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" },
{ url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" },
{ url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" },
{ url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" },
{ url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" },
{ url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" },
{ url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" },
{ url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" },
{ url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" },
{ url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" },
{ url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" },
{ url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" },
{ url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" },
{ url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" },
{ url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" },
{ url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" },
{ url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" },
{ url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" },
{ url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" },
{ url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" },
{ url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" },
{ url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" },
{ url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" },
{ url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" },
{ url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" },
{ url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" },
{ url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" },
{ url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" },
{ url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" },
{ url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" },
{ url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" },
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
{ url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" },
{ url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" },
{ url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" },
{ url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" },
{ url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" },
{ url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" },
{ url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
@@ -158,6 +557,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pyjwt"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]]
name = "pytest"
version = "9.1.1"
@@ -242,6 +655,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "starlette"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
@@ -251,6 +677,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "uvicorn"
version = "0.53.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/ad/04bbb797c84fc1f26cb171f7394716f4865ffb8d8c5e1eef42565c2dfa6b/uvicorn-0.53.0.tar.gz", hash = "sha256:a9356f0cb89b3b8621529c5d5eebd69bfe154f4c3f68b4cf2de47e45fa855c2e", size = 110881, upload-time = "2026-09-14T07:44:23.815Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/18/0eea75741ee812e9f598b687619ce2454f6c3a1c5cd21ea990ec6bd26f45/uvicorn-0.53.0-py3-none-any.whl", hash = "sha256:e8dca71ec86dce5f04e333f0d56cdedf942446e6643b9cea1af0d6d3a02cb03e", size = 87081, upload-time = "2026-09-14T07:44:22.179Z" },
]
[[package]]
name = "voluptuous"
version = "0.16.0"