Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f02b0f527 | ||
|
|
a3773fec8c | ||
|
|
e85180d492 | ||
|
|
0b7f71831b | ||
|
|
a30b852789 | ||
|
|
ca1165de3f | ||
|
|
d4a55b6b40 | ||
|
|
a07ce1b2f2 | ||
|
|
550a8b3e1e | ||
|
|
4da762173c | ||
|
|
ca477125d6 | ||
|
|
7fec3c76ef |
@@ -1,32 +0,0 @@
|
||||
---
|
||||
description: Independently verify NestQuest merge gates and merge approved heads
|
||||
mode: primary
|
||||
model: openrouter/deepseek/deepseek-v4.1-flash
|
||||
permission:
|
||||
edit: deny
|
||||
write: deny
|
||||
task: deny
|
||||
bash:
|
||||
"*": deny
|
||||
"/home/overseer/.local/share/nestquest-controller/scripts/nq-approve-merge *": allow
|
||||
external_directory:
|
||||
"*": ask
|
||||
"/tmp/opencode/*": allow
|
||||
"/home/overseer/.local/share/nestquest-controller/*": allow
|
||||
---
|
||||
|
||||
You are the NestQuest feature-to-dev approver, separate from the controller and
|
||||
developer sessions. Verify the current PR head equals the independently
|
||||
reviewed and approved commit,
|
||||
required checks pass, blocking findings are resolved, branch protection is
|
||||
satisfied, and Maestro has accurate links. Use only the approver Gitea identity
|
||||
for the merge. Do not change code or approve a stale head. Use only the
|
||||
installed nq-approve-merge launcher for Gitea inspection and merging. Merge
|
||||
only into dev; never merge into main. If any gate is
|
||||
missing, refuse the merge and give the exact missing evidence. Report the PR,
|
||||
reviewed head, resulting merge commit, and target branch.
|
||||
|
||||
Broker syntax: `nq-approve-merge inspect PR_NUMBER` or
|
||||
`nq-approve-merge merge PR_NUMBER REVIEWED_HEAD REVIEW_FILE`. Run one broker
|
||||
command per Bash call. The broker validates the current PR and review again
|
||||
before merging; do not substitute an ad hoc Gitea command.
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
description: Implement one assigned NestQuest task in an isolated worktree
|
||||
mode: primary
|
||||
model: openrouter/z-ai/glm-5.3-flash
|
||||
permission:
|
||||
task: deny
|
||||
external_directory:
|
||||
"*": ask
|
||||
"/tmp/opencode/*": allow
|
||||
---
|
||||
|
||||
You are the NestQuest development agent. Implement only the assigned Maestro
|
||||
task in the supplied worktree. Follow the task's acceptance criteria and the
|
||||
repository's instructions. Run relevant tests, commit on the task branch, and
|
||||
report the commit and test results. Keep changes inside the assigned worktree.
|
||||
Do not approve your own pull request. You may merge your task PR into the
|
||||
feature branch only after the independent reviewer approved its exact current
|
||||
head and the controller verified every task merge gate. Never merge a feature
|
||||
into dev or a release into main. Do not start another task, change role, or
|
||||
use another model. Do not read or print credential files except when the
|
||||
approved task merge requires the existing Gitea development credential.
|
||||
@@ -1,60 +0,0 @@
|
||||
---
|
||||
description: Reconcile NestQuest work and dispatch the next verified action
|
||||
mode: primary
|
||||
model: openrouter/deepseek/deepseek-v4.1-flash
|
||||
permission:
|
||||
doom_loop: deny
|
||||
task: deny
|
||||
external_directory:
|
||||
"*": ask
|
||||
"/tmp/opencode/*": allow
|
||||
"/home/overseer/.local/share/opencode/tool-output/*": allow
|
||||
---
|
||||
|
||||
You are the NestQuest controller. Work in the NestQuest repository. Maestro is
|
||||
the source of truth for feature and task state; CTXD is the source of truth for
|
||||
project instructions. At the start of every invocation, read the current
|
||||
NestQuest context in CTXD, including OPENCODE-ORCHESTRATOR-PROMPT.MD, then
|
||||
reconcile it with Maestro, Git, pull requests, and checks before dispatching.
|
||||
Follow the review, identity, and branch rules in that prompt.
|
||||
If the CTXD project or prompt is unavailable, do not dispatch or merge work;
|
||||
report the missing source and end with ORCHESTRATOR_STATE: BLOCKED.
|
||||
|
||||
One invocation is a controller tick, not an entire feature lifetime. Prefer a
|
||||
state-changing action over another identical status query. Never repeat an
|
||||
unchanged tool call three times. If work is asynchronous, wait or inspect a
|
||||
specific changed result. Do not claim that you are continuing while ending
|
||||
the turn without naming the next action.
|
||||
|
||||
The NESTQUEST_CONTROL_HOME environment variable names the installed launchers
|
||||
(normally /home/overseer/.local/share/nestquest-controller).
|
||||
For implementation, run "$NESTQUEST_CONTROL_HOME/scripts/nq-agent" with role
|
||||
developer, a task worktree, title, and task prompt file. For independent
|
||||
review, run "$NESTQUEST_CONTROL_HOME/scripts/nq-review" with the worktree,
|
||||
feature base ref, exact head ref, review prompt file, PR number, and an output
|
||||
file under /tmp/opencode. It publishes an exact-head Gitea approval only after
|
||||
Claude approves. The developer may merge
|
||||
a task PR into its feature branch only after independent approval of the exact
|
||||
head and verification of every task gate. For feature-to-dev approval and
|
||||
merge, run "$NESTQUEST_CONTROL_HOME/scripts/nq-agent" with role approver in
|
||||
a separate session after the independent review. Save the Claude review output
|
||||
in a file under /tmp/opencode and give that file and its reviewed head to the
|
||||
approver. Never substitute a different model or reviewer silently.
|
||||
|
||||
When a feature is integrated into dev, reconcile Maestro, then immediately
|
||||
select and dispatch the next eligible feature. Feature completion is a
|
||||
transition, not the end of the controller's job. A release awaiting Joshua's
|
||||
main merge does not prevent work on independent eligible features.
|
||||
|
||||
End each invocation with exactly one final line using one of these values:
|
||||
|
||||
ORCHESTRATOR_STATE: CONTINUE
|
||||
ORCHESTRATOR_STATE: QUIESCENT
|
||||
ORCHESTRATOR_STATE: BLOCKED
|
||||
|
||||
Use CONTINUE when there is any eligible or in-flight work; include the feature,
|
||||
task, and next concrete action immediately above the final line. Use QUIESCENT
|
||||
only after verifying no eligible or in-flight work remains in Maestro and the
|
||||
repository. Use BLOCKED only when a specific decision or access is required;
|
||||
include evidence and the needed human action. If you cannot establish the
|
||||
state, use BLOCKED. Never emit a success claim based only on a child's summary.
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
description: Run the NestQuest orchestration loop in this foreground terminal
|
||||
mode: primary
|
||||
model: openrouter/deepseek/deepseek-v4.1-flash
|
||||
permission:
|
||||
doom_loop: deny
|
||||
task: deny
|
||||
external_directory:
|
||||
"*": ask
|
||||
"/tmp/opencode/*": allow
|
||||
"/home/overseer/.local/share/opencode/tool-output/*": allow
|
||||
---
|
||||
|
||||
You are the foreground NestQuest orchestrator. The user explicitly launched
|
||||
this session to run the development queue now. Read the supplied terminal
|
||||
orchestrator prompt and current CTXD project context, but follow the
|
||||
FOREGROUND TERMINAL RUNTIME for this session instead of CTXD's service runtime
|
||||
section.
|
||||
|
||||
Continuously reconcile Maestro, CTXD, Git, pull requests, checks, and child
|
||||
agent results. Execute the next useful transition in this same session. Do
|
||||
not finish after dispatch, review, remediation, or merge while eligible work
|
||||
remains. Use the installed role launchers for developer, reviewer, and
|
||||
approver isolation. Never start or wait for the systemd controller service.
|
||||
Never repeat an identical unchanged tool call three times; wait on the
|
||||
specific child or inspect newly changed evidence.
|
||||
|
||||
End only at a global terminal condition, an explicit user stop, a genuine
|
||||
global access/decision block with no other eligible work, or a host-forced
|
||||
yield. On a forced yield, return a continuation checkpoint that can be
|
||||
resumed in the same OpenCode session.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
description: Run the NestQuest orchestrator now in this OpenCode terminal
|
||||
agent: terminal-orchestrator
|
||||
---
|
||||
|
||||
Run the NestQuest project queue now in this foreground OpenCode session.
|
||||
Read the current CTXD prompt and Maestro state, but use the FOREGROUND
|
||||
TERMINAL RUNTIME instead of the service runtime. Reconcile repository and
|
||||
pull-request state, then execute CONTROLLER_TICK repeatedly until a global
|
||||
terminal condition, explicit user stop, genuine global block, or host-forced
|
||||
yield. Do not start or wait for the systemd controller.
|
||||
@@ -1,77 +0,0 @@
|
||||
# NestQuest foreground orchestrator
|
||||
|
||||
OpenCode 1.18.32 loads the named agents from `.opencode/agents/`. Their
|
||||
models match the current NestQuest CTXD prompt: DeepSeek V4.1 Flash for the
|
||||
foreground orchestrator and approver, and GLM 5.3 Flash for development.
|
||||
Run orchestration directly from a shell:
|
||||
|
||||
```bash
|
||||
nq-orchestrate
|
||||
nq-orchestrate mission.md
|
||||
cat mission.md | nq-orchestrate -
|
||||
nq-orchestrate --print-prompt > nestquest-orchestrator.md
|
||||
```
|
||||
|
||||
The launcher renders the project prompt with the foreground terminal runtime,
|
||||
stops the dormant legacy service, refuses immediately when another NestQuest
|
||||
TUI is open, and then replaces itself with OpenCode using
|
||||
`terminal-orchestrator`. There is no background polling process and no wait
|
||||
for a controller tick. Exit or interrupt the TUI to stop the foreground run;
|
||||
resume that OpenCode session when a host-forced yield needs continuation.
|
||||
|
||||
`scripts/nq-agent` launches developer and approver sessions with fixed agent
|
||||
definitions and no `--auto` flag. Headless OpenCode runs use `--pure` so the
|
||||
global idle-notification plugin does not notify on every controller tick.
|
||||
The approver's shell permission permits only the installed
|
||||
`nq-approve-merge` broker for Gitea. That broker requires exact-head
|
||||
Claude approval evidence, an exact-head Gitea approval by `review_agent`,
|
||||
the `dev` target, a mergeable PR, and passing configured checks before it
|
||||
requests a squash merge with Gitea's atomic `head_commit_id` guard.
|
||||
`scripts/nq-review` launches the independent
|
||||
Claude review in a noninteractive read-only session in the task worktree,
|
||||
checks the head again afterward, saves
|
||||
the output under `/tmp/opencode`, and posts the Gitea approval.
|
||||
The approver verifies the reviewed head again before a merge.
|
||||
|
||||
The terminal launcher uses a stable copy of this configuration and scripts
|
||||
at `/home/overseer/.local/share/nestquest-controller`; it does not depend on
|
||||
which task branch is checked out in the primary NestQuest directory. Install
|
||||
that copy, run `scripts/nq-controller --check`, and verify the OpenCode
|
||||
agents (terminal-orchestrator, developer, and approver)
|
||||
with `opencode debug agent <name>` while `OPENCODE_CONFIG_DIR` points at the
|
||||
installed `.opencode` directory. Install `scripts/nq-orchestrate` in
|
||||
`~/.local/bin/`. For the global slash command, also install
|
||||
`.opencode/agents/terminal-orchestrator.md` under
|
||||
`~/.config/opencode/agents/` and
|
||||
`deploy/opencode-global-commands/start-nestquest-development.md` under
|
||||
`~/.config/opencode/commands/`. The old `nestquest-controller.service` remains static and
|
||||
inactive only for migration safety; foreground launch stops it.
|
||||
|
||||
Inside an already-open OpenCode TUI, `/start-development` (or the global
|
||||
`/start-nestquest-development`) switches to the same foreground
|
||||
`terminal-orchestrator` behavior immediately. The shell launcher is the
|
||||
preferred entry point when supplying a mission prompt file.
|
||||
|
||||
`templates/ORCHESTRATOR-PROMPT.template.md` and
|
||||
`templates/nestquest.profile.json` make the current CTXD cycle prompt
|
||||
adaptable to another project. See `templates/README.md` before publishing a
|
||||
rendered prompt for a new project.
|
||||
|
||||
The CTXD preflight reads the configured HTTP/MCP server's NestQuest prompt.
|
||||
It deliberately does not inspect `/home/overseer/.ctx/ctxd.db`, which belongs
|
||||
to a different local store and lacks this project.
|
||||
|
||||
All four agent credentials are currently readable by the same Unix account.
|
||||
The Gitea review and merge gates enforce normal workflow sequencing, but
|
||||
they are not a hard isolation boundary against a process running as that
|
||||
account. Strong isolation requires separate OS identities and protected
|
||||
credentials for the controller, reviewer, and approver.
|
||||
|
||||
The terminal runtime repeatedly executes CONTROLLER_TICK within the foreground
|
||||
session. Neither a feature merge nor a progress report ends the run while
|
||||
eligible work remains. A host-forced yield returns a continuation checkpoint
|
||||
that can be resumed in the same OpenCode session.
|
||||
|
||||
Do not put tokens in this repository or in unit files. Existing OpenCode MCP
|
||||
connections supply CTXD and Maestro. Rotate those MCP tokens as a coordinated
|
||||
operation across every consumer before revoking the old values.
|
||||
+1
-12
@@ -48,7 +48,6 @@ 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):
|
||||
@@ -58,11 +57,7 @@ def _build_lifespan(db_path: str):
|
||||
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.
|
||||
every route and SSE subscriber shares that single instance.
|
||||
"""
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -70,16 +65,10 @@ def _build_lifespan(db_path: str):
|
||||
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
|
||||
|
||||
@@ -116,11 +116,6 @@ core_snapshot = importlib.import_module(f"{_CORE_MODULE_NAME}.snapshot")
|
||||
#: ``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")
|
||||
@@ -129,17 +124,6 @@ core_events = importlib.import_module(f"{_CORE_MODULE_NAME}.events")
|
||||
#: 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")
|
||||
@@ -198,12 +182,6 @@ core_dao_children = importlib.import_module(
|
||||
#: 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",
|
||||
@@ -214,7 +192,6 @@ __all__ = [
|
||||
"core_dao_rules",
|
||||
"core_db",
|
||||
"core_events",
|
||||
"core_history",
|
||||
"core_materialize",
|
||||
"core_migrations",
|
||||
"core_presence",
|
||||
@@ -223,6 +200,4 @@ __all__ = [
|
||||
"core_recurrence",
|
||||
"core_snapshot",
|
||||
"core_settings",
|
||||
"core_settings_store",
|
||||
"core_sweep",
|
||||
]
|
||||
|
||||
+1
-436
@@ -92,70 +92,6 @@ pattern over :mod:`nestquest_core.completion` and
|
||||
instance. The response carries the scope, the scoped id (if any)
|
||||
and the core's upsert ``count``.
|
||||
|
||||
The history routes (task 2d2dda53) follow the same pattern over
|
||||
:mod:`nestquest_core.history` — the ONE read-only history layer, which
|
||||
owns every rule so the two routes cannot drift:
|
||||
|
||||
- ``GET /history?filter=all|reversals|missed&start=YYYY-MM-DD&end=YYYY-MM-DD``
|
||||
returns the history rows for ONE filter over the CLOSED due-date
|
||||
range ``[start, end]``: ``all`` (every completion event — completed
|
||||
AND uncompleted — whose instance is due in the range), ``reversals``
|
||||
(the uncompleted events only) and ``missed`` (the DERIVED past-due
|
||||
open instances, computed by the core's own derive-state rule —
|
||||
``missed`` is never a stored event_type and nothing is appended).
|
||||
The range scopes by the instance's ``due_date`` (the Feature 13
|
||||
rule: history asks what happened to the tasks due that day), not the
|
||||
event's wall-clock stamp. The filter and date-range policies live
|
||||
in the core (strict ``YYYY-MM-DD``, ``end >= start``, a
|
||||
``ValueError`` naming the offending field); the handler is a thin
|
||||
adapter that threads ONE clock read (:func:`_local_now`, the missed
|
||||
derivation's ``today``) and serializes the core's rows.
|
||||
- ``GET /history.csv?...`` exports the SAME filtered range as CSV:
|
||||
ONE :func:`nestquest_core.history.export_history_csv` call — the
|
||||
same query path the JSON route uses — answered as ``text/csv`` with
|
||||
a ``Content-Disposition`` attachment filename and the documented,
|
||||
stable header row (Admin spec §5 event-row fields).
|
||||
|
||||
The settings routes (task 2b3de7e5) follow the same pattern over
|
||||
:mod:`nestquest_core.settings_store` — the API's OWN durable settings
|
||||
store. The API now OWNS settings in the database (a validated JSON
|
||||
document in the existing ``nestquest_meta_state`` key/value table); the
|
||||
integration's Home-Assistant config-entry options remain a temporary,
|
||||
SEPARATE source until Feature 18 wires the client to the API — the two
|
||||
are deliberately not unified here:
|
||||
|
||||
- ``GET /settings`` returns the current effective settings: all ten
|
||||
documented fields (the rolling horizon, the day rollover time, the
|
||||
notify target, the three notification times and the four enable
|
||||
toggles) — the defaults on a fresh database.
|
||||
- ``PATCH /settings`` updates ONLY the supplied fields: the body is a
|
||||
JSON object of settings field names to new values, passed STRAIGHT
|
||||
to :func:`nestquest_core.settings_store.update_settings` — the
|
||||
unknown-field rejection, the per-field validation (the core settings
|
||||
validators: a positive int, strict ``HH:MM`` strings, real booleans)
|
||||
and the validate-everything-before-write atomicity all live in the
|
||||
core, so a rejected update never changes the stored settings. A
|
||||
JSON ``null`` value resets its field to the default (the settings
|
||||
module's uniform None-as-absent rule). The response is the updated
|
||||
effective settings, the same shape ``GET`` returns.
|
||||
|
||||
The missed-sweep trigger (task 058c7b69) follows the same pattern over
|
||||
:mod:`nestquest_core.sweep` — the HA-free sweep policy the
|
||||
integration's rollover listener shares:
|
||||
|
||||
- ``POST /missed-sweep`` runs the sweep for the API host's local
|
||||
``today`` (ONE :func:`_local_now` clock read threaded into the core
|
||||
call) and publishes each returned ``(event_type, payload)`` pair
|
||||
DIRECTLY on the app's ONE transition publisher — the sweep-built
|
||||
payload, never re-fetched (the core commits its watermark before
|
||||
returning, so a failed re-fetch after it would lose the missed event
|
||||
forever), the same publish-what-the-core-built shape
|
||||
api/scheduler.py uses. The watermark rule
|
||||
(a same-day rerun announces nothing, a post-downtime run sweeps the
|
||||
accumulated window once), the no-completion-event rule and the
|
||||
read-only-over-the-domain-tables guarantee all live in the core; the
|
||||
response reports how many transitions were published.
|
||||
|
||||
Error mapping (every children, quest-definition and presence route,
|
||||
deliberately narrow):
|
||||
|
||||
@@ -205,22 +141,6 @@ deliberately narrow):
|
||||
stance. Every core ``ValueError`` (a rejected horizon, a malformed
|
||||
date — unreachable from this body model, but mapped anyway) is 422;
|
||||
anything else re-raises.
|
||||
- The HISTORY routes have no path ids, so they have no 404 case: a
|
||||
missing query parameter (the request's shape) is FastAPI's 422
|
||||
before any handler code runs, and every core ``ValueError`` — an
|
||||
unknown filter, a non-strict or inverted date range, each naming the
|
||||
offending field (``filter`` / ``start`` / ``end``) — is 422
|
||||
(:func:`_raise_history_error`): well-formed but semantically
|
||||
invalid, the same failure class Pydantic reports. Only
|
||||
``ValueError`` is caught, so an unexpected failure re-raises rather
|
||||
than becoming a 4xx.
|
||||
- The SETTINGS routes have no path ids either, so they have no 404
|
||||
case: a body that is not a JSON object is FastAPI's 422 before any
|
||||
handler code runs, and every core ``ValueError`` — an unknown field
|
||||
or an invalid value, each naming the offending field, raised BEFORE
|
||||
any write so the stored settings are never half-changed — is 422
|
||||
(:func:`_raise_settings_error`). Only ``ValueError`` is caught, so
|
||||
an unexpected failure re-raises rather than becoming a 4xx.
|
||||
|
||||
PATCH edit semantics: the body model's fields are optional and only
|
||||
SUPPLIED fields are forwarded (``model_dump(exclude_unset=True)``), so
|
||||
@@ -234,7 +154,7 @@ from __future__ import annotations
|
||||
import datetime
|
||||
from typing import Annotated, Literal, NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
|
||||
from api import transitions as api_transitions
|
||||
@@ -246,15 +166,11 @@ from api.nestquest_core import (
|
||||
core_const,
|
||||
core_dao_children,
|
||||
core_dao_rules,
|
||||
core_history,
|
||||
core_materialize,
|
||||
core_presence,
|
||||
core_presence_management,
|
||||
core_quest_definitions,
|
||||
core_recurrence,
|
||||
core_settings,
|
||||
core_settings_store,
|
||||
core_sweep,
|
||||
)
|
||||
|
||||
#: All admin-plane routes share this router; the ONE admin dependency
|
||||
@@ -610,79 +526,6 @@ class AdminRegenerateResponse(BaseModel):
|
||||
count: int
|
||||
|
||||
|
||||
class AdminHistoryRow(BaseModel):
|
||||
"""One history row as the admin plane serializes it.
|
||||
|
||||
Mirrors :class:`nestquest_core.history.HistoryRow` — the Admin spec
|
||||
§5 event-row fields, the same set the CSV export's header carries.
|
||||
A stored event carries its UTC ``occurred_at`` stamp and the
|
||||
``completed``/``uncompleted`` ``event_type``; a derived missed row
|
||||
(``missed`` is NEVER a stored event_type) carries ``event_type``
|
||||
``'missed'``, ``occurred_at`` ``None`` (there is no event) and the
|
||||
``nightly sweep`` actor.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
class AdminHistoryResponse(BaseModel):
|
||||
"""The history query's answer.
|
||||
|
||||
``filter``/``start``/``end`` echo the request, ``count`` is the row
|
||||
count, and ``rows`` are the core's rows in its deterministic order
|
||||
(``due_date``, then instance, then event id — the per-instance
|
||||
append order, so a completion and its reversal keep their order).
|
||||
"""
|
||||
|
||||
filter: str
|
||||
start: str
|
||||
end: str
|
||||
count: int
|
||||
rows: list[AdminHistoryRow]
|
||||
|
||||
|
||||
class AdminSettingsResponse(BaseModel):
|
||||
"""The effective settings as the admin plane serializes them.
|
||||
|
||||
Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings`
|
||||
— all TEN documented fields, resolved (every absent stored field
|
||||
already fell back to its default in the core). The same shape both
|
||||
the GET and the PATCH route answer with.
|
||||
"""
|
||||
|
||||
horizon_days: int
|
||||
day_rollover_time: str
|
||||
notify_target: str
|
||||
morning_summary_time: str
|
||||
afternoon_reminder_time: str
|
||||
end_of_day_report_time: str
|
||||
morning_summary_enabled: bool
|
||||
afternoon_reminder_enabled: bool
|
||||
end_of_day_report_enabled: bool
|
||||
celebration_enabled: bool
|
||||
|
||||
|
||||
class AdminMissedSweepResponse(BaseModel):
|
||||
"""The result of ``POST /api/v1/admin/missed-sweep``.
|
||||
|
||||
``fired`` is the number of ``nestquest_quest_missed`` transitions
|
||||
the run built AND this route published on the SSE stream — zero
|
||||
when the sweep's watermark already covers today (a same-day rerun).
|
||||
"""
|
||||
|
||||
fired: int
|
||||
|
||||
|
||||
#: 404 detail for a child id the core layer reports as non-existent.
|
||||
_CHILD_NOT_FOUND_DETAIL = "Child not found"
|
||||
|
||||
@@ -803,48 +646,6 @@ def _raise_regenerate_error(error: ValueError) -> NoReturn:
|
||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||
|
||||
|
||||
def _raise_history_error(error: ValueError) -> NoReturn:
|
||||
"""Map a core history ``ValueError`` onto 422 and raise it.
|
||||
|
||||
The history routes have no path ids, so they have no 404 case:
|
||||
every ``ValueError`` that survives to here is a rejected argument —
|
||||
an unknown filter, a non-strict date, or an inverted range, each
|
||||
raised by the core naming the offending field (``filter`` /
|
||||
``start`` / ``end``) BEFORE any read — and is 422 with the core's
|
||||
message as the detail. Only ``ValueError`` is caught by the
|
||||
handlers, so an unexpected failure re-raises rather than becoming a
|
||||
4xx.
|
||||
"""
|
||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||
|
||||
|
||||
def _raise_settings_error(error: ValueError) -> NoReturn:
|
||||
"""Map a core settings-store ``ValueError`` onto 422 and raise it.
|
||||
|
||||
The settings routes have no path ids either, so they have no 404
|
||||
case: every ``ValueError`` that survives to here is a rejected
|
||||
change — an unknown field or an invalid value, each raised by the
|
||||
core naming the offending field BEFORE any write, so the stored
|
||||
settings are never half-changed — and is 422 with the core's
|
||||
message as the detail. Only ``ValueError`` is caught, so an
|
||||
unexpected failure re-raises rather than becoming a 4xx.
|
||||
"""
|
||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||
|
||||
|
||||
def _raise_sweep_error(error: ValueError) -> NoReturn:
|
||||
"""Map a core missed-sweep ``ValueError`` onto 422 and raise it.
|
||||
|
||||
The sweep route has no path ids, so it has no 404 case: a core
|
||||
``ValueError`` — a rejected date the sweep's query refuses before
|
||||
any read, unreachable from this handler because ``today`` comes
|
||||
from the route's own clock read — is 422 with the core's message
|
||||
as the detail. Only ``ValueError`` is caught, so an unexpected
|
||||
failure re-raises rather than becoming a 4xx.
|
||||
"""
|
||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||
|
||||
|
||||
def _local_now() -> datetime.datetime:
|
||||
"""Return the API host's local time as ONE timezone-aware clock read.
|
||||
|
||||
@@ -1493,240 +1294,4 @@ async def admin_regenerate(
|
||||
)
|
||||
|
||||
|
||||
def _history_row_response(row: core_history.HistoryRow) -> AdminHistoryRow:
|
||||
"""Serialize one core HistoryRow into the documented payload."""
|
||||
return AdminHistoryRow(
|
||||
occurred_at=row.occurred_at,
|
||||
event_type=row.event_type,
|
||||
child_name=row.child_name,
|
||||
child_id=row.child_id,
|
||||
quest_title=row.quest_title,
|
||||
instance_id=row.instance_id,
|
||||
window=row.window,
|
||||
due_date=row.due_date,
|
||||
due_time=row.due_time,
|
||||
actor=row.actor,
|
||||
was_on_time=row.was_on_time,
|
||||
)
|
||||
|
||||
|
||||
def _settings_response(
|
||||
settings: core_settings.NestQuestSettings,
|
||||
) -> AdminSettingsResponse:
|
||||
"""Serialize one core NestQuestSettings into the documented payload.
|
||||
|
||||
Explicit field-by-field (not ``dataclasses.asdict``) so the payload
|
||||
stays pinned to the ten documented fields even if the core
|
||||
dataclass later grows another one.
|
||||
"""
|
||||
return AdminSettingsResponse(
|
||||
horizon_days=settings.horizon_days,
|
||||
day_rollover_time=settings.day_rollover_time,
|
||||
notify_target=settings.notify_target,
|
||||
morning_summary_time=settings.morning_summary_time,
|
||||
afternoon_reminder_time=settings.afternoon_reminder_time,
|
||||
end_of_day_report_time=settings.end_of_day_report_time,
|
||||
morning_summary_enabled=settings.morning_summary_enabled,
|
||||
afternoon_reminder_enabled=settings.afternoon_reminder_enabled,
|
||||
end_of_day_report_enabled=settings.end_of_day_report_enabled,
|
||||
celebration_enabled=settings.celebration_enabled,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/history",
|
||||
summary="Query the completion history for one filter and range",
|
||||
response_model=AdminHistoryResponse,
|
||||
)
|
||||
async def admin_history_query(
|
||||
request: Request,
|
||||
start: str,
|
||||
end: str,
|
||||
filter: str = "all",
|
||||
) -> AdminHistoryResponse:
|
||||
"""Return the history rows for one filter over the closed range.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter: ``filter``
|
||||
(default ``all``), ``start`` and ``end`` go straight to
|
||||
:func:`nestquest_core.history.query_history` as STRINGS — the
|
||||
filter policy (``all`` / ``reversals`` / ``missed``) and the
|
||||
date-range policy (strict ``YYYY-MM-DD``, ``end >= start``) live in
|
||||
the core, which raises a ``ValueError`` naming the offending field;
|
||||
the handler only threads ONE clock read (:func:`_local_now`) as the
|
||||
missed derivation's ``today`` and serializes the returned rows.
|
||||
``start``/``end`` are required query parameters (missing is
|
||||
FastAPI's 422 before any handler code runs); every core
|
||||
``ValueError`` is 422 (:func:`_raise_history_error`).
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
today = _local_now().date()
|
||||
try:
|
||||
rows = await core_history.query_history(
|
||||
state.database, filter, start, end, today=today
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_history_error(error)
|
||||
return AdminHistoryResponse(
|
||||
filter=filter,
|
||||
start=start,
|
||||
end=end,
|
||||
count=len(rows),
|
||||
rows=[_history_row_response(row) for row in rows],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/history.csv",
|
||||
summary="Export the completion history as CSV",
|
||||
response_class=Response,
|
||||
)
|
||||
async def admin_history_csv(
|
||||
request: Request,
|
||||
start: str,
|
||||
end: str,
|
||||
filter: str = "all",
|
||||
) -> Response:
|
||||
"""Export the SAME filtered history range as a CSV attachment.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter over ONE
|
||||
:func:`nestquest_core.history.export_history_csv` call — the same
|
||||
query path the JSON route uses, so the CSV can never drift from the
|
||||
query's filtered range — with the core's validation policy and the
|
||||
:mod:`csv`-based serialization (stable documented header row, RFC
|
||||
4180 quoting) living in the core. The answer is ``text/csv`` with
|
||||
a ``Content-Disposition`` attachment filename naming the range;
|
||||
the range is core-validated strict ``YYYY-MM-DD`` by the time the
|
||||
filename is built, so it is injection-safe. Error mapping is the
|
||||
JSON route's (:func:`_raise_history_error`).
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
today = _local_now().date()
|
||||
try:
|
||||
csv_text = await core_history.export_history_csv(
|
||||
state.database, filter, start, end, today=today
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_history_error(error)
|
||||
filename = f"nestquest-history-{start}-to-{end}.csv"
|
||||
return Response(
|
||||
content=csv_text,
|
||||
media_type="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/settings",
|
||||
summary="Read the effective settings",
|
||||
response_model=AdminSettingsResponse,
|
||||
)
|
||||
async def admin_get_settings(request: Request) -> AdminSettingsResponse:
|
||||
"""Return the current effective settings (all ten documented fields).
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter: ONE
|
||||
:func:`nestquest_core.settings_store.load_settings` call — the API
|
||||
owns settings in the database (the integration's HA options remain
|
||||
a temporary separate source until Feature 18 wires the client to
|
||||
the API), and the stored-document decode, the per-field validation
|
||||
and the defaults-for-absent-fields fallback all live in the core.
|
||||
A corrupt stored document never fails the read: the core falls
|
||||
back to the defaults (per field where possible).
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
settings = await core_settings_store.load_settings(state.database)
|
||||
return _settings_response(settings)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/settings",
|
||||
summary="Update the supplied settings fields",
|
||||
response_model=AdminSettingsResponse,
|
||||
)
|
||||
async def admin_update_settings(
|
||||
changes: dict[str, object], request: Request
|
||||
) -> AdminSettingsResponse:
|
||||
"""Update the supplied settings fields and return the new state.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter: the body — a
|
||||
JSON object of settings field names to new values — goes STRAIGHT
|
||||
to :func:`nestquest_core.settings_store.update_settings`, which
|
||||
rejects an unknown field, resolves each supplied field through the
|
||||
core settings validators (a JSON ``null`` resets its field to the
|
||||
default) and merges onto the current effective settings only after
|
||||
every value has validated — so a rejected update never changes the
|
||||
stored settings. Every core ``ValueError`` is 422
|
||||
(:func:`_raise_settings_error`); anything else re-raises.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
try:
|
||||
settings = await core_settings_store.update_settings(
|
||||
state.database, changes
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_settings_error(error)
|
||||
return _settings_response(settings)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/missed-sweep",
|
||||
summary="Run the nightly missed-quest sweep now",
|
||||
response_model=AdminMissedSweepResponse,
|
||||
)
|
||||
async def admin_run_missed_sweep(
|
||||
request: Request,
|
||||
) -> AdminMissedSweepResponse:
|
||||
"""Run the missed-quest sweep and publish what it built.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter over the SAME
|
||||
HA-free sweep policy the integration's rollover listener runs
|
||||
(:mod:`nestquest_core.sweep`): ONE timezone-aware clock read
|
||||
(:func:`_local_now`) pins the API host's local ``today``, and ONE
|
||||
:func:`nestquest_core.sweep.run_missed_sweep` call does everything
|
||||
else — the past-due open-instance query, the documented payload
|
||||
build and the watermark update (a same-day rerun is an empty
|
||||
no-op, and a run after downtime sweeps the accumulated window
|
||||
once). The route is read-only over the domain tables: it never
|
||||
mutates an instance and never writes a completion event.
|
||||
|
||||
Each returned ``(event_type, payload)`` pair is published DIRECTLY
|
||||
on the app's ONE transition publisher (``request.app.state.publisher``,
|
||||
fan-out fire-and-forget, see api/events.py) — the exact payload the
|
||||
sweep built, never re-fetched and never hand-built here, the same
|
||||
publish-what-the-core-built shape api/scheduler.py uses. This is a
|
||||
correctness rule, not a style preference: the core commits the
|
||||
idempotency watermark BEFORE returning, so a re-fetch-based publish
|
||||
loop opens a loss window — a concurrent regenerate deleting the
|
||||
instance, or a transient database error, would raise uncaught (a
|
||||
bare 500) AFTER the watermark advanced, and no future sweep would
|
||||
ever re-examine that instance. Publishing the built payload leaves
|
||||
no such window, and every event of one run keeps the ONE shared
|
||||
``occurred_at`` stamp the sweep computed for it. The response
|
||||
reports ``fired`` — the number of transitions published. Error
|
||||
mapping (:func:`_raise_sweep_error`): every core ``ValueError`` is
|
||||
422; anything else re-raises.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
database = state.database
|
||||
today = _local_now().date()
|
||||
try:
|
||||
events = await core_sweep.run_missed_sweep(database, today=today)
|
||||
except ValueError as error:
|
||||
_raise_sweep_error(error)
|
||||
for event_type, payload in events:
|
||||
request.app.state.publisher.publish(event_type, payload)
|
||||
return AdminMissedSweepResponse(fired=len(events))
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
"""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"]
|
||||
@@ -1,76 +1,375 @@
|
||||
"""The NestQuest integration (Feature 18: a thin client of the NestQuest API service).
|
||||
|
||||
The integration owns NO database: the API service (Feature 16) owns the
|
||||
SQLite file, the migrations, and every business rule. Setup builds the
|
||||
entry's ONE shared API client, the coordinator that polls the panel
|
||||
snapshot through it, and the SSE subscription that re-fires the API's
|
||||
transition frames on the HA bus, registers the single
|
||||
``nestquest.complete_quest`` proxy service, and forwards the sensor and
|
||||
binary_sensor platforms.
|
||||
"""
|
||||
"""The NestQuest integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import inspect
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_change
|
||||
|
||||
from .const import (
|
||||
CONF_SNAPSHOT_STALENESS,
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_SNAPSHOT_STALENESS,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
|
||||
from .core.settings import NestQuestSettings
|
||||
from .frontend import async_register_frontend
|
||||
from .coordinator import NestQuestCoordinator
|
||||
from .db import NestQuestDatabase, make_database
|
||||
from .materialize import materialize as _materialize_run
|
||||
from .migrations import apply_migrations
|
||||
from .services import async_deregister_services, async_register_services
|
||||
from .sse import NestQuestEventStream
|
||||
from .store import async_get_db_path
|
||||
from .admin_allowlist import seed_setup_admin
|
||||
from .sweep import run_missed_sweep
|
||||
|
||||
_LOGGER = LOGGER
|
||||
|
||||
#: SQLite reports "file is not a database" for corruption in every
|
||||
#: sqlite3 version this integration targets; the version-specific
|
||||
#: "encrypted or malformed" spelling (3.41+) maps to the same failure.
|
||||
_CORRUPTION_MARKERS = (
|
||||
"file is not a database",
|
||||
"database disk image is malformed",
|
||||
"encrypted or malformed",
|
||||
"is not a database",
|
||||
)
|
||||
|
||||
#: Primary sqlite error codes that mean "the file is corrupt" (SQLite
|
||||
#: result codes, https://www.sqlite.org/c3ref/c_abort.html). Extended
|
||||
#: codes carry the primary code in their low byte, so the match is on
|
||||
#: errcode & 0xff.
|
||||
_CORRUPT_PRIMARY_CODES = frozenset({11, 26}) # SQLITE_CORRUPT, SQLITE_NOTADB
|
||||
|
||||
|
||||
def _is_corruption_error(error: BaseException) -> bool:
|
||||
"""Return True when ``error`` indicates a corrupt database file.
|
||||
|
||||
Classification order: sqlite_errorcode (Python 3.11+) is primary
|
||||
and locale-independent — SQLITE_CORRUPT and SQLITE_NOTADB, matched
|
||||
on the primary byte so extended codes (SQLITE_CORRUPT_VTAB etc.)
|
||||
classify correctly too. Message matching is the fallback for
|
||||
older Pythons: the corruption spellings are the documented English
|
||||
texts; a non-English locale could evade them, but the code check
|
||||
already covers every locale on supported Pythons.
|
||||
"""
|
||||
if not isinstance(error, sqlite3.DatabaseError):
|
||||
return False
|
||||
errorcode = getattr(error, "sqlite_errorcode", None)
|
||||
if errorcode is not None and (errorcode & 0xFF) in _CORRUPT_PRIMARY_CODES:
|
||||
return True
|
||||
message = str(error).lower()
|
||||
return any(marker in message for marker in _CORRUPTION_MARKERS)
|
||||
|
||||
|
||||
async def _preflight_existing_file(
|
||||
hass: HomeAssistant, db_path: Path
|
||||
) -> None:
|
||||
"""Detect corruption in an EXISTING file before it is opened read-write.
|
||||
|
||||
Opening read-write persists WAL mode — for a rollback-journal file
|
||||
that rewrites the header byte 18/19 and can create -wal/-shm
|
||||
sidecars — which would modify a corrupt file before detection and
|
||||
break the leave-untouched guarantee. This preflight therefore
|
||||
validates the WHOLE file with a READ-ONLY connection (mode=ro +
|
||||
immutable=1 URI, PRAGMA integrity_check, run on the executor like
|
||||
every other DB call): corruption anywhere fails there, before
|
||||
anything is written. ``immutable=1`` is SQLite's documented
|
||||
guarantee that the connection performs NO writes and creates NO
|
||||
sidecars — even a WAL-header file gets its -wal/-shm reads served
|
||||
without creating the shared-memory file. Missing/empty files skip
|
||||
the preflight (they are created fresh by the real open). All
|
||||
filesystem stat/URI work runs inside the executor job with the
|
||||
probe so the event loop never touches disk.
|
||||
"""
|
||||
def _probe() -> None:
|
||||
# The file-state check and URI construction live inside the
|
||||
# executor job: exists/stat/resolve are disk I/O.
|
||||
if not db_path.exists():
|
||||
return
|
||||
# A zero-byte file is neither missing nor a valid database: it
|
||||
# is a corrupt (truncated-to-nothing) existing file, likely an
|
||||
# interrupted first write. The leave-untouched contract applies
|
||||
# to it too: refuse rather than silently initialize over it.
|
||||
if db_path.stat().st_size == 0:
|
||||
raise sqlite3.DatabaseError(
|
||||
"existing database file is empty (zero bytes): file is "
|
||||
"not a database"
|
||||
)
|
||||
# Properly escape the path: '#'/'?'/'%' in a directory or file
|
||||
# name would otherwise be parsed as URI fragments/parameters/
|
||||
# escapes. immutable=1: SQLite guarantees no writes and no
|
||||
# sidecar creation from this connection.
|
||||
uri = db_path.resolve().as_uri() + "?mode=ro&immutable=1"
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
try:
|
||||
row = conn.execute("PRAGMA integrity_check").fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
verdict = row[0] if row else "missing"
|
||||
if verdict != "ok":
|
||||
# A non-ok integrity verdict IS corruption: raise with the
|
||||
# verdict text (which matches the corruption markers) and
|
||||
# the NOTADB code so classification is code-based.
|
||||
raise sqlite3.DatabaseError(
|
||||
f"integrity_check verdict {verdict!r} on read-only "
|
||||
f"probe: file is not a database"
|
||||
)
|
||||
|
||||
try:
|
||||
# Every DB-touching call goes through the executor (the mock
|
||||
# harness's async_add_executor_job is awaitable too).
|
||||
await hass.async_add_executor_job(_probe)
|
||||
except sqlite3.DatabaseError as err:
|
||||
# Non-ok integrity verdicts are corruption, full stop: there is
|
||||
# no benign reading of a failed whole-file integrity check, so
|
||||
# ANY DatabaseError out of the probe that carries a corruption
|
||||
# marker or code propagates as corruption. Only exotic probe
|
||||
# failures that classify as non-corruption (e.g. a transient
|
||||
# 'database is locked' on the read) are downgraded to a debug
|
||||
# log, letting the real open path surface them.
|
||||
if _is_corruption_error(err):
|
||||
raise
|
||||
raise sqlite3.DatabaseError(
|
||||
f"read-only corruption probe of {db_path} failed: {err}"
|
||||
) from err
|
||||
|
||||
async def _async_open_database(
|
||||
hass: HomeAssistant, db_path: Path
|
||||
) -> NestQuestDatabase:
|
||||
"""Open the NestQuest database, mapping failure modes for setup.
|
||||
|
||||
Three startup cases (Feature 02 done-condition):
|
||||
|
||||
- file missing: SQLite creates it fresh on open — no special path,
|
||||
migrations stamp version 1 on the next step.
|
||||
- file present and valid: opens normally.
|
||||
- file present but corrupt: log an ERROR with the path, close
|
||||
anything opened, and raise ConfigEntryNotReady so HA retries —
|
||||
the file is left byte-for-byte untouched so the owner can
|
||||
restore a backup. No code path here deletes or overwrites an
|
||||
existing database file.
|
||||
|
||||
Corruption can surface at EITHER stage: a truncated header fails
|
||||
in the read-only preflight (before the file is ever opened
|
||||
read-write), while damaged later pages fail during migrations —
|
||||
the preflight guarantees no WAL sidecar or header write happens
|
||||
on a corrupt file before it is detected. Both are classified the
|
||||
same way.
|
||||
"""
|
||||
try:
|
||||
await _preflight_existing_file(hass, db_path)
|
||||
database = await make_database(hass).open(db_path)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except sqlite3.DatabaseError as err:
|
||||
if _is_corruption_error(err):
|
||||
_LOGGER.error(
|
||||
"NestQuest database at %s is corrupt: %s. Refusing to "
|
||||
"start with a corrupt database; the file was left "
|
||||
"untouched so a backup can be restored. Home Assistant "
|
||||
"will retry setup.",
|
||||
db_path,
|
||||
err,
|
||||
)
|
||||
raise ConfigEntryNotReady(
|
||||
f"NestQuest database corrupt: {db_path}"
|
||||
) from err
|
||||
# open() failed for a non-corruption reason (locked file,
|
||||
# permission problem): retryable, but nothing was opened.
|
||||
_LOGGER.error(
|
||||
"NestQuest database at %s failed to open: %s. Home "
|
||||
"Assistant will retry setup.",
|
||||
db_path,
|
||||
err,
|
||||
)
|
||||
raise ConfigEntryNotReady(
|
||||
f"NestQuest database unavailable: {db_path}"
|
||||
) from err
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
except (sqlite3.DatabaseError, RuntimeError) as err:
|
||||
await database.close()
|
||||
if _is_corruption_error(err):
|
||||
_LOGGER.error(
|
||||
"NestQuest database at %s is corrupt: %s. Refusing to "
|
||||
"start with a corrupt database; the file was left "
|
||||
"untouched so a backup can be restored. Home Assistant "
|
||||
"will retry setup.",
|
||||
db_path,
|
||||
err,
|
||||
)
|
||||
raise ConfigEntryNotReady(
|
||||
f"NestQuest database corrupt: {db_path}"
|
||||
) from err
|
||||
# A non-corruption failure (e.g. a locked file, a future schema
|
||||
# version) is also a retryable setup problem, but without the
|
||||
# corruption framing.
|
||||
_LOGGER.error(
|
||||
"NestQuest database at %s failed to open: %s. Home "
|
||||
"Assistant will retry setup.",
|
||||
db_path,
|
||||
err,
|
||||
)
|
||||
raise ConfigEntryNotReady(
|
||||
f"NestQuest database unavailable: {db_path}"
|
||||
) from err
|
||||
except asyncio.CancelledError:
|
||||
await database.close()
|
||||
raise
|
||||
return database
|
||||
|
||||
|
||||
@dataclass
|
||||
class NestQuestRuntimeData:
|
||||
"""Runtime data stored on a NestQuest config entry."""
|
||||
|
||||
entry_id: str
|
||||
settings: NestQuestSettings
|
||||
database: NestQuestDatabase
|
||||
remove_update_listener: Callable[[], Any]
|
||||
remove_time_change_listener: Callable[[], Any]
|
||||
coordinator: Any = None
|
||||
event_stream: NestQuestEventStream | None = None
|
||||
|
||||
|
||||
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
||||
"""Return every Home Assistant owner-account user id.
|
||||
|
||||
The last-resort admin seed: when no persisted allowlist copy and no
|
||||
flow-context user survive to first setup, the HA owner accounts are
|
||||
seeded so the household is never left with zero admins (an empty
|
||||
allowlist fails closed, which would lock the household out of its
|
||||
own integration). Owner accounts can already do everything in HA,
|
||||
so granting NestQuest admin is not a privilege escalation.
|
||||
"""
|
||||
auth = getattr(hass, "auth", None)
|
||||
if auth is None:
|
||||
return []
|
||||
users = await auth.async_get_users()
|
||||
owners = [
|
||||
user.id
|
||||
for user in users
|
||||
if getattr(user, "is_owner", False) and getattr(user, "id", None)
|
||||
]
|
||||
return owners
|
||||
|
||||
|
||||
async def _run_horizon_materialization(
|
||||
hass: HomeAssistant,
|
||||
database: NestQuestDatabase,
|
||||
settings: NestQuestSettings,
|
||||
) -> None:
|
||||
"""Materialize the rolling horizon ``[today, today + horizon_days]``.
|
||||
|
||||
"today" is computed in HA local time (``hass.config.time_zone``) rather
|
||||
than the system clock, so the horizon tracks the household's own day.
|
||||
The walk itself runs through :func:`~.materialize.materialize`, whose DB
|
||||
access is executor-only.
|
||||
|
||||
This is the ONE generation path: the startup backfill, the daily rollover
|
||||
listener and the ``regenerate`` service all funnel through it, so there is
|
||||
no duplicated materialization logic. ``settings`` (REQUIRED — there is no
|
||||
silent default; every caller already passes the entry's validated
|
||||
:class:`NestQuestSettings`) carries the configured ``horizon_days``. The
|
||||
window itself comes from :meth:`settings.horizon_window` so the helper is
|
||||
real and shared (not recomputed inline here).
|
||||
"""
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
start, end = settings.horizon_window(today)
|
||||
await _materialize_run(database, start.isoformat(), end.isoformat(), today=today)
|
||||
|
||||
|
||||
def _register_day_rollover_listener(
|
||||
hass: HomeAssistant,
|
||||
database: NestQuestDatabase,
|
||||
settings: NestQuestSettings,
|
||||
) -> Callable[[], Any]:
|
||||
"""Register the daily rollover listener and return its remover.
|
||||
|
||||
Fires once per day at the configured local ``day_rollover_time``:
|
||||
first re-materializes the rolling horizon
|
||||
``[today, today + horizon_days]`` through the shared
|
||||
:func:`_run_horizon_materialization` path (the entry's configured,
|
||||
validated ``horizon_days`` carried on ``settings``), then runs the
|
||||
Feature 11 missed sweep — announcing yesterday's still-open quests
|
||||
as missed AFTER today's instances exist, watermark-guarded so a
|
||||
re-fire of the listener the same night announces nothing twice.
|
||||
"""
|
||||
hour, minute = settings.day_rollover_hour_minute
|
||||
|
||||
async def _run_materialization(_now: datetime.datetime) -> None:
|
||||
await _run_horizon_materialization(hass, database, settings)
|
||||
await run_missed_sweep(hass, database)
|
||||
|
||||
return async_track_time_change(
|
||||
hass, _run_materialization, hour=hour, minute=minute
|
||||
)
|
||||
|
||||
|
||||
def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
||||
"""Return a live runtime record with a coordinator, or None.
|
||||
"""Return a live runtime record with a connected database, or None.
|
||||
|
||||
The domain-global service (the panel completion proxy) has no
|
||||
database of its own; it resolves whichever config entry's runtime
|
||||
data is currently live at call time, so it cannot hold a stale
|
||||
handle to an unloaded entry's coordinator.
|
||||
The domain-global services (e.g. ``regenerate``) have no database of
|
||||
their own; they resolve whichever config entry's runtime data is
|
||||
currently live at call time, so none of them can hold a stale handle
|
||||
to a closed connection after another entry unloads. The record also
|
||||
carries that entry's validated ``settings`` (e.g. the configured
|
||||
horizon) so service handlers read horizon days off it rather than
|
||||
re-reading HA config.
|
||||
"""
|
||||
for runtime_data in hass.data.get(DOMAIN, {}).values():
|
||||
if getattr(runtime_data, "coordinator", None) is not None:
|
||||
database = getattr(runtime_data, "database", None)
|
||||
if database is not None and database.connected:
|
||||
return runtime_data
|
||||
return None
|
||||
|
||||
|
||||
def _register_services(hass: HomeAssistant) -> None:
|
||||
"""Register the domain-global NestQuest service once.
|
||||
def _make_regenerate_handler(hass: HomeAssistant):
|
||||
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
|
||||
|
||||
The service is DOMAIN-global — registered exactly once (guarded by
|
||||
``has_service``) and NOT bound to any config entry — so the panel
|
||||
completion proxy resolves the currently-live runtime at call time
|
||||
async def _regenerate(_call: Any) -> None:
|
||||
runtime_data = _find_live_runtime_data(hass)
|
||||
if runtime_data is None:
|
||||
_LOGGER.warning(
|
||||
"NestQuest regenerate requested but no config entry has a "
|
||||
"live database; ignoring"
|
||||
)
|
||||
return
|
||||
await _run_horizon_materialization(
|
||||
hass, runtime_data.database, runtime_data.settings
|
||||
)
|
||||
|
||||
return _regenerate
|
||||
|
||||
|
||||
def _register_services(hass: HomeAssistant) -> None:
|
||||
"""Register domain-global NestQuest services once.
|
||||
|
||||
Services are DOMAIN-global — registered exactly once (guarded by
|
||||
``has_service`` on regenerate) and NOT bound to any config entry —
|
||||
so each handler resolves the currently-live database at call time
|
||||
and a name collision or duplicate setup is impossible. Removal is
|
||||
handled by the unload path only once the LAST entry is removed
|
||||
(see :func:`_deregister_services`).
|
||||
"""
|
||||
async_register_services(hass, find_runtime=_find_live_runtime_data)
|
||||
async_register_services(
|
||||
hass,
|
||||
find_runtime=_find_live_runtime_data,
|
||||
regenerate_handler=_make_regenerate_handler(hass),
|
||||
)
|
||||
|
||||
|
||||
def _deregister_services(hass: HomeAssistant) -> None:
|
||||
@@ -88,6 +387,47 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up NestQuest from a config entry."""
|
||||
await async_register_frontend(hass)
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
existing = hass.data[DOMAIN].get(entry.entry_id)
|
||||
if existing is not None:
|
||||
db = getattr(existing, "database", None)
|
||||
if db is None or not db.connected:
|
||||
# Reopen through the SAME safety path as first setup: a
|
||||
# record without a live connection (a previous setup's
|
||||
# close-on-failure, or a hand-replaced file) must get the
|
||||
# missing/valid/corrupt classification too, never a raw
|
||||
# sqlite3 error and never skipped migrations — and the
|
||||
# same allowlist seeding, so a reopened fresh file is
|
||||
# never ownerless.
|
||||
db = await _async_open_database(
|
||||
hass, await async_get_db_path(hass)
|
||||
)
|
||||
try:
|
||||
stored_admin_ids = (
|
||||
entry.options.get(CONF_ADMIN_USER_IDS)
|
||||
or entry.data.get(CONF_ADMIN_USER_IDS)
|
||||
)
|
||||
context = getattr(entry, "context", None)
|
||||
context_user_id = (
|
||||
context.get("user_id")
|
||||
if isinstance(context, dict)
|
||||
else None
|
||||
)
|
||||
await seed_setup_admin(
|
||||
db,
|
||||
stored_admin_ids=stored_admin_ids,
|
||||
context_user_id=context_user_id,
|
||||
owner_ids=await _async_owner_user_ids(hass),
|
||||
)
|
||||
except BaseException:
|
||||
# A seeding failure must not leak the just-opened
|
||||
# connection: close it before the error propagates, so
|
||||
# HA's setup retry starts from a clean handle (the
|
||||
# first-setup path closes symmetrically).
|
||||
await db.close()
|
||||
raise
|
||||
existing.database = db
|
||||
entry.runtime_data = existing
|
||||
return True
|
||||
|
||||
async def _async_update_listener(
|
||||
listener_hass: HomeAssistant, listener_entry: ConfigEntry
|
||||
@@ -95,94 +435,109 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Reload the entry when its options change."""
|
||||
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
|
||||
|
||||
database = await _async_open_database(hass, await async_get_db_path(hass))
|
||||
remove_update_listener: Callable[[], Any] | None = None
|
||||
remove_time_change_listener: Callable[[], Any] | None = None
|
||||
coordinator: Any = None
|
||||
event_stream: NestQuestEventStream | None = None
|
||||
try:
|
||||
# Seed the admin allowlist on first setup so the owner is never
|
||||
# locked out: an empty database takes the persisted admin copy
|
||||
# — the options flow's saved list first (it is the current
|
||||
# one), else the config flow's user from entry data — and only
|
||||
# then the HA user still present on the entry context. No-op
|
||||
# on a non-empty list (fail-closed narrowing is deliberate).
|
||||
stored_admin_ids = (
|
||||
entry.options.get(CONF_ADMIN_USER_IDS)
|
||||
or entry.data.get(CONF_ADMIN_USER_IDS)
|
||||
)
|
||||
context = getattr(entry, "context", None)
|
||||
context_user_id = (
|
||||
context.get("user_id")
|
||||
if isinstance(context, dict)
|
||||
else None
|
||||
)
|
||||
owner_ids = await _async_owner_user_ids(hass)
|
||||
await seed_setup_admin(
|
||||
database,
|
||||
stored_admin_ids=stored_admin_ids,
|
||||
context_user_id=context_user_id,
|
||||
owner_ids=owner_ids,
|
||||
)
|
||||
# Build the explicit settings object from entry.options ONCE: the
|
||||
# horizon materialization and the daily-rollover listener both
|
||||
# consume it, so core never reads HA config itself. A malformed
|
||||
# stored option falls back PER FIELD (via from_options_resilient)
|
||||
# so a single bad sibling option cannot reset the user's
|
||||
# horizon_days or day_rollover_time — the two fields core
|
||||
# consumes — to their defaults.
|
||||
settings = NestQuestSettings.from_options_resilient(entry.options)
|
||||
remove_update_listener = entry.add_update_listener(_async_update_listener)
|
||||
remove_time_change_listener = _register_day_rollover_listener(
|
||||
hass, database, settings
|
||||
)
|
||||
# Backfill any days the daily listener missed while the integration
|
||||
# was off (or freshly installed): run the one idempotent walk over
|
||||
# [today, today + horizon_days] once at setup, using the entry's
|
||||
# configured horizon. This runs through the SAME path as the daily
|
||||
# listener and regenerate service.
|
||||
await _run_horizon_materialization(hass, database, settings)
|
||||
# The missed sweep runs at startup too (Feature 11): if HA was
|
||||
# down at the rollover time, the previous night's still-open
|
||||
# quests are announced as missed now instead of waiting for
|
||||
# the next midnight. Watermark-guarded, so this is a no-op
|
||||
# when the night's sweep already ran.
|
||||
await run_missed_sweep(hass, database)
|
||||
_register_services(hass)
|
||||
# ONE API client for the entry, shared by the coordinator's
|
||||
# snapshot polling, the SSE subscription, and the completion
|
||||
# proxy (the client is stateless — one session, one connection
|
||||
# pool). An entry with no panel token yet gets the
|
||||
# typed-error stub (unavailable entities, never a setup crash).
|
||||
api_client = coordinator_client_from_entry(hass, entry)
|
||||
# The shared coordinator: one refresh cycle every entity reads
|
||||
# from (CONF_UPDATE_INTERVAL seconds, default five minutes).
|
||||
# It polls the API service's panel snapshot; a failed poll
|
||||
# keeps the entities rendering the last-good snapshot within
|
||||
# CONF_SNAPSHOT_STALENESS, unavailable past it.
|
||||
# The shared Feature 10 coordinator: one refresh cycle every
|
||||
# entity reads from (CONF_UPDATE_INTERVAL seconds, default
|
||||
# five minutes). Created AFTER services so a failure below
|
||||
# unwinds them through the except path's listener handling.
|
||||
coordinator = NestQuestCoordinator(
|
||||
hass,
|
||||
entry_id=entry.entry_id,
|
||||
api_client=api_client,
|
||||
database=database,
|
||||
settings=settings,
|
||||
update_interval_seconds=entry.options.get(
|
||||
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
|
||||
),
|
||||
staleness_seconds=entry.options.get(
|
||||
CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS
|
||||
),
|
||||
)
|
||||
# The panel event subscription (the SSE task): a background
|
||||
# task consumes the API service's transition-event stream and
|
||||
# re-fires the four documented HA bus events. It reuses the
|
||||
# coordinator's client, so it only starts when that client
|
||||
# actually offers a stream — an unconfigured-token stub (or a
|
||||
# test snapshot client without ``stream_events``) leaves it
|
||||
# unstarted, and the typed-error path already covers
|
||||
# availability for those entries.
|
||||
if callable(getattr(api_client, "stream_events", None)):
|
||||
event_stream = NestQuestEventStream(hass, api_client)
|
||||
event_stream.start()
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
runtime_data = NestQuestRuntimeData(
|
||||
entry_id=entry.entry_id,
|
||||
settings=settings,
|
||||
database=database,
|
||||
remove_update_listener=remove_update_listener,
|
||||
remove_time_change_listener=remove_time_change_listener,
|
||||
coordinator=coordinator,
|
||||
event_stream=event_stream,
|
||||
)
|
||||
# The runtime record is registered BEFORE the first refresh:
|
||||
# the service proxy resolves the entry's runtime state through
|
||||
# hass.data, and it must find the live coordinator there —
|
||||
# never a half-set-up entry. The platforms forward below reads
|
||||
# the coordinator off the entry (the HA runtime-data contract);
|
||||
# a forward failure below unwinds it through the except path.
|
||||
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||
# runtime_data is assigned BEFORE the platforms forward so each
|
||||
# platform's async_setup_entry reads its coordinator off the
|
||||
# entry (the HA runtime-data contract); a forward failure below
|
||||
# unwinds it through the except path.
|
||||
entry.runtime_data = runtime_data
|
||||
# The first refresh polls the API snapshot; an API failure
|
||||
# (unconfigured token, API service down) does NOT crash setup:
|
||||
# the coordinator reports last_update_success=False, entities
|
||||
# come up unavailable, and HA's standard retry cycle applies
|
||||
# once the API is reachable. HA's real
|
||||
# ``async_config_entry_first_refresh`` wraps ANY failed update
|
||||
# pass in ConfigEntryNotReady — the original error survives
|
||||
# only as ``__cause__``, never as the raised exception — so the
|
||||
# catch keys on ConfigEntryNotReady, NOT on the typed API
|
||||
# error. Everything else (a platform-forward failure,
|
||||
# cancellation) still unwinds through the except block below.
|
||||
try:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
except ConfigEntryNotReady:
|
||||
pass
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
except BaseException:
|
||||
# A failure anywhere after the listener was registered must not
|
||||
# leak it (or the SSE task): unwind EVERYTHING acquired before
|
||||
# the failure, keeping the original error as the one raised.
|
||||
# Removal is best-effort — a failing step must not mask the
|
||||
# setup error that triggered it.
|
||||
if event_stream is not None:
|
||||
# A failure anywhere after the database is open must not leak the
|
||||
# listeners already registered against that (now-closed) database:
|
||||
# unwind EVERY remover we acquired before the failure, keeping the
|
||||
# original error as the one raised. Removal is best-effort — a
|
||||
# failing remover must not mask the setup error that triggered it.
|
||||
for remove_listener in (
|
||||
remove_time_change_listener,
|
||||
remove_update_listener,
|
||||
):
|
||||
if remove_listener is None:
|
||||
continue
|
||||
try:
|
||||
await event_stream.stop()
|
||||
except BaseException:
|
||||
pass
|
||||
if remove_update_listener is not None:
|
||||
try:
|
||||
remove_update_listener()
|
||||
result = remove_listener()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
except BaseException:
|
||||
pass
|
||||
# A coordinator created before the failure holds entity
|
||||
# listeners pointed at the never-registered runtime — shut it
|
||||
# down so no scheduled refresh outlives the failed setup.
|
||||
# listeners pointed at the database being closed below —
|
||||
# shut it down FIRST so no scheduled refresh can ever run
|
||||
# against a closed connection.
|
||||
if coordinator is not None:
|
||||
try:
|
||||
await coordinator.async_shutdown()
|
||||
@@ -190,20 +545,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
pass
|
||||
# A platform-forward failure reached this unwind AFTER
|
||||
# runtime_data was assigned: clear it so no later unload path
|
||||
# re-invokes the already-consumed update-listener remover
|
||||
# through the stale record.
|
||||
# closes the already-closed database through the stale record.
|
||||
if getattr(entry, "runtime_data", None) is not None:
|
||||
entry.runtime_data = None
|
||||
# The runtime record was ALSO registered in hass.data before
|
||||
# the first refresh: remove it here on ANY failure, or a later
|
||||
# unload picks up the stale, already-torn-down record
|
||||
# (hass.data wins over entry.runtime_data there) and
|
||||
# re-invokes the already-consumed update-listener remover.
|
||||
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
||||
await database.close()
|
||||
raise
|
||||
# The runtime record was already registered before the first
|
||||
# refresh; re-assert the (unchanged) record so the return path
|
||||
# stays readable as one registration site.
|
||||
assert remove_update_listener is not None
|
||||
assert remove_time_change_listener is not None
|
||||
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||
return True
|
||||
|
||||
@@ -218,50 +566,58 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if runtime_data is None:
|
||||
runtime_data = getattr(entry, "runtime_data", None)
|
||||
# Platforms unload FIRST (mirroring HA conventions): entity
|
||||
# listeners must not outlive the coordinator the teardown below
|
||||
# shuts down.
|
||||
# listeners must not outlive the database and coordinator the
|
||||
# teardown below closes.
|
||||
platforms_unloaded = await hass.config_entries.async_unload_platforms(
|
||||
entry, PLATFORMS
|
||||
)
|
||||
if platforms_unloaded is False and runtime_data is not None:
|
||||
# Home Assistant KEPT one or more platforms loaded — their
|
||||
# entities still live and must keep their coordinator. Restore
|
||||
# entities still live and must keep their database. Restore
|
||||
# the runtime record popped above and report failure so HA
|
||||
# retries the unload later instead of leaving live entities
|
||||
# backed by a torn-down coordinator.
|
||||
# backed by a closed database.
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data
|
||||
return False
|
||||
unload_error: BaseException | None = None
|
||||
if runtime_data is not None:
|
||||
# The SSE subscription is stopped FIRST: its task must be gone
|
||||
# (no frame can fire) before anything else is torn down.
|
||||
event_stream = getattr(runtime_data, "event_stream", None)
|
||||
if event_stream is not None:
|
||||
stop_stream = getattr(event_stream, "stop", None)
|
||||
if stop_stream is not None:
|
||||
await stop_stream()
|
||||
remove_update_listener = getattr(
|
||||
runtime_data, "remove_update_listener", None
|
||||
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
||||
remove_time_change_listener = getattr(
|
||||
runtime_data, "remove_time_change_listener", None
|
||||
)
|
||||
try:
|
||||
if remove_update_listener is not None:
|
||||
result = remove_update_listener()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
except BaseException as err:
|
||||
# A failing remover must not skip the coordinator shutdown
|
||||
# below; the first error is retained and re-raised only
|
||||
# after the teardown ran.
|
||||
if unload_error is None:
|
||||
unload_error = err
|
||||
for remove_listener in (
|
||||
remove_update_listener,
|
||||
remove_time_change_listener,
|
||||
):
|
||||
if remove_listener is None:
|
||||
continue
|
||||
try:
|
||||
result = remove_listener()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
except BaseException as err:
|
||||
# Cancel EVERY listener even when an earlier remover
|
||||
# raises: a skipped remover would leave a daily
|
||||
# callback firing against a closed database after
|
||||
# unload. The first error is retained and re-raised
|
||||
# only after both removers (and the DB close) ran.
|
||||
if unload_error is None:
|
||||
unload_error = err
|
||||
finally:
|
||||
# The coordinator's entity listeners must stop here: a
|
||||
# scheduled refresh must never outlive the unload.
|
||||
# The coordinator's entity listeners must stop BEFORE the
|
||||
# database closes: a scheduled refresh starting during the
|
||||
# close await would touch a cleared connection. (The
|
||||
# platform unload above already removed the entities; this
|
||||
# clears the coordinator's own remaining listeners.)
|
||||
coordinator = getattr(runtime_data, "coordinator", None)
|
||||
if coordinator is not None:
|
||||
shutdown = getattr(coordinator, "async_shutdown", None)
|
||||
if shutdown is not None:
|
||||
await shutdown()
|
||||
database = getattr(runtime_data, "database", None)
|
||||
if database is not None:
|
||||
await database.close()
|
||||
# Domain-global services are torn down only once the FINAL entry
|
||||
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
|
||||
# entry is still loaded.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.admin_allowlist` for the integration package.
|
||||
|
||||
The admin allowlist business layer lives in
|
||||
:mod:`custom_components.nestquest.core.admin_allowlist` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .admin_allowlist import ...`` references inside the integration
|
||||
keep working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.admin_allowlist import * # noqa: F401,F403
|
||||
@@ -1,414 +0,0 @@
|
||||
"""Panel-plane API client for the NestQuest integration (Feature 18).
|
||||
|
||||
The integration talks to the NestQuest API service (Feature 16) over
|
||||
HTTP instead of reading the database: this module is the ONE place that
|
||||
speaks the API's panel plane — ``GET /api/v1/panel/snapshot`` (today's
|
||||
household snapshot) and ``POST /api/v1/panel/instances/{id}/complete``
|
||||
(completion as the tapped child profile) — authenticated with the
|
||||
static panel service token as a Bearer credential on every request.
|
||||
|
||||
Transport-injected by construction: the constructor takes the base URL,
|
||||
the panel token, and the HTTP session to use. The session speaks the
|
||||
aiohttp ``ClientSession`` surface (``request(method, url, headers=...,
|
||||
json=...)`` returning an async context manager whose response carries
|
||||
``status`` and ``await text()``), so tests stub the transport with NO
|
||||
network and NO aiohttp installed; production builds the client through
|
||||
:func:`client_from_entry`, which hands in Home Assistant's shared
|
||||
aiohttp session (``homeassistant.helpers.aiohttp_client
|
||||
.async_get_clientsession``). The module itself imports NEITHER
|
||||
homeassistant NOR aiohttp at runtime (aiohttp appears only behind
|
||||
``TYPE_CHECKING`` for annotations), so the client is usable anywhere —
|
||||
the HA coupling lives in the one production factory.
|
||||
|
||||
Every failure is typed: a transport failure (connection refused, DNS
|
||||
failure, timeout — the exchange never produced a response) and every
|
||||
non-2xx HTTP response raise :class:`NestQuestApiError`. The error
|
||||
carries the HTTP status where there is one (so 401 and 404 are
|
||||
distinguishable at the call site) and ``None`` when there is not; the
|
||||
original transport error is chained as ``__cause__`` — nothing is
|
||||
swallowed. There are NO retries and the client logs nothing: refresh
|
||||
cadence is the coordinator's concern (the next task), and the panel
|
||||
token is a secret that must never reach a log line or an error
|
||||
message — the error texts name the method, path, and status only.
|
||||
|
||||
The panel plane's SSE event stream (``GET /api/v1/panel/events``) is
|
||||
exposed by :meth:`NestQuestApiClient.stream_events` — an async
|
||||
generator yielding ``(event_type, payload)`` per Server-Sent Events
|
||||
frame (``event: <type>`` / ``data: <json>`` lines separated by a blank
|
||||
line). Opening the stream gets the same one-timeout, typed-error
|
||||
contract as every other call; reading it has NO body-wide timeout (a
|
||||
healthy stream is long-lived — the subscription manager owns
|
||||
reconnection and backoff).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .const import (
|
||||
CONF_API_BASE_URL,
|
||||
CONF_PANEL_TOKEN,
|
||||
DEFAULT_API_BASE_URL,
|
||||
DEFAULT_PANEL_TOKEN,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiohttp
|
||||
|
||||
__all__ = [
|
||||
"COMPLETE_INSTANCE_PATH",
|
||||
"DEFAULT_TIMEOUT_SECONDS",
|
||||
"EVENTS_PATH",
|
||||
"NestQuestApiError",
|
||||
"NestQuestApiClient",
|
||||
"SNAPSHOT_PATH",
|
||||
"client_from_entry",
|
||||
"is_valid_base_url",
|
||||
"resolve_api_config",
|
||||
]
|
||||
|
||||
#: The panel plane's snapshot route (Feature 16).
|
||||
SNAPSHOT_PATH = "/api/v1/panel/snapshot"
|
||||
|
||||
#: The panel plane's SSE event-stream route (Feature 16): each
|
||||
#: transition arrives as an ``event: <type>`` / ``data: <json>`` frame.
|
||||
EVENTS_PATH = "/api/v1/panel/events"
|
||||
|
||||
#: The panel plane's completion route template (Feature 16); the
|
||||
#: ``{instance_id}`` placeholder is filled per call.
|
||||
COMPLETE_INSTANCE_PATH = "/api/v1/panel/instances/{instance_id}/complete"
|
||||
|
||||
#: Request timeout, in seconds. One attempt, one timeout: a hung API
|
||||
#: call surfaces as a typed error for the coordinator to report, never
|
||||
#: a retry loop.
|
||||
DEFAULT_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
|
||||
class NestQuestApiError(Exception):
|
||||
"""A failed NestQuest API exchange, raised for every failure mode.
|
||||
|
||||
``status`` carries the HTTP status when a response arrived (a
|
||||
non-2xx response — 401 for a bad token, 404 for an unknown
|
||||
instance) and is ``None`` when the exchange failed at the transport
|
||||
level (connection refused, DNS failure, timeout) — so callers
|
||||
distinguish the two with ``is_transport_error``. The original
|
||||
transport error, where there is one, is chained as ``__cause__``.
|
||||
Messages carry the method, path, and status — never the request
|
||||
headers, so the panel token can never leak through an error.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, status: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
@property
|
||||
def is_transport_error(self) -> bool:
|
||||
"""True when the exchange failed before any response arrived."""
|
||||
return self.status is None
|
||||
|
||||
|
||||
def is_valid_base_url(value: object) -> bool:
|
||||
"""Return True when ``value`` is a usable API base URL.
|
||||
|
||||
A full ``http://`` or ``https://`` URL with a host — the exact
|
||||
shape the options flow validates (form ergonomics) and the client
|
||||
constructor enforces (fail fast, never build requests against a
|
||||
scheme-less or host-less string).
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
candidate = value.strip()
|
||||
if not candidate:
|
||||
return False
|
||||
parsed = urlparse(candidate)
|
||||
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||
|
||||
|
||||
def resolve_api_config(entry: Any) -> tuple[str, str]:
|
||||
"""Resolve ``(base_url, panel_token)`` off a config entry.
|
||||
|
||||
Precedence mirrors the options flow's stored-values resolution:
|
||||
``entry.options`` first (the live options-flow copy), then
|
||||
``entry.data`` (the config-flow-era copy), then the const defaults.
|
||||
The token is a secret: callers must never log the returned pair.
|
||||
"""
|
||||
options = getattr(entry, "options", None) or {}
|
||||
data = getattr(entry, "data", None) or {}
|
||||
|
||||
def _pick(key: str, default: str) -> str:
|
||||
if key in options:
|
||||
return options[key]
|
||||
if key in data:
|
||||
return data[key]
|
||||
return default
|
||||
|
||||
return (
|
||||
_pick(CONF_API_BASE_URL, DEFAULT_API_BASE_URL),
|
||||
_pick(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
|
||||
)
|
||||
|
||||
|
||||
def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient:
|
||||
"""Build the production client for a config entry.
|
||||
|
||||
Reads the configured base URL and panel token off the entry
|
||||
(:func:`resolve_api_config`) and hands the client Home Assistant's
|
||||
SHARED aiohttp session (``homeassistant.helpers.aiohttp_client
|
||||
.async_get_clientsession``) so panel calls ride HA's one connection
|
||||
pool. The helper import is deferred into this function on purpose:
|
||||
the test harness is mock-only and the module must import (and the
|
||||
client must be stub-transport testable) without homeassistant or
|
||||
aiohttp installed.
|
||||
"""
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
base_url, panel_token = resolve_api_config(entry)
|
||||
return NestQuestApiClient(
|
||||
base_url, panel_token, async_get_clientsession(hass)
|
||||
)
|
||||
|
||||
|
||||
class NestQuestApiClient:
|
||||
"""Async client for the NestQuest API service's panel plane.
|
||||
|
||||
ONE instance per config entry (the coordinator task wires it into
|
||||
the refresh cycle). The client is deliberately thin: it adds the
|
||||
Bearer credential and the base URL, enforces one timeout per
|
||||
request, and turns every failure into :class:`NestQuestApiError` —
|
||||
no business logic, no retries, no caching.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
panel_token: str,
|
||||
session: aiohttp.ClientSession,
|
||||
*,
|
||||
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
"""Store the connection settings; the session is injected.
|
||||
|
||||
``session`` is an aiohttp ``ClientSession`` in production and a
|
||||
stub transport in tests. ``base_url`` must be a full http(s)
|
||||
URL and ``panel_token`` must be non-empty — a client built
|
||||
without either can only fail later at request time with a
|
||||
less-explicable error, so construction fails fast instead.
|
||||
"""
|
||||
if not is_valid_base_url(base_url):
|
||||
raise ValueError(
|
||||
f"base_url must be a full http(s) URL, got {base_url!r}"
|
||||
)
|
||||
if not isinstance(panel_token, str) or not panel_token.strip():
|
||||
raise ValueError("panel_token must be a non-empty string")
|
||||
if (
|
||||
not isinstance(timeout_seconds, (int, float))
|
||||
or isinstance(timeout_seconds, bool)
|
||||
or timeout_seconds <= 0
|
||||
):
|
||||
raise ValueError(
|
||||
f"timeout_seconds must be a positive number, got {timeout_seconds!r}"
|
||||
)
|
||||
self._base_url = base_url.strip().rstrip("/")
|
||||
self._panel_token = panel_token
|
||||
self._session = session
|
||||
self._timeout_seconds = float(timeout_seconds)
|
||||
|
||||
async def get_snapshot(self) -> dict[str, Any]:
|
||||
"""Fetch and parse today's household snapshot.
|
||||
|
||||
Returns the JSON the snapshot route returns (``today_iso``,
|
||||
``cycle_day``, and the per-child list — see
|
||||
``api/routes_panel.py`` for the documented shape). Raises
|
||||
:class:`NestQuestApiError` on transport failure, timeout, or a
|
||||
non-2xx response.
|
||||
"""
|
||||
return await self._request_json("GET", SNAPSHOT_PATH)
|
||||
|
||||
async def complete_instance(
|
||||
self, instance_id: int, actor_child_id: int
|
||||
) -> dict[str, Any]:
|
||||
"""Complete one quest instance as the tapped child profile.
|
||||
|
||||
POSTs ``{"actor_child_id": actor_child_id}`` to the complete
|
||||
route and returns the parsed response body (the route answers
|
||||
``{"status": ...}`` both for a first completion and for an
|
||||
idempotent repeat, so a retried panel tap succeeds). Raises
|
||||
:class:`NestQuestApiError` on transport failure, timeout, or a
|
||||
non-2xx response — 401 for a wrong token, 404 for an unknown
|
||||
instance.
|
||||
"""
|
||||
path = COMPLETE_INSTANCE_PATH.format(instance_id=instance_id)
|
||||
return await self._request_json(
|
||||
"POST", path, json_body={"actor_child_id": actor_child_id}
|
||||
)
|
||||
|
||||
async def stream_events(
|
||||
self,
|
||||
) -> AsyncIterator[tuple[str, dict[str, Any]]]:
|
||||
"""Yield ``(event_type, payload)`` for every panel SSE frame.
|
||||
|
||||
Opens ``GET /api/v1/panel/events`` with the Bearer credential
|
||||
and parses the Server-Sent Events framing the API service
|
||||
emits: ``event: <type>`` and ``data: <json>`` lines closed by a
|
||||
blank line (one frame per transition event; a frame's ``data``
|
||||
may span several ``data:`` lines, joined with newlines — the
|
||||
standard SSE rule). Line splits across chunk boundaries are
|
||||
buffered, so frames are parsed correctly whatever chunking the
|
||||
transport produces.
|
||||
|
||||
Opening the stream carries the SAME one-timeout, typed-error
|
||||
contract as every other call: a non-2xx response raises
|
||||
:class:`NestQuestApiError` with its status and a transport
|
||||
failure (connection refused, DNS, the open timing out) raises
|
||||
one with ``status=None`` and the original error chained. The
|
||||
reading loop itself has NO timeout — a healthy stream is
|
||||
long-lived — but any failure raised from the transport
|
||||
mid-stream raises typed as well, so the consumer can reconnect.
|
||||
A frame whose ``data`` is not valid JSON is a protocol failure
|
||||
and raises typed, carrying the response status it arrived
|
||||
with. Cancellation is never converted: closing the generator
|
||||
(``aclose``, i.e. the consumer's task being cancelled) stops
|
||||
cleanly and closes the underlying response in every path.
|
||||
"""
|
||||
url = f"{self._base_url}{EVENTS_PATH}"
|
||||
headers = {"Authorization": f"Bearer {self._panel_token}"}
|
||||
# Opening the response gets ONE request timeout, exactly like
|
||||
# ``_request_json``; the read loop below runs WITHOUT one — a
|
||||
# body-wide timeout would kill a healthy long-lived stream.
|
||||
# The response context manager is entered manually (not via
|
||||
# ``async with``) because the block must span the yields below;
|
||||
# the ``finally`` closes it on EVERY exit path.
|
||||
try:
|
||||
async with asyncio.timeout(self._timeout_seconds):
|
||||
stream_cm = self._session.request(
|
||||
"GET", url, headers=headers, json=None
|
||||
)
|
||||
response = await stream_cm.__aenter__()
|
||||
except asyncio.CancelledError:
|
||||
# Cancellation is never converted (see ``_request_json``).
|
||||
raise
|
||||
except NestQuestApiError:
|
||||
raise
|
||||
except Exception as err:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API GET {EVENTS_PATH} transport failure: {err}",
|
||||
status=None,
|
||||
) from err
|
||||
|
||||
status = response.status
|
||||
if not 200 <= status < 300:
|
||||
await self._close_stream(stream_cm)
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API GET {EVENTS_PATH} failed with HTTP {status}",
|
||||
status=status,
|
||||
)
|
||||
|
||||
try:
|
||||
event_type: str | None = None
|
||||
data_lines: list[str] = []
|
||||
buffer = ""
|
||||
async for chunk in response:
|
||||
if isinstance(chunk, (bytes, bytearray)):
|
||||
chunk = chunk.decode("utf-8")
|
||||
buffer += chunk
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
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:
|
||||
# A blank line ends the frame. A trailing
|
||||
# incomplete frame (stream ends without its
|
||||
# closing blank line) is discarded, per the
|
||||
# SSE spec's treatment of incomplete events.
|
||||
try:
|
||||
payload = json.loads("\n".join(data_lines))
|
||||
except ValueError as err:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API GET {EVENTS_PATH} "
|
||||
f"delivered a malformed SSE frame: {err}",
|
||||
status=status,
|
||||
) from err
|
||||
yield event_type, payload
|
||||
event_type = None
|
||||
data_lines = []
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except NestQuestApiError:
|
||||
raise
|
||||
except Exception as err:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API GET {EVENTS_PATH} transport failure: {err}",
|
||||
status=None,
|
||||
) from err
|
||||
finally:
|
||||
await self._close_stream(stream_cm)
|
||||
|
||||
@staticmethod
|
||||
async def _close_stream(stream_cm: Any) -> None:
|
||||
"""Close an opened streaming response context manager.
|
||||
|
||||
Works for aiohttp's response context manager (whose
|
||||
``__aexit__`` is a coroutine) and for test stubs that return
|
||||
either a coroutine or a plain value.
|
||||
"""
|
||||
result = stream_cm.__aexit__(None, None, None)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
async def _request_json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""Run ONE request and return the parsed JSON body.
|
||||
|
||||
The Bearer token rides the Authorization header only. A
|
||||
non-2xx response raises :class:`NestQuestApiError` with its
|
||||
status; anything the transport raises (connection, timeout) is
|
||||
wrapped as a transport-level :class:`NestQuestApiError` with
|
||||
the original error chained — never swallowed, never retried.
|
||||
A 2xx body that is not valid JSON is a protocol failure and
|
||||
raises typed too, carrying the status it arrived with.
|
||||
"""
|
||||
url = f"{self._base_url}{path}"
|
||||
headers = {"Authorization": f"Bearer {self._panel_token}"}
|
||||
try:
|
||||
async with asyncio.timeout(self._timeout_seconds):
|
||||
async with self._session.request(
|
||||
method, url, headers=headers, json=json_body
|
||||
) as response:
|
||||
status = response.status
|
||||
if not 200 <= status < 300:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API {method} {path} failed with "
|
||||
f"HTTP {status}",
|
||||
status=status,
|
||||
)
|
||||
body_text = await response.text()
|
||||
except asyncio.CancelledError:
|
||||
# Cancellation is never converted: a shutting-down event
|
||||
# loop must stop, not read a synthesized API error.
|
||||
raise
|
||||
except NestQuestApiError:
|
||||
raise
|
||||
except Exception as err:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API {method} {path} transport failure: {err}",
|
||||
status=None,
|
||||
) from err
|
||||
try:
|
||||
return json.loads(body_text)
|
||||
except ValueError as err:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API {method} {path} returned malformed "
|
||||
f"JSON: {err}",
|
||||
status=status,
|
||||
) from err
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.children` for the integration package.
|
||||
|
||||
The children registry business layer lives in
|
||||
:mod:`custom_components.nestquest.core.children` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .children import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.children import * # noqa: F401,F403
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.completion` for the integration package.
|
||||
|
||||
The completion business layer lives in
|
||||
:mod:`custom_components.nestquest.core.completion` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .completion import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.completion import * # noqa: F401,F403
|
||||
@@ -7,7 +7,7 @@ from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import CONF_ADMIN_USER_IDS, DOMAIN
|
||||
|
||||
|
||||
class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
@@ -30,11 +30,11 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
) -> FlowResult:
|
||||
"""Handle the user step of the config flow.
|
||||
|
||||
The integration needs no configuration to start (the API
|
||||
connection is set in the options flow), so the user input is
|
||||
stored as-is. The former admin-allowlist copy is gone: with
|
||||
the database removed the allowlist lives in the API service's
|
||||
own store, not on the entry.
|
||||
The HA user completing the flow is persisted into the entry
|
||||
data as the initial admin allowlist copy: real HA does not
|
||||
guarantee the flow context survives to setup, and entry data is
|
||||
the persisted record first setup seeds from — the owner is
|
||||
never locked out.
|
||||
"""
|
||||
await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
|
||||
if self._async_in_progress() or self._async_current_entries():
|
||||
@@ -43,4 +43,12 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="user")
|
||||
|
||||
return self.async_create_entry(title="NestQuest", data=dict(user_input))
|
||||
data = dict(user_input)
|
||||
flow_user_id = (
|
||||
self.context.get("user_id")
|
||||
if isinstance(self.context, dict)
|
||||
else None
|
||||
)
|
||||
if flow_user_id:
|
||||
data[CONF_ADMIN_USER_IDS] = [flow_user_id]
|
||||
return self.async_create_entry(title="NestQuest", data=data)
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The shared NestQuest DataUpdateCoordinator (Feature 10, Feature 18).
|
||||
"""The shared NestQuest DataUpdateCoordinator (Feature 10).
|
||||
|
||||
ONE refresh pass builds the whole entity snapshot: active children,
|
||||
today's instances with their derived states (open / done / missed via
|
||||
@@ -7,81 +7,54 @@ per-child rollup counts. Every Feature 10 entity reads
|
||||
``coordinator.data`` — no entity queries the database itself, so one
|
||||
refresh updates the whole board coherently.
|
||||
|
||||
Since Feature 18 the snapshot no longer comes from the local database:
|
||||
the coordinator polls the NestQuest API service's panel plane —
|
||||
``GET /api/v1/panel/snapshot`` through
|
||||
:class:`~.api_client.NestQuestApiClient` on its update interval — and
|
||||
RECONSTRUCTS the same core dataclasses the local builder produced
|
||||
(:class:`~.core.snapshot.NestQuestSnapshot`,
|
||||
:class:`~.core.snapshot.ChildDaySnapshot`,
|
||||
:class:`~.core.snapshot.QuestInstanceView`). ``sensor.py`` and
|
||||
``binary_sensor.py`` read ``coordinator.data`` and
|
||||
:func:`~.core.snapshot.instance_payload` exactly as before, so every
|
||||
entity_id and attribute payload shape is unchanged: the panel cards and
|
||||
the four blueprints keep working untouched.
|
||||
"Today" and "now" are HA-local (``hass.config.time_zone``), never the
|
||||
host clock and never UTC, matching the materialization and completion
|
||||
layers.
|
||||
|
||||
One loss is deliberate and documented (D-009 revisited): the panel
|
||||
payload OMITS missed instances, so a snapshot rebuilt from the API can
|
||||
no longer carry them — ``admin_instances`` (``include_missed=True``)
|
||||
now equals ``instances`` (they can hold only open and completed rows).
|
||||
The admin surface for missed quests is the PWA, which reads the API
|
||||
service directly; the integration's entities never needed missed rows
|
||||
for their states or counts (the counts arrive precomputed).
|
||||
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.
|
||||
|
||||
Failure handling: constructing the client fails fast (ValueError) when
|
||||
the entry has no panel token configured, and a fetch failure raises the
|
||||
typed :class:`~.api_client.NestQuestApiError`. Neither crashes
|
||||
integration setup: the constructor logs a clear message and hands back
|
||||
a client-shaped stub whose refresh raises the same typed error, and
|
||||
``_async_update_data`` converts every typed error into
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed` — the
|
||||
exception a HA ``DataUpdateCoordinator`` records as a failed pass
|
||||
(``last_update_success = False``, ``data = None``), so the entities
|
||||
come up unavailable. The typed error is never swallowed silently: it
|
||||
is logged with its message and chained into the UpdateFailed. (The
|
||||
conversion matters at setup: HA's real
|
||||
``async_config_entry_first_refresh`` wraps any failed pass in
|
||||
``ConfigEntryNotReady``, so a setup that keys on the original
|
||||
exception type would never match it.)
|
||||
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
|
||||
answers ``is this child at this house today`` (D-004 — never ISO week
|
||||
parity).
|
||||
|
||||
Last-good cache and staleness: every successful pass refreshes a cache
|
||||
of the rebuilt snapshot stamped with the :func:`time.monotonic`
|
||||
instant of the success. A typed failure is checked against that stamp
|
||||
BEFORE the pass is declared failed: within the configured staleness
|
||||
threshold (``CONF_SNAPSHOT_STALENESS``, seconds, default three update
|
||||
intervals) the pass SUCCEEDS with the cached snapshot — the panel
|
||||
keeps rendering last-good data and the entities stay available — and
|
||||
the outage is logged as a warning. Entities go unavailable only once
|
||||
the cache is older than the threshold (or before the first success);
|
||||
a later successful fetch refreshes the cache and the entities recover
|
||||
on their own, without a config-entry reload.
|
||||
``cycle_day`` reports where today falls in the custody cycle of the
|
||||
first active child (display order) that HAS a schedule — 1-based day
|
||||
``N`` of ``cycle_length_weeks * 7``, computed from that schedule's
|
||||
anchor date. ``0`` means no active child has a schedule at all, so
|
||||
the admin header degrades to a harmless zero instead of guessing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import time
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .api_client import NestQuestApiClient, NestQuestApiError, resolve_api_config
|
||||
from .const import (
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_SNAPSHOT_STALENESS,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MIN_UPDATE_INTERVAL,
|
||||
)
|
||||
from .core.db import NestQuestDatabase
|
||||
from .core.settings import NestQuestSettings
|
||||
from .core.snapshot import (
|
||||
ChildDaySnapshot,
|
||||
NestQuestSnapshot,
|
||||
QuestInstanceView,
|
||||
build_snapshot,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -92,131 +65,9 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
|
||||
"""Reconstruct the core snapshot dataclasses from the API's JSON.
|
||||
|
||||
The panel snapshot route (``api/routes_panel.py``) returns today's
|
||||
household as plain JSON — ``today_iso``, ``cycle_day``, and the
|
||||
per-child list with precomputed counts and the instances in the
|
||||
documented payload shape. This function maps that shape BACK onto
|
||||
the dataclasses ``sensor.py`` / ``binary_sensor.py`` read, field by
|
||||
field:
|
||||
|
||||
- the API's ``state`` ``"completed"`` maps back to the core view's
|
||||
``"done"`` (so :func:`~.core.snapshot.instance_payload` renders
|
||||
``"completed"`` again — the round-trip is identity for open and
|
||||
completed rows);
|
||||
- ``overdue``, ``on_time`` (→ ``was_on_time``), ``completed_at``,
|
||||
``window``, ``due_time``, ``icon`` and the per-child counts pass
|
||||
through verbatim;
|
||||
- ``present`` and ``next_present`` pass through verbatim (the API
|
||||
resolved them through the same presence engine server-side);
|
||||
- the instance list is rebuilt IN ORDER, so the panel payload the
|
||||
sensors emit matches the route's order byte for byte.
|
||||
|
||||
Because the route omits missed instances (D-009), a snapshot built
|
||||
here cannot contain any ``missed`` view — see the module docstring.
|
||||
"""
|
||||
children: list[ChildDaySnapshot] = []
|
||||
for child in payload.get("children", []):
|
||||
views = tuple(
|
||||
QuestInstanceView(
|
||||
instance_id=instance["id"],
|
||||
definition_id=instance["definition_id"],
|
||||
child_id=instance["child_id"],
|
||||
title=instance["title"],
|
||||
icon=instance.get("icon"),
|
||||
window=instance["window"],
|
||||
# The API payload does not carry due_date: every row
|
||||
# is TODAY's instance (the route queries
|
||||
# [today, today]), so the view's due_date is the
|
||||
# snapshot's today_iso — the same value the local
|
||||
# builder stamped on every today row.
|
||||
due_date=payload["today_iso"],
|
||||
due_time=instance.get("due_time"),
|
||||
state=(
|
||||
"done" if instance["state"] == "completed" else
|
||||
instance["state"]
|
||||
),
|
||||
overdue=instance["overdue"],
|
||||
completed_at=instance.get("completed_at"),
|
||||
was_on_time=instance.get("on_time"),
|
||||
)
|
||||
for instance in child.get("instances", [])
|
||||
)
|
||||
children.append(
|
||||
ChildDaySnapshot(
|
||||
child_id=child["child_id"],
|
||||
child_name=child["child_name"],
|
||||
instances=views,
|
||||
present=child["present"],
|
||||
due_today=child["due_today"],
|
||||
completed_today=child["completed_today"],
|
||||
remaining_today=child["remaining_today"],
|
||||
completion_pct=child["completion_pct"],
|
||||
next_present=child.get("next_present"),
|
||||
)
|
||||
)
|
||||
return NestQuestSnapshot(
|
||||
today_iso=payload["today_iso"],
|
||||
children=tuple(children),
|
||||
cycle_day=payload["cycle_day"],
|
||||
)
|
||||
|
||||
|
||||
class _UnconfiguredApiClient:
|
||||
"""Client-shaped stand-in used when the entry has no panel token.
|
||||
|
||||
Constructing a real :class:`~.api_client.NestQuestApiClient` with an
|
||||
empty token raises ValueError by design (fail fast on a client that
|
||||
could only ever 401). An entry that simply has not been configured
|
||||
yet must not crash integration setup, so the coordinator swaps in
|
||||
this stub: every refresh raises the SAME typed
|
||||
:class:`~.api_client.NestQuestApiError` a transport failure would,
|
||||
carrying a clear "API not configured" message;
|
||||
:meth:`NestQuestCoordinator._async_update_data` converts it to
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, so
|
||||
the coordinator marks the entities unavailable and the HA standard
|
||||
retry cycle applies. The error is logged, never silent.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
async def get_snapshot(self) -> dict[str, Any]:
|
||||
"""Always raise: there is no configured API to fetch from."""
|
||||
raise NestQuestApiError(
|
||||
"NestQuest API not configured: set the panel token in the "
|
||||
"integration options"
|
||||
)
|
||||
|
||||
|
||||
def _monotonic() -> float:
|
||||
"""The monotonic clock the staleness math reads.
|
||||
|
||||
A one-line indirection over :func:`time.monotonic` so the staleness
|
||||
tests can drive the clock deterministically (patch THIS function,
|
||||
never the stdlib clock). Monotonic is deliberate: wall-clock jumps
|
||||
(NTP corrections, suspends) must not shorten or extend the window.
|
||||
"""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
"""Shared refresh cycle for every NestQuest entity.
|
||||
|
||||
One refresh pass = one ``GET /api/v1/panel/snapshot`` through the
|
||||
API client, reconstructed into the core snapshot dataclasses every
|
||||
entity reads. A failed pass (unconfigured API, transport failure,
|
||||
non-2xx response) raises the typed error out of the client;
|
||||
``_async_update_data`` converts it to
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, the
|
||||
exception HA's coordinator machinery records as a failed pass: the
|
||||
coordinator's ``data`` keeps its last good value while
|
||||
``last_update_success`` goes False — HA marks the entities
|
||||
unavailable until a later pass succeeds. An unexpected
|
||||
non-typed error still propagates out of ``_async_update_data``
|
||||
and is logged (with its traceback) by HA's machinery, the same way.
|
||||
|
||||
Forced refreshes (the service paths pushing an immediate update
|
||||
after a completion) are SERIALIZED per entry: overlapping
|
||||
complete/uncomplete calls must not interleave their snapshot
|
||||
@@ -224,15 +75,6 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
newer one and leave entities wrong until the next poll. Real
|
||||
HA's coordinator already serializes internally; the lock keeps
|
||||
the same guarantee on the stand-in and costs nothing upstream.
|
||||
|
||||
Last-good cache: a successful pass stamps the rebuilt snapshot
|
||||
with the :func:`~.coordinator._monotonic` instant of the success;
|
||||
a typed failure within the configured staleness threshold
|
||||
(``staleness_seconds``, from ``CONF_SNAPSHOT_STALENESS``) serves
|
||||
that cached snapshot instead of failing the pass, so a short API
|
||||
outage keeps the board up. Past the threshold — or before the
|
||||
first success — the pass fails as above, and a later successful
|
||||
pass refreshes the cache and recovers the entities on its own.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -240,9 +82,9 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
entry_id: str,
|
||||
api_client: NestQuestApiClient,
|
||||
database: NestQuestDatabase,
|
||||
settings: NestQuestSettings,
|
||||
update_interval_seconds: int | None = None,
|
||||
staleness_seconds: int | None = None,
|
||||
) -> None:
|
||||
interval = update_interval_seconds
|
||||
if (
|
||||
@@ -251,19 +93,6 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
or interval < MIN_UPDATE_INTERVAL
|
||||
):
|
||||
interval = DEFAULT_UPDATE_INTERVAL
|
||||
staleness = staleness_seconds
|
||||
if (
|
||||
not isinstance(staleness, int)
|
||||
or isinstance(staleness, bool)
|
||||
or staleness < interval
|
||||
):
|
||||
# A missing or nonsensical threshold falls back to the
|
||||
# shipped default; a configured-but-too-short one (below
|
||||
# even one poll interval) falls back to whichever of
|
||||
# default/interval spans at least one failed poll, so the
|
||||
# cache is never dead on arrival. (The options flow
|
||||
# validates the same rule at the form.)
|
||||
staleness = max(DEFAULT_SNAPSHOT_STALENESS, interval)
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
@@ -271,16 +100,9 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
update_interval=datetime.timedelta(seconds=interval),
|
||||
)
|
||||
self.entry_id = entry_id
|
||||
self.api_client = api_client
|
||||
self.database = database
|
||||
self.settings = settings
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
#: The configured last-good window, in SECONDS (const.py:
|
||||
#: CONF_SNAPSHOT_STALENESS / DEFAULT_SNAPSHOT_STALENESS).
|
||||
self._staleness_seconds = staleness
|
||||
#: The last-good cache: the snapshot from the most recent
|
||||
#: SUCCESSFUL fetch and the :func:`time.monotonic` instant it
|
||||
#: landed. Both stay None until the first success.
|
||||
self._last_good_snapshot: NestQuestSnapshot | None = None
|
||||
self._last_good_monotonic: float | None = None
|
||||
|
||||
async def async_refresh(self) -> None:
|
||||
"""One refresh at a time; a queued refresh runs AFTER the
|
||||
@@ -289,98 +111,12 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
async with self._refresh_lock:
|
||||
await super().async_refresh()
|
||||
|
||||
def _local_now(self) -> datetime.datetime:
|
||||
time_zone = ZoneInfo(self.hass.config.time_zone)
|
||||
return datetime.datetime.now(time_zone)
|
||||
|
||||
async def _async_update_data(self) -> NestQuestSnapshot:
|
||||
"""One refresh pass: fetch the panel snapshot and rebuild it.
|
||||
|
||||
A successful pass rebuilds the snapshot and refreshes the
|
||||
last-good cache (the snapshot plus the
|
||||
:func:`time.monotonic` instant of the success).
|
||||
|
||||
A typed API failure (unconfigured token stub, transport
|
||||
failure, non-2xx response) is first checked against that
|
||||
stamp: WITHIN the configured staleness threshold (elapsed time
|
||||
since the last successful fetch, inclusive of the threshold
|
||||
itself) the pass SUCCEEDS with the cached snapshot — the panel
|
||||
keeps rendering last-good data, the entities stay available —
|
||||
and the outage is logged as a clear warning (never silent).
|
||||
With no cache yet, or past the threshold, the typed error is
|
||||
converted to
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`
|
||||
— the exception HA's coordinator machinery records as a failed
|
||||
pass (``last_update_success = False``, ``data`` unchanged) so
|
||||
entities go unavailable and the setup-time first refresh
|
||||
arrives as ``ConfigEntryNotReady`` — with the typed error
|
||||
logged and chained. Anything else still propagates.
|
||||
"""
|
||||
try:
|
||||
payload = await self.api_client.get_snapshot()
|
||||
except NestQuestApiError as err:
|
||||
now = _monotonic()
|
||||
cached = self._last_good_snapshot
|
||||
fetched_at = self._last_good_monotonic
|
||||
if (
|
||||
cached is not None
|
||||
and fetched_at is not None
|
||||
and now - fetched_at <= self._staleness_seconds
|
||||
):
|
||||
LOGGER.warning(
|
||||
"NestQuest API refresh failed (%s); serving the "
|
||||
"last-good snapshot fetched %.0f s ago, within the "
|
||||
"%d s staleness threshold",
|
||||
err,
|
||||
now - fetched_at,
|
||||
self._staleness_seconds,
|
||||
)
|
||||
return cached
|
||||
LOGGER.error(
|
||||
"NestQuest coordinator refresh failed: %s", err
|
||||
)
|
||||
raise UpdateFailed(str(err)) from err
|
||||
snapshot = snapshot_from_api_payload(payload)
|
||||
self._last_good_snapshot = snapshot
|
||||
self._last_good_monotonic = _monotonic()
|
||||
return snapshot
|
||||
|
||||
|
||||
def coordinator_client_from_entry(
|
||||
hass: HomeAssistant, entry: Any, *, session_factory: Any = None
|
||||
) -> Any:
|
||||
"""Build the refresh client for a config entry, tolerating no token.
|
||||
|
||||
Reads ``(base_url, panel_token)`` off the entry and constructs the
|
||||
real client. An entry with no panel token configured (the shipped
|
||||
default is the empty string) makes the constructor raise ValueError
|
||||
by design; integration setup must not crash on an unconfigured
|
||||
entry, so this factory swaps in the typed-error stub instead and
|
||||
logs ONE clear message. The ValueError itself is never re-raised
|
||||
past this point — it is converted, with the reason logged.
|
||||
|
||||
``session_factory`` overrides where the HTTP session comes from
|
||||
(production: HA's shared aiohttp session via
|
||||
:func:`~.api_client.client_from_entry`); it exists so tests can
|
||||
inject a stub transport without importing aiohttp — the mock-only
|
||||
test harness has no ``homeassistant.helpers.aiohttp_client``.
|
||||
"""
|
||||
base_url, panel_token = resolve_api_config(entry)
|
||||
if not isinstance(panel_token, str) or not panel_token.strip():
|
||||
LOGGER.warning(
|
||||
"NestQuest API panel token is not configured for entry %s: "
|
||||
"entities will stay unavailable until a token is set in the "
|
||||
"integration options",
|
||||
entry.entry_id,
|
||||
now = self._local_now()
|
||||
return await build_snapshot(
|
||||
self.database, self.settings, now
|
||||
)
|
||||
return _UnconfiguredApiClient()
|
||||
if session_factory is not None:
|
||||
return NestQuestApiClient(
|
||||
base_url, panel_token, session_factory(hass)
|
||||
)
|
||||
from custom_components.nestquest.api_client import client_from_entry
|
||||
|
||||
return client_from_entry(hass, entry)
|
||||
|
||||
|
||||
#: Test seam: entry_id → client, consulted by the conftest's client
|
||||
#: factory BEFORE the production path. Always empty in production
|
||||
#: (nothing in the integration writes to it); tests register scripted
|
||||
#: clients through it and clear it between tests.
|
||||
_COORDINATOR_CLIENT_OVERRIDES: dict[str, Any] = {}
|
||||
|
||||
@@ -20,25 +20,40 @@ TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
|
||||
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
|
||||
|
||||
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
|
||||
# panel and admin cards listen for these; since Feature 18 the
|
||||
# completed/uncompleted/day-complete transitions are published by the
|
||||
# API service on its SSE stream and re-fired on the bus by the
|
||||
# integration's subscription, and Feature 11's nightly sweep fires
|
||||
# quest_missed.
|
||||
# panel and admin cards listen for these; Feature 10 fires completed,
|
||||
# uncompleted, and child_day_complete from the service paths, and
|
||||
# Feature 11's nightly sweep fires quest_missed.
|
||||
EVENT_QUEST_COMPLETED = "nestquest_quest_completed"
|
||||
EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted"
|
||||
EVENT_QUEST_MISSED = "nestquest_quest_missed"
|
||||
EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete"
|
||||
|
||||
#: Canonical domain-global service name (design/ENTITIES-AND-SERVICES.md §2).
|
||||
#: Since Feature 18 only the panel completion service remains: the admin
|
||||
#: surface is the PWA and the API service's admin routes, so the former
|
||||
#: HA admin services (uncomplete, quest-definition and presence
|
||||
#: management, CSV export, child management, regenerate) are gone.
|
||||
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
|
||||
#: plus the existing Feature 07 regenerate service).
|
||||
SERVICE_COMPLETE_QUEST = "complete_quest"
|
||||
SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest"
|
||||
SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition"
|
||||
SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition"
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active"
|
||||
SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern"
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override"
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override"
|
||||
SERVICE_EXPORT_HISTORY_CSV = "export_history_csv"
|
||||
SERVICE_MANAGE_CHILD = "manage_child"
|
||||
SERVICE_REGENERATE = "regenerate"
|
||||
|
||||
DOMAIN_SERVICES: tuple[str, ...] = (
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
)
|
||||
|
||||
SQLITE_DB_FILENAME = "nestquest.db"
|
||||
@@ -66,21 +81,6 @@ CONF_UPDATE_INTERVAL = "update_interval"
|
||||
DEFAULT_UPDATE_INTERVAL = 300
|
||||
MIN_UPDATE_INTERVAL = 30
|
||||
|
||||
# Last-good snapshot staleness (Feature 18), in SECONDS: how long the
|
||||
# coordinator keeps SERVING the last good snapshot after the API stops
|
||||
# answering. A failed poll whose elapsed time since the last successful
|
||||
# fetch is within the threshold returns the cached snapshot (the panel
|
||||
# keeps rendering last-good data, entities stay available); past it the
|
||||
# poll fails the HA way and the entities go unavailable. The default is
|
||||
# 3x the default update interval (900 s = 15 minutes): three consecutive
|
||||
# failed five-minute polls before the board is declared stale — enough
|
||||
# to ride out an API service restart, short enough not to render a
|
||||
# days-old board all day. Validation floor: the threshold must be at
|
||||
# least the configured update interval, or the cache could never span
|
||||
# even one failed poll.
|
||||
CONF_SNAPSHOT_STALENESS = "snapshot_staleness"
|
||||
DEFAULT_SNAPSHOT_STALENESS = 3 * DEFAULT_UPDATE_INTERVAL
|
||||
|
||||
# Notification automations (Feature 11). The notify target is a free-text
|
||||
# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the
|
||||
# household fills in — never a hard-coded personal target; the times are
|
||||
@@ -101,32 +101,6 @@ CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
|
||||
CONF_CELEBRATION_ENABLED = "celebration_enabled"
|
||||
DEFAULT_AUTOMATION_ENABLED = True
|
||||
|
||||
# Panel-plane API client (Feature 18): the integration reaches the
|
||||
# NestQuest API service (Feature 16) over HTTP instead of the database.
|
||||
# The base URL points at the API service host serving ``/api/v1/panel/*``
|
||||
# (uvicorn's default host/port is the sensible starting point); the panel
|
||||
# service token is the static Bearer credential the panel plane requires
|
||||
# (``NESTQUEST_PANEL_TOKEN`` on the API side). Both are stored on the
|
||||
# entry like every other setting — options first, then the entry-data
|
||||
# copy, then defaults. The TOKEN IS A SECRET: it is never logged, never
|
||||
# echoed into error messages, and never committed. The shipped default
|
||||
# token IS the empty string (DEFAULT_PANEL_TOKEN) — an entry with no
|
||||
# configured token is therefore NOT a working client: the options flow
|
||||
# accepts an empty token as "not configured yet" and only rejects
|
||||
# malformed (padded/whitespace-only/non-string) values — the actual
|
||||
# non-emptiness guard is enforced at client construction, where
|
||||
# NestQuestApiClient fails fast on an empty/whitespace token
|
||||
# (api_client.py). So a caller building the client from a bare
|
||||
# entry must either ensure a token is configured first or handle the
|
||||
# construction ValueError and surface an "API not configured" state —
|
||||
# an unconfigured entry never yields a client that can only 401.
|
||||
# This mirrors the API service's fail-closed stance (api/config.py: no
|
||||
# default token exists).
|
||||
CONF_API_BASE_URL = "api_base_url"
|
||||
DEFAULT_API_BASE_URL = "http://127.0.0.1:8000"
|
||||
CONF_PANEL_TOKEN = "panel_token"
|
||||
DEFAULT_PANEL_TOKEN = ""
|
||||
|
||||
# Quest windows (D-008): a definition may declare several day windows;
|
||||
# each produces its own instance per day and groups the panel's Quest
|
||||
# Log into three columns. The clock ranges are stored here so they stay
|
||||
|
||||
@@ -1,405 +0,0 @@
|
||||
"""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,46 @@
|
||||
"""Explicit admin-only versus open service registry (Feature 09).
|
||||
|
||||
One mapping, one file: every registered ``nestquest`` service name is
|
||||
either ``open`` or ``admin_only``. The permission decorator (later
|
||||
Feature 09 tasks) reads this table; it does not take per-handler
|
||||
policy arguments.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .const import (
|
||||
DOMAIN_SERVICES,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
)
|
||||
|
||||
OPEN = "open"
|
||||
ADMIN_ONLY = "admin_only"
|
||||
|
||||
SERVICE_POLICY: dict[str, str] = {
|
||||
SERVICE_COMPLETE_QUEST: OPEN,
|
||||
SERVICE_UNCOMPLETE_QUEST: ADMIN_ONLY,
|
||||
SERVICE_CREATE_QUEST_DEFINITION: ADMIN_ONLY,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION: ADMIN_ONLY,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE: ADMIN_ONLY,
|
||||
SERVICE_SET_PRESENCE_PATTERN: ADMIN_ONLY,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
||||
SERVICE_EXPORT_HISTORY_CSV: ADMIN_ONLY,
|
||||
SERVICE_MANAGE_CHILD: ADMIN_ONLY,
|
||||
SERVICE_REGENERATE: ADMIN_ONLY,
|
||||
}
|
||||
|
||||
if frozenset(SERVICE_POLICY) != frozenset(DOMAIN_SERVICES):
|
||||
raise RuntimeError(
|
||||
"SERVICE_POLICY must name exactly the registered nestquest services, "
|
||||
f"got {sorted(SERVICE_POLICY)} vs {list(DOMAIN_SERVICES)}"
|
||||
)
|
||||
@@ -1,210 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,143 +0,0 @@
|
||||
"""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,12 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_children` for the integration package.
|
||||
|
||||
The children/admin DAO lives in
|
||||
:mod:`custom_components.nestquest.core.dao_children` (extracted in
|
||||
task d0b691d8); this module re-exports it so existing
|
||||
``from .dao_children import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.dao_children import * # noqa: F401,F403
|
||||
from .core.dao_children import _CONNECTION_LOCKS, _connection_lock # noqa: F401
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_instances` for the integration package.
|
||||
|
||||
The instances/completion-events DAO lives in
|
||||
:mod:`custom_components.nestquest.core.dao_instances`
|
||||
(extracted in task d0b691d8); this module re-exports it so existing
|
||||
``from .dao_instances import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.dao_instances import * # noqa: F401,F403
|
||||
from .core.dao_instances import _resolve_today # noqa: F401
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_meta` for the integration package.
|
||||
|
||||
The meta-state DAO lives in
|
||||
:mod:`custom_components.nestquest.core.dao_meta` (extracted in task
|
||||
d0b691d8); this module re-exports it so existing
|
||||
``from .dao_meta import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.dao_meta import * # noqa: F401,F403
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_presence` for the integration package.
|
||||
|
||||
The presence DAOs live in
|
||||
:mod:`custom_components.nestquest.core.dao_presence` (extracted in
|
||||
task d0b691d8); this module re-exports them so existing
|
||||
``from .dao_presence import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.dao_presence import * # noqa: F401,F403
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_rules` for the integration package.
|
||||
|
||||
The schedule-rules/quest-definitions DAO lives in
|
||||
:mod:`custom_components.nestquest.core.dao_rules` (extracted in task
|
||||
d0b691d8); this module re-exports it so existing
|
||||
``from .dao_rules import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.dao_rules import * # noqa: F401,F403
|
||||
from .core.dao_rules import ( # noqa: F401
|
||||
_UNSET,
|
||||
_validate_date,
|
||||
_validate_time,
|
||||
_validate_window,
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.db` for the integration package.
|
||||
|
||||
The async SQLite wrapper lives in :mod:`custom_components.nestquest.core.db` (extracted in task
|
||||
d0b691d8) and is Home-Assistant-free: :class:`NestQuestDatabase` takes
|
||||
an executor callable rather than a ``hass`` object. This module
|
||||
re-exports it and provides :func:`make_database`, a thin adapter that
|
||||
constructs a wrapper backed by ``hass.async_add_executor_job``, so the
|
||||
integration constructs the database the same way it always did.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .core.db import ExecutionResult, NestQuestDatabase
|
||||
|
||||
__all__ = ["ExecutionResult", "NestQuestDatabase", "make_database"]
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def make_database(hass: HomeAssistant) -> NestQuestDatabase:
|
||||
"""Construct a :class:`NestQuestDatabase` wired to ``hass``'s executor.
|
||||
|
||||
Mirrors the pre-extraction ``NestQuestDatabase(hass)`` call: the
|
||||
wrapper's executor is ``hass.async_add_executor_job``, so every
|
||||
sqlite3 call runs on the HA executor thread and the event loop
|
||||
never blocks.
|
||||
"""
|
||||
return NestQuestDatabase(hass.async_add_executor_job)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Re-export / HA wiring of :mod:`custom_components.nestquest.core.events` for the integration package.
|
||||
|
||||
The transition-event payload construction lives in
|
||||
:mod:`custom_components.nestquest.core.events` (extracted in
|
||||
task 510c1f78) and is HA-free: it builds and RETURNS the events. This
|
||||
shim keeps the integration's old ``fire_quest_completed`` /
|
||||
``fire_quest_uncompleted`` API (the complete/uncomplete service
|
||||
handlers call them with ``hass``), reads the HA-local timezone from
|
||||
``hass.config.time_zone`` to derive ``today``, asks the core builder
|
||||
for the events, and fires each on ``hass.bus`` with the documented
|
||||
payloads. The four blueprints keep working with identical payloads.
|
||||
|
||||
The completed event is fired BEFORE the day-complete rule evaluates:
|
||||
``fire_quest_completed`` asks
|
||||
:func:`build_quest_completed_event` for the completed event and fires
|
||||
it immediately, THEN reads the HA-local ``today``, asks
|
||||
:func:`build_child_day_complete_event` for the day-complete event, and
|
||||
fires it (if any) afterwards. This restores the pre-extraction
|
||||
ordering — a failure in the day-complete DB reads or the ZoneInfo
|
||||
lookup cannot suppress the completed event.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .core.db import NestQuestDatabase
|
||||
from .core.events import (
|
||||
build_child_day_complete_event,
|
||||
build_quest_completed_event,
|
||||
build_quest_uncompleted_events,
|
||||
)
|
||||
|
||||
__all__ = ["fire_quest_completed", "fire_quest_uncompleted"]
|
||||
|
||||
|
||||
async def fire_quest_completed(
|
||||
hass,
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
*,
|
||||
was_on_time: bool | None,
|
||||
) -> None:
|
||||
"""Fire ``nestquest_quest_completed`` and, when the completion
|
||||
cleared the child's whole day, ``nestquest_child_day_complete``.
|
||||
|
||||
Thin HA wiring over the HA-free
|
||||
:func:`custom_components.nestquest.core.events.build_quest_completed_event`
|
||||
and
|
||||
:func:`build_child_day_complete_event`: the completed event is
|
||||
built and fired FIRST (so a failure in the day-complete DB reads
|
||||
or the HA-local ``today`` lookup cannot suppress it), then the
|
||||
HA-local ``today`` (the day-complete rule's input) is read from
|
||||
``hass.config.time_zone`` and the day-complete builder is asked
|
||||
whether the child's whole day cleared. Identical payloads and
|
||||
day-complete rule to the pre-extraction implementation.
|
||||
"""
|
||||
completed = await build_quest_completed_event(
|
||||
database,
|
||||
instance_id,
|
||||
was_on_time=was_on_time,
|
||||
)
|
||||
hass.bus.async_fire(*completed)
|
||||
|
||||
today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
|
||||
day_complete = await build_child_day_complete_event(
|
||||
database, completed[1], today=today
|
||||
)
|
||||
if day_complete is not None:
|
||||
hass.bus.async_fire(*day_complete)
|
||||
|
||||
|
||||
async def fire_quest_uncompleted(
|
||||
hass, database: NestQuestDatabase, instance_id: int
|
||||
) -> None:
|
||||
"""Fire ``nestquest_quest_uncompleted`` for an actual reversal."""
|
||||
events = await build_quest_uncompleted_events(database, instance_id)
|
||||
for event_type, payload in events:
|
||||
hass.bus.async_fire(event_type, payload)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.materialize` for the integration package.
|
||||
|
||||
The materialization walk lives in
|
||||
:mod:`custom_components.nestquest.core.materialize` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .materialize import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.materialize import * # noqa: F401,F403
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.migrations` for the integration package.
|
||||
|
||||
The versioned migration runner lives in
|
||||
:mod:`custom_components.nestquest.core.migrations`
|
||||
(extracted in task d0b691d8); this module re-exports it so existing
|
||||
``from .migrations import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.migrations import * # noqa: F401,F403
|
||||
@@ -1,15 +1,4 @@
|
||||
"""Options flow for NestQuest post-setup settings.
|
||||
|
||||
Since the DB removal (Feature 18) the integration is a thin client of
|
||||
the NestQuest API service: the settings that only served the local
|
||||
database (the generation horizon, the day rollover, the notification
|
||||
defaults, the admin allowlist) are gone — the API service owns those in
|
||||
its own settings store, and the automations take their behaviour from
|
||||
blueprint inputs. What remains is what the CLIENT itself consumes: the
|
||||
panel-plane API connection (base URL + token), the shared coordinator's
|
||||
refresh interval, the last-good staleness threshold, and the
|
||||
panel-facing idle timeout.
|
||||
"""
|
||||
"""Options flow for NestQuest post-setup settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
@@ -17,39 +6,61 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
CONF_API_BASE_URL,
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_NOTIFY_TARGET,
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
CONF_PANEL_TOKEN,
|
||||
CONF_SNAPSHOT_STALENESS,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_API_BASE_URL,
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_PANEL_IDLE_TIMEOUT,
|
||||
DEFAULT_PANEL_TOKEN,
|
||||
DEFAULT_SNAPSHOT_STALENESS,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
MIN_UPDATE_INTERVAL,
|
||||
TIME_PATTERN,
|
||||
)
|
||||
from .api_client import is_valid_base_url
|
||||
from .admin_allowlist import list_admin_ids, set_admin_ids
|
||||
|
||||
MIN_HORIZON_DAYS = 1
|
||||
MIN_PANEL_IDLE_TIMEOUT = 30
|
||||
|
||||
|
||||
def _async_validate(
|
||||
user_input: dict[str, Any],
|
||||
current: dict[str, Any] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Validate user input and return field-level errors instead of raising.
|
||||
def _parse_time(value: str) -> bool:
|
||||
"""Return True when value is a valid 24-hour HH:MM string."""
|
||||
return TIME_PATTERN.fullmatch(value) is not None
|
||||
|
||||
``current`` carries the stored values (entry options → entry data →
|
||||
defaults) so a raw submit that sets one field but omits a sibling it
|
||||
is validated against (the staleness threshold vs. the update
|
||||
interval) can still check the effective pair; raw submits omitting
|
||||
BOTH keys keep their stored values untouched.
|
||||
"""
|
||||
|
||||
def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
||||
"""Validate user input and return field-level errors instead of raising."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
horizon_days = user_input.get(CONF_HORIZON_DAYS)
|
||||
if (
|
||||
isinstance(horizon_days, bool)
|
||||
or not isinstance(horizon_days, int)
|
||||
or horizon_days < MIN_HORIZON_DAYS
|
||||
):
|
||||
errors[CONF_HORIZON_DAYS] = "invalid"
|
||||
|
||||
day_rollover_time = user_input.get(CONF_DAY_ROLLOVER_TIME)
|
||||
if not isinstance(day_rollover_time, str) or not _parse_time(day_rollover_time):
|
||||
errors[CONF_DAY_ROLLOVER_TIME] = "invalid_time"
|
||||
|
||||
panel_idle_timeout = user_input.get(CONF_PANEL_IDLE_TIMEOUT)
|
||||
if (
|
||||
isinstance(panel_idle_timeout, bool)
|
||||
@@ -70,127 +81,270 @@ def _async_validate(
|
||||
):
|
||||
errors[CONF_UPDATE_INTERVAL] = "invalid"
|
||||
|
||||
# The last-good staleness threshold (Feature 18) is OPTIONAL in raw
|
||||
# submits the same way: a missing key keeps the current value. It
|
||||
# must be a positive int, and at least the EFFECTIVE update interval
|
||||
# (the one this submit carries, else the stored or default one) — a
|
||||
# threshold shorter than the poll cadence could never serve even one
|
||||
# cached snapshot.
|
||||
staleness = user_input.get(CONF_SNAPSHOT_STALENESS)
|
||||
if staleness is not None and (
|
||||
isinstance(staleness, bool)
|
||||
or not isinstance(staleness, int)
|
||||
or staleness < 1
|
||||
# The notification section (Feature 11) is OPTIONAL in raw submits
|
||||
# the same way: a missing key keeps the current value. The three
|
||||
# times are strict HH:MM; the notify target is a non-empty HA
|
||||
# service name; the toggles are real booleans.
|
||||
for time_key in (
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
):
|
||||
errors[CONF_SNAPSHOT_STALENESS] = "invalid"
|
||||
elif staleness is not None and not errors.get(CONF_UPDATE_INTERVAL):
|
||||
stored_interval = (current or {}).get(CONF_UPDATE_INTERVAL)
|
||||
effective_interval = update_interval
|
||||
if effective_interval is None and (
|
||||
isinstance(stored_interval, int)
|
||||
and not isinstance(stored_interval, bool)
|
||||
and stored_interval >= MIN_UPDATE_INTERVAL
|
||||
time_value = user_input.get(time_key)
|
||||
if time_value is not None and (
|
||||
not isinstance(time_value, str) or not _parse_time(time_value)
|
||||
):
|
||||
effective_interval = stored_interval
|
||||
if effective_interval is None:
|
||||
effective_interval = DEFAULT_UPDATE_INTERVAL
|
||||
if staleness < effective_interval:
|
||||
errors[CONF_SNAPSHOT_STALENESS] = "invalid_staleness"
|
||||
|
||||
# The panel-plane API connection (Feature 18) is OPTIONAL in raw
|
||||
# submits the same way: a missing key keeps the current value. The
|
||||
# base URL must be a full http(s) URL with a host — the same check
|
||||
# the client's constructor enforces, so a malformed URL fails at
|
||||
# the form, not at the first request. The token may be EMPTY (the
|
||||
# API connection is simply not configured yet) but a non-empty one
|
||||
# must be pasted unpadded: a padded or whitespace-only value is a
|
||||
# clipboard mistake that would fail the token check at request
|
||||
# time.
|
||||
api_base_url = user_input.get(CONF_API_BASE_URL)
|
||||
if api_base_url is not None and not is_valid_base_url(api_base_url):
|
||||
errors[CONF_API_BASE_URL] = "invalid_url"
|
||||
panel_token = user_input.get(CONF_PANEL_TOKEN)
|
||||
if panel_token is not None and (
|
||||
not isinstance(panel_token, str)
|
||||
or panel_token != panel_token.strip()
|
||||
errors[time_key] = "invalid_time"
|
||||
notify_target = user_input.get(CONF_NOTIFY_TARGET)
|
||||
if notify_target is not None and not isinstance(notify_target, str):
|
||||
errors[CONF_NOTIFY_TARGET] = "invalid"
|
||||
elif isinstance(notify_target, str) and notify_target != (
|
||||
notify_target.strip()
|
||||
):
|
||||
errors[CONF_PANEL_TOKEN] = "invalid"
|
||||
# An EMPTY target is valid — it means "no notifications
|
||||
# configured yet" and is the shipped default; a value that
|
||||
# differs from its own trim (padded or whitespace-only) is
|
||||
# malformed and rejected.
|
||||
errors[CONF_NOTIFY_TARGET] = "invalid"
|
||||
for toggle_key in (
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
):
|
||||
toggle_value = user_input.get(toggle_key)
|
||||
if toggle_value is not None and not isinstance(toggle_value, bool):
|
||||
errors[toggle_key] = "invalid"
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _build_schema(current: dict[str, Any]) -> vol.Schema:
|
||||
def _strict_bool(value: object) -> bool:
|
||||
"""Require a real bool; voluptuous ``bool`` would coerce strings."""
|
||||
if not isinstance(value, bool):
|
||||
raise vol.Invalid(f"expected a boolean, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _build_schema(
|
||||
current: dict[str, Any],
|
||||
*,
|
||||
admin_choices: dict[str, str] | None = None,
|
||||
admin_default: list[str] | None = None,
|
||||
) -> vol.Schema:
|
||||
"""Build the form schema, pre-filled with the current values.
|
||||
|
||||
The API connection (Feature 18) carries the base URL the API client
|
||||
talks to and the panel service token it presents as the Bearer
|
||||
credential. Schema-validated submits always carry every field (the
|
||||
defaults pre-fill from the stored values); raw callers omitting one
|
||||
keep its stored value (see _async_validate).
|
||||
The admin picker is a multi-select of EXISTING Home Assistant users
|
||||
(never a free-text field — the allowlist stores user IDs); its
|
||||
default is the current database allowlist, so an untouched submit
|
||||
re-saves exactly who is already allowed. The notification section
|
||||
(Feature 11) carries the household's notify target, the three
|
||||
automation times, and four enable toggles — defaults from
|
||||
:mod:`~.const`, never a hard-coded personal target.
|
||||
"""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
default=current[CONF_PANEL_IDLE_TIMEOUT],
|
||||
): int,
|
||||
vol.Required(
|
||||
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
|
||||
): int,
|
||||
# The last-good staleness threshold (Feature 18): how long
|
||||
# the coordinator keeps serving the cached snapshot after
|
||||
# the API stops answering.
|
||||
vol.Required(
|
||||
CONF_SNAPSHOT_STALENESS,
|
||||
default=current[CONF_SNAPSHOT_STALENESS],
|
||||
): int,
|
||||
# Panel-plane API connection (Feature 18): the base URL the
|
||||
# API client talks to and the panel service token it
|
||||
# presents as the Bearer credential.
|
||||
vol.Required(
|
||||
CONF_API_BASE_URL, default=current[CONF_API_BASE_URL]
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_PANEL_TOKEN, default=current[CONF_PANEL_TOKEN]
|
||||
): str,
|
||||
}
|
||||
)
|
||||
fields: dict[Any, Any] = {
|
||||
vol.Required(
|
||||
CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS]
|
||||
): int,
|
||||
vol.Required(
|
||||
CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME]
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
|
||||
): int,
|
||||
vol.Required(
|
||||
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
|
||||
): int,
|
||||
vol.Required(
|
||||
CONF_NOTIFY_TARGET, default=current[CONF_NOTIFY_TARGET]
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
default=current[CONF_MORNING_SUMMARY_TIME],
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
default=current[CONF_AFTERNOON_REMINDER_TIME],
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
default=current[CONF_END_OF_DAY_REPORT_TIME],
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
default=current[CONF_MORNING_SUMMARY_ENABLED],
|
||||
): _strict_bool,
|
||||
vol.Required(
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
default=current[CONF_AFTERNOON_REMINDER_ENABLED],
|
||||
): _strict_bool,
|
||||
vol.Required(
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
default=current[CONF_END_OF_DAY_REPORT_ENABLED],
|
||||
): _strict_bool,
|
||||
vol.Required(
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
default=current[CONF_CELEBRATION_ENABLED],
|
||||
): _strict_bool,
|
||||
}
|
||||
if admin_choices is not None:
|
||||
# HA's supported multi-select validator: the frontend can
|
||||
# serialize the field as a real picker of the given choices.
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_ADMIN_USER_IDS, default=list(admin_default or [])
|
||||
)
|
||||
] = cv.multi_select(admin_choices)
|
||||
return vol.Schema(fields)
|
||||
|
||||
|
||||
class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||
"""Handle the options flow for NestQuest."""
|
||||
|
||||
def _async_database(self):
|
||||
"""Return the runtime database for this entry, or None.
|
||||
|
||||
Real HA only opens the options flow for an existing entry, so
|
||||
the runtime record (and its database) exists; the None branch
|
||||
covers direct construction in tests and degraded states.
|
||||
"""
|
||||
hass_data = getattr(self.hass, "data", None)
|
||||
if not isinstance(hass_data, dict):
|
||||
return None
|
||||
record = hass_data.get(DOMAIN, {}).get(self.config_entry.entry_id)
|
||||
database = getattr(record, "database", None)
|
||||
return database if getattr(database, "connected", False) else None
|
||||
|
||||
async def _async_admin_choices(self) -> dict[str, str]:
|
||||
"""Map HA user id -> display name for the picker.
|
||||
|
||||
Real Home Assistant always provides ``hass.auth``; a missing
|
||||
one (direct construction in tests, degraded harness) degrades
|
||||
to an empty picker rather than crashing the form.
|
||||
"""
|
||||
auth = getattr(self.hass, "auth", None)
|
||||
if auth is None:
|
||||
return {}
|
||||
users = await auth.async_get_users()
|
||||
return {
|
||||
user.id: user.name
|
||||
for user in users
|
||||
if getattr(user, "id", None)
|
||||
}
|
||||
|
||||
async def _async_current_admin_ids(self) -> list[str]:
|
||||
"""Return the current allowlist, or [] without a database."""
|
||||
database = self._async_database()
|
||||
if database is None:
|
||||
return []
|
||||
return await list_admin_ids(database)
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Manage the NestQuest options."""
|
||||
admin_choices = await self._async_admin_choices()
|
||||
admin_default = await self._async_current_admin_ids()
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=_build_schema(self._current_values()),
|
||||
data_schema=_build_schema(
|
||||
self._current_values(),
|
||||
admin_choices=admin_choices,
|
||||
admin_default=admin_default,
|
||||
),
|
||||
)
|
||||
|
||||
errors = _async_validate(user_input, self._current_values())
|
||||
errors = _async_validate(user_input)
|
||||
# The admin ids are validated only when the key is present:
|
||||
# schema-validated submits always carry it (vol.Optional's
|
||||
# default fills from the current allowlist), while raw callers
|
||||
# omitting the key keep the field untouched.
|
||||
admin_ids_present = CONF_ADMIN_USER_IDS in user_input
|
||||
admin_ids = user_input.get(CONF_ADMIN_USER_IDS)
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=_build_schema(self._current_values()),
|
||||
data_schema=_build_schema(
|
||||
self._current_values(),
|
||||
admin_choices=admin_choices,
|
||||
admin_default=admin_default,
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
if admin_ids_present and (
|
||||
not isinstance(admin_ids, list)
|
||||
or any(not isinstance(value, str) for value in admin_ids)
|
||||
):
|
||||
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
|
||||
elif admin_ids_present and not admin_ids:
|
||||
# An empty allowlist fails closed: saving zero admins is
|
||||
# refused, never silently applied.
|
||||
errors[CONF_ADMIN_USER_IDS] = "no_admins"
|
||||
elif admin_ids_present and any(
|
||||
value not in admin_choices for value in admin_ids
|
||||
):
|
||||
# The schema enforces membership for real HA submits; this
|
||||
# backs the raw-input path up so an unknown id can never
|
||||
# reach the allowlist.
|
||||
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
|
||||
elif admin_ids_present and len(set(admin_ids)) != len(admin_ids):
|
||||
# set_admin_ids refuses duplicates with ValueError; a flow
|
||||
# submission must surface that as a field error instead of
|
||||
# an unhandled crash.
|
||||
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=_build_schema(
|
||||
self._current_values(),
|
||||
admin_choices=admin_choices,
|
||||
admin_default=admin_default,
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# Every remaining key is stored the fall-back-to-current way, so
|
||||
# raw submits that predate a section keep that section's stored
|
||||
# values.
|
||||
# Apply the picker's selection to the allowlist when the
|
||||
# runtime database is reachable; the selection is always
|
||||
# stored on the entry as the disaster-recovery copy.
|
||||
database = self._async_database()
|
||||
if database is not None and admin_ids_present:
|
||||
await set_admin_ids(database, admin_ids)
|
||||
data = {
|
||||
CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS],
|
||||
CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME],
|
||||
CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT],
|
||||
CONF_UPDATE_INTERVAL: user_input.get(
|
||||
CONF_UPDATE_INTERVAL,
|
||||
self._current_values()[CONF_UPDATE_INTERVAL],
|
||||
),
|
||||
}
|
||||
# The notification section (Feature 11) stores the household's
|
||||
# choices the same fall-back-to-current way, so raw submits
|
||||
# that predate the section keep their stored values.
|
||||
current = self._current_values()
|
||||
data: dict[str, Any] = {}
|
||||
for key in (
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
CONF_SNAPSHOT_STALENESS,
|
||||
CONF_API_BASE_URL,
|
||||
CONF_PANEL_TOKEN,
|
||||
for notification_key in (
|
||||
CONF_NOTIFY_TARGET,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
):
|
||||
data[key] = user_input.get(key, current[key])
|
||||
data[notification_key] = user_input.get(
|
||||
notification_key, current[notification_key]
|
||||
)
|
||||
if admin_ids_present:
|
||||
data[CONF_ADMIN_USER_IDS] = admin_ids
|
||||
elif admin_default:
|
||||
# HA replaces entry.options wholesale, so an omitted picker
|
||||
# field must carry the CURRENT allowlist forward: dropping
|
||||
# it would leave only the stale config-flow copy in entry
|
||||
# data, and a later database loss would resurrect admins
|
||||
# the owner has since removed.
|
||||
data[CONF_ADMIN_USER_IDS] = list(admin_default)
|
||||
return self.async_create_entry(title="NestQuest", data=data)
|
||||
|
||||
def _current_values(self) -> dict[str, Any]:
|
||||
@@ -199,11 +353,18 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||
entry_data = getattr(self.config_entry, "data", None) or {}
|
||||
values: dict[str, Any] = {}
|
||||
for key, default in (
|
||||
(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS),
|
||||
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
|
||||
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
|
||||
(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL),
|
||||
(CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS),
|
||||
(CONF_API_BASE_URL, DEFAULT_API_BASE_URL),
|
||||
(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
|
||||
(CONF_NOTIFY_TARGET, ""),
|
||||
(CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME),
|
||||
(CONF_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME),
|
||||
(CONF_END_OF_DAY_REPORT_TIME, DEFAULT_END_OF_DAY_REPORT_TIME),
|
||||
(CONF_MORNING_SUMMARY_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
(CONF_AFTERNOON_REMINDER_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
(CONF_END_OF_DAY_REPORT_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
(CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
):
|
||||
if key in options:
|
||||
values[key] = options[key]
|
||||
@@ -211,4 +372,4 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||
values[key] = entry_data[key]
|
||||
else:
|
||||
values[key] = default
|
||||
return values
|
||||
return values
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Permission gate for NestQuest services (Feature 09).
|
||||
|
||||
A wrapper factory, applied to EVERY registered service at registration
|
||||
time (no handler is exempt), reads ``call.context.user_id``, consults
|
||||
the :data:`~.service_policy.SERVICE_POLICY` registry and
|
||||
:func:`~.admin_allowlist.is_admin`, and raises
|
||||
:class:`homeassistant.exceptions.Unauthorized` for an admin-only
|
||||
operation called by a non-admin — including a call with NO user
|
||||
context, which fails closed. ``complete_quest`` (policy ``open``)
|
||||
proceeds regardless of the allowlist.
|
||||
|
||||
Logging (later Feature 09 tasks): every denied admin-only call logs a
|
||||
warning and every successful one logs a debug line, both carrying the
|
||||
service, the user id (or ``none``), and a UTC timestamp.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.exceptions import Unauthorized
|
||||
|
||||
from .admin_allowlist import is_admin
|
||||
from .service_policy import ADMIN_ONLY
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ServiceHandler = Callable[[Any], Awaitable[None]]
|
||||
|
||||
|
||||
def _context_user_id(call: Any) -> str | None:
|
||||
context = getattr(call, "context", None)
|
||||
user_id = getattr(context, "user_id", None) if context is not None else None
|
||||
if isinstance(user_id, str) and user_id:
|
||||
return user_id
|
||||
return None
|
||||
|
||||
|
||||
def _stamp() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def permission_gate(
|
||||
service: str,
|
||||
policy: str,
|
||||
database_of: Callable[[Any], Any],
|
||||
) -> Callable[[ServiceHandler], ServiceHandler]:
|
||||
"""Wrap ``handler`` with the Feature 09 permission check.
|
||||
|
||||
``database_of`` resolves the live database from the call-time
|
||||
runtime (the same ``find_runtime`` the handler uses), so the gate
|
||||
never opens its own connection. The verdict is fail-closed:
|
||||
absent allowlist row, unknown id, no user context — all not admin.
|
||||
"""
|
||||
|
||||
def _wrap(handler: ServiceHandler) -> ServiceHandler:
|
||||
if policy != ADMIN_ONLY:
|
||||
return handler
|
||||
|
||||
async def _gated(call: Any) -> None:
|
||||
user_id = _context_user_id(call)
|
||||
allowed = user_id is not None and await is_admin(
|
||||
database_of(call), user_id
|
||||
)
|
||||
if not allowed:
|
||||
LOGGER.warning(
|
||||
"NestQuest denied %s: caller user_id=%s at %s",
|
||||
service,
|
||||
user_id if user_id is not None else "none",
|
||||
_stamp(),
|
||||
)
|
||||
raise Unauthorized(
|
||||
f"User '{user_id if user_id is not None else 'none'}' "
|
||||
f"is not allowed to call nestquest.{service}"
|
||||
)
|
||||
LOGGER.debug(
|
||||
"NestQuest allowed %s: caller user_id=%s at %s",
|
||||
service,
|
||||
user_id,
|
||||
_stamp(),
|
||||
)
|
||||
await handler(call)
|
||||
|
||||
return _gated
|
||||
|
||||
return _wrap
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.presence` for the integration package.
|
||||
|
||||
The presence and custody model lives in
|
||||
:mod:`custom_components.nestquest.core.presence` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .presence import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.presence import * # noqa: F401,F403
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.quest_definitions` for the integration package.
|
||||
|
||||
The quest definitions business layer lives in
|
||||
:mod:`custom_components.nestquest.core.quest_definitions` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .quest_definitions import ...`` references inside the integration
|
||||
keep working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.quest_definitions import * # noqa: F401,F403
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.recurrence` for the integration package.
|
||||
|
||||
The recurrence model lives in :mod:`custom_components.nestquest.core.recurrence`
|
||||
(extracted in task d0b691d8); this module re-exports it so existing
|
||||
``from .recurrence import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.recurrence import * # noqa: F401,F403
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.schema` for the integration package.
|
||||
|
||||
The declarative schema DDL lives in
|
||||
:mod:`custom_components.nestquest.core.schema` (extracted in
|
||||
task d0b691d8); this module re-exports it so existing
|
||||
``from .schema import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.schema import * # noqa: F401,F403
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.service_policy` for the integration package.
|
||||
|
||||
The service policy registry lives in
|
||||
:mod:`custom_components.nestquest.core.service_policy` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .service_policy import ...`` references inside the integration
|
||||
keep working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.service_policy import * # noqa: F401,F403
|
||||
@@ -1,36 +1,61 @@
|
||||
"""The domain-global ``nestquest.complete_quest`` service (Feature 18).
|
||||
"""Domain-global NestQuest service schemas and handlers (Feature 09).
|
||||
|
||||
Registration is domain-scoped and happens once (see
|
||||
:func:`async_register_services`). Since Feature 18 the integration is
|
||||
a client of the NestQuest API service, so ``complete_quest`` proxies
|
||||
to the API's panel complete route through the entry's shared API
|
||||
client instead of writing any database. The former HA admin services
|
||||
(uncomplete, quest-definition and presence management, CSV export,
|
||||
child management, regenerate) are removed: the admin surface is the
|
||||
PWA and the API service's admin routes, so the only HA service left is
|
||||
the panel completion path.
|
||||
: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 (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
|
||||
|
||||
import datetime
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .api_client import NestQuestApiError
|
||||
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,
|
||||
DOMAIN_SERVICES,
|
||||
QUEST_WINDOWS,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
)
|
||||
from .events import fire_quest_completed, fire_quest_uncompleted
|
||||
from .permissions import permission_gate
|
||||
from .recurrence import ScheduleRule
|
||||
from .service_policy import SERVICE_POLICY
|
||||
|
||||
FindRuntime = Callable[[HomeAssistant], Any]
|
||||
ServiceHandler = Callable[[Any], Awaitable[None]]
|
||||
|
||||
ACTOR_PANEL = "panel"
|
||||
ACTOR_USER = "user"
|
||||
MANAGE_CHILD_CREATE = "create"
|
||||
MANAGE_CHILD_EDIT = "edit"
|
||||
MANAGE_CHILD_SET_ACTIVE = "set_active"
|
||||
MANAGE_CHILD_REORDER = "reorder"
|
||||
|
||||
|
||||
def _strict_int(value: object) -> int:
|
||||
@@ -40,6 +65,82 @@ def _strict_int(value: object) -> int:
|
||||
return value
|
||||
|
||||
|
||||
def _strict_bool(value: object) -> bool:
|
||||
"""Reject ints and other truthy values; require a real bool."""
|
||||
if type(value) is not bool:
|
||||
raise vol.Invalid(f"expected bool, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _weekday_list(value: object) -> list[int]:
|
||||
if type(value) is not list:
|
||||
raise vol.Invalid(f"expected a list of weekday ints, got {value!r}")
|
||||
return [_strict_int(item) for item in value]
|
||||
|
||||
|
||||
def _presence_pattern(value: object) -> dict[int, list[int]]:
|
||||
if not isinstance(value, dict):
|
||||
raise vol.Invalid(f"pattern must be a mapping, got {value!r}")
|
||||
pattern: dict[int, list[int]] = {}
|
||||
for week, days in value.items():
|
||||
if isinstance(week, str):
|
||||
try:
|
||||
week_index = int(week)
|
||||
except ValueError as err:
|
||||
raise vol.Invalid(
|
||||
f"pattern week index must be an integer, got {week!r}"
|
||||
) from err
|
||||
if str(week_index) != week:
|
||||
raise vol.Invalid(
|
||||
f"pattern week index must be an integer, got {week!r}"
|
||||
)
|
||||
else:
|
||||
week_index = _strict_int(week)
|
||||
pattern[week_index] = _weekday_list(days)
|
||||
return pattern
|
||||
|
||||
|
||||
def _window_entry(value: object) -> str | tuple[str, str | None]:
|
||||
if isinstance(value, str):
|
||||
if value not in QUEST_WINDOWS:
|
||||
raise vol.Invalid(f"unknown window {value!r}")
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
parsed = vol.Schema(
|
||||
{
|
||||
vol.Required("window"): vol.In(QUEST_WINDOWS),
|
||||
vol.Optional("due_time"): str,
|
||||
}
|
||||
)(value)
|
||||
due_time = parsed.get("due_time")
|
||||
if due_time is None:
|
||||
return parsed["window"]
|
||||
return (parsed["window"], due_time)
|
||||
raise vol.Invalid(
|
||||
f"windows entry must be a window name or object, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def _windows(value: object) -> list[str | tuple[str, str | None]]:
|
||||
if type(value) is not list:
|
||||
raise vol.Invalid(f"windows must be a list, got {value!r}")
|
||||
if not value:
|
||||
raise vol.Invalid("windows must not be empty")
|
||||
return [_window_entry(entry) for entry in value]
|
||||
|
||||
|
||||
def _int_list(value: object) -> list[int]:
|
||||
if type(value) is not list:
|
||||
raise vol.Invalid(f"expected a list of ints, got {value!r}")
|
||||
return [_strict_int(item) for item in value]
|
||||
|
||||
|
||||
def _rule_dict(value: object) -> dict:
|
||||
if not isinstance(value, dict):
|
||||
raise vol.Invalid(f"rule must be a dict, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
SCHEMA_COMPLETE_QUEST = vol.Schema(
|
||||
{
|
||||
vol.Required("instance_id"): _strict_int,
|
||||
@@ -49,22 +150,102 @@ SCHEMA_COMPLETE_QUEST = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_UNCOMPLETE_QUEST = SCHEMA_COMPLETE_QUEST
|
||||
|
||||
SCHEMA_CREATE_QUEST_DEFINITION = vol.Schema(
|
||||
{
|
||||
vol.Required("title"): str,
|
||||
vol.Required("rule"): _rule_dict,
|
||||
vol.Required("assignee_child_ids"): vol.All(
|
||||
_int_list, vol.Length(min=1)
|
||||
),
|
||||
vol.Required("windows"): _windows,
|
||||
vol.Optional("description"): str,
|
||||
vol.Optional("icon"): str,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_UPDATE_QUEST_DEFINITION = vol.Schema(
|
||||
{
|
||||
vol.Required("definition_id"): _strict_int,
|
||||
vol.Optional("title"): str,
|
||||
vol.Optional("description"): vol.Any(str, None),
|
||||
vol.Optional("icon"): vol.Any(str, None),
|
||||
vol.Optional("rule"): _rule_dict,
|
||||
vol.Optional("windows"): _windows,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_SET_QUEST_DEFINITION_ACTIVE = vol.Schema(
|
||||
{
|
||||
vol.Required("definition_id"): _strict_int,
|
||||
vol.Required("is_active"): _strict_bool,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_SET_PRESENCE_PATTERN = vol.Schema(
|
||||
{
|
||||
vol.Required("child_id"): _strict_int,
|
||||
vol.Required("cycle_length_weeks"): _strict_int,
|
||||
vol.Required("anchor_date"): str,
|
||||
vol.Required("pattern"): _presence_pattern,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_CREATE_PRESENCE_OVERRIDE = vol.Schema(
|
||||
{
|
||||
vol.Required("child_id"): _strict_int,
|
||||
vol.Required("start_date"): str,
|
||||
vol.Required("end_date"): str,
|
||||
vol.Required("is_present"): _strict_bool,
|
||||
vol.Optional("note"): str,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_DELETE_PRESENCE_OVERRIDE = vol.Schema(
|
||||
{
|
||||
vol.Required("override_id"): _strict_int,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_EXPORT_HISTORY_CSV = vol.Schema({})
|
||||
|
||||
SCHEMA_MANAGE_CHILD = vol.Schema(
|
||||
{
|
||||
vol.Required("action"): vol.In(
|
||||
[
|
||||
MANAGE_CHILD_CREATE,
|
||||
MANAGE_CHILD_EDIT,
|
||||
MANAGE_CHILD_SET_ACTIVE,
|
||||
MANAGE_CHILD_REORDER,
|
||||
]
|
||||
),
|
||||
vol.Optional("child_id"): _strict_int,
|
||||
vol.Optional("display_name"): str,
|
||||
vol.Optional("colour"): str,
|
||||
vol.Optional("avatar_ref"): str,
|
||||
vol.Optional("sort_order"): _strict_int,
|
||||
vol.Optional("is_active"): _strict_bool,
|
||||
vol.Optional("ordered_ids"): _int_list,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime):
|
||||
runtime = find_runtime(hass)
|
||||
if runtime is None:
|
||||
raise HomeAssistantError(
|
||||
"NestQuest is not loaded; cannot run this service"
|
||||
"NestQuest has no live database; cannot run this service"
|
||||
)
|
||||
return runtime
|
||||
|
||||
|
||||
async def _refresh_entities(runtime: Any) -> None:
|
||||
"""Force an immediate coordinator refresh after the service call.
|
||||
"""Force an immediate coordinator refresh after a service mutation.
|
||||
|
||||
Only the SERVICE path calls this — the scheduled materialize and
|
||||
Only the SERVICE paths call this — the scheduled materialize and
|
||||
rollover internals are direct function calls and never widen this
|
||||
gate — so the entities reflect a completion the moment the service
|
||||
gate — so entities reflect a completion the moment the service
|
||||
call returns instead of waiting for the poll interval.
|
||||
"""
|
||||
coordinator = getattr(runtime, "coordinator", None)
|
||||
@@ -72,6 +253,12 @@ async def _refresh_entities(runtime: Any) -> None:
|
||||
await coordinator.async_refresh()
|
||||
|
||||
|
||||
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
return {"today": today, "horizon_days": runtime.settings.horizon_days}
|
||||
|
||||
|
||||
def _actor_kwargs(call: Any) -> dict[str, Any]:
|
||||
actor = call.data["actor"]
|
||||
if actor == ACTOR_PANEL:
|
||||
@@ -114,50 +301,223 @@ def _complete_quest(
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
instance_id = call.data["instance_id"]
|
||||
# The panel contract (decision 9): a tap completes as the
|
||||
# tapped child profile, the only actor shape the API's panel
|
||||
# route accepts. The payload schema is unchanged — the panel
|
||||
# cards call it with instance_id, actor=panel, actor_child_id.
|
||||
actor = _actor_kwargs(call)
|
||||
if actor["actor_source"] != ACTOR_PANEL:
|
||||
raise HomeAssistantError(
|
||||
"nestquest.complete_quest completes through the API's "
|
||||
"panel route as the tapped child profile; actor must "
|
||||
"be 'panel' with actor_child_id"
|
||||
)
|
||||
# The entry's ONE shared API client (built at setup): the
|
||||
# coordinator's snapshot poll, the SSE subscription, and this
|
||||
# proxy all ride the same instance. An entry with no panel
|
||||
# token runs the unconfigured stub, which has no completion
|
||||
# surface — fail fast with a clear, token-free message.
|
||||
complete_instance = getattr(
|
||||
getattr(runtime.coordinator, "api_client", None),
|
||||
"complete_instance",
|
||||
None,
|
||||
# ``appended`` is decided under the same lock as the write, so
|
||||
# a concurrent duplicate call (a double tap) can not produce a
|
||||
# second transition event: the loser appends nothing.
|
||||
result = await completion_layer.complete_instance(
|
||||
runtime.database,
|
||||
instance_id,
|
||||
**_actor_kwargs(call),
|
||||
)
|
||||
if complete_instance is None:
|
||||
raise HomeAssistantError(
|
||||
"NestQuest API is not configured; cannot complete a "
|
||||
"quest"
|
||||
if result.appended:
|
||||
await fire_quest_completed(
|
||||
hass,
|
||||
runtime.database,
|
||||
instance_id,
|
||||
was_on_time=result.was_on_time,
|
||||
)
|
||||
try:
|
||||
await complete_instance(instance_id, actor["actor_child_id"])
|
||||
except NestQuestApiError as err:
|
||||
# The typed API failure surfaces as a service error the
|
||||
# caller sees — never swallowed, never retried here. The
|
||||
# typed message names method, path, and status only; the
|
||||
# panel token never appears in it.
|
||||
await _refresh_entities(runtime)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _uncomplete_quest(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
instance_id = call.data["instance_id"]
|
||||
result = await completion_layer.uncomplete_instance(
|
||||
runtime.database,
|
||||
instance_id,
|
||||
**_actor_kwargs(call),
|
||||
)
|
||||
if result.appended:
|
||||
await fire_quest_uncompleted(
|
||||
hass, runtime.database, instance_id
|
||||
)
|
||||
await _refresh_entities(runtime)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _create_quest_definition(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
database = _require_runtime(hass, find_runtime).database
|
||||
data = call.data
|
||||
await quest_definitions_layer.create_quest_definition(
|
||||
database,
|
||||
data["title"],
|
||||
ScheduleRule.from_dict(data["rule"]),
|
||||
data["assignee_child_ids"],
|
||||
data["windows"],
|
||||
description=data.get("description"),
|
||||
icon=data.get("icon"),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _update_quest_definition(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
data = call.data
|
||||
kwargs: dict[str, Any] = _generation_kwargs(hass, runtime)
|
||||
if "title" in data:
|
||||
kwargs["title"] = data["title"]
|
||||
if "description" in data:
|
||||
kwargs["description"] = data["description"]
|
||||
if "icon" in data:
|
||||
kwargs["icon"] = data["icon"]
|
||||
if "rule" in data:
|
||||
kwargs["rule"] = ScheduleRule.from_dict(data["rule"])
|
||||
if "windows" in data:
|
||||
kwargs["windows"] = data["windows"]
|
||||
await quest_definitions_layer.edit_quest_definition(
|
||||
runtime.database, data["definition_id"], **kwargs
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _set_quest_definition_active(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
await quest_definitions_layer.set_quest_definition_active(
|
||||
runtime.database,
|
||||
call.data["definition_id"],
|
||||
call.data["is_active"],
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _set_presence_pattern(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
data = call.data
|
||||
await presence_management_layer.set_presence_schedule(
|
||||
runtime.database,
|
||||
data["child_id"],
|
||||
data["cycle_length_weeks"],
|
||||
data["anchor_date"],
|
||||
data["pattern"],
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _create_presence_override(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
data = call.data
|
||||
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"),
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _delete_presence_override(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
await presence_management_layer.delete_presence_override(
|
||||
runtime.database,
|
||||
call.data["override_id"],
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
async def _export_history_csv(_call: Any) -> None:
|
||||
raise HomeAssistantError(
|
||||
"CSV history export is not implemented until Feature 13"
|
||||
)
|
||||
|
||||
|
||||
def _manage_child(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
database = _require_runtime(hass, find_runtime).database
|
||||
data = call.data
|
||||
action = data["action"]
|
||||
if action == MANAGE_CHILD_CREATE:
|
||||
display_name = data.get("display_name")
|
||||
if display_name is None:
|
||||
raise HomeAssistantError(
|
||||
"display_name is required when action is create"
|
||||
)
|
||||
kwargs: dict[str, Any] = {}
|
||||
if "colour" in data:
|
||||
kwargs["colour"] = data["colour"]
|
||||
if "avatar_ref" in data:
|
||||
kwargs["avatar_ref"] = data["avatar_ref"]
|
||||
if "sort_order" in data:
|
||||
kwargs["sort_order"] = data["sort_order"]
|
||||
await children_layer.create_child(
|
||||
database, display_name, **kwargs
|
||||
)
|
||||
return
|
||||
if action == MANAGE_CHILD_EDIT:
|
||||
child_id = data.get("child_id")
|
||||
if child_id is None:
|
||||
raise HomeAssistantError(
|
||||
"child_id is required when action is edit"
|
||||
)
|
||||
kwargs = {}
|
||||
if "display_name" in data:
|
||||
kwargs["display_name"] = data["display_name"]
|
||||
if "colour" in data:
|
||||
kwargs["colour"] = data["colour"]
|
||||
if "avatar_ref" in data:
|
||||
kwargs["avatar_ref"] = data["avatar_ref"]
|
||||
if "sort_order" in data:
|
||||
kwargs["sort_order"] = data["sort_order"]
|
||||
await children_layer.edit_child(database, child_id, **kwargs)
|
||||
return
|
||||
if action == MANAGE_CHILD_SET_ACTIVE:
|
||||
child_id = data.get("child_id")
|
||||
is_active = data.get("is_active")
|
||||
if child_id is None:
|
||||
raise HomeAssistantError(
|
||||
"child_id is required when action is set_active"
|
||||
)
|
||||
if is_active is None:
|
||||
raise HomeAssistantError(
|
||||
"is_active is required when action is set_active"
|
||||
)
|
||||
await children_layer.set_child_active(
|
||||
database, child_id, is_active
|
||||
)
|
||||
return
|
||||
ordered_ids = data.get("ordered_ids")
|
||||
if ordered_ids is None:
|
||||
raise HomeAssistantError(
|
||||
f"NestQuest quest completion failed: {err}"
|
||||
) from err
|
||||
# NO local completion write and NO local event: the API service
|
||||
# emits the transition on its SSE stream and the integration's
|
||||
# subscription re-fires it on the bus — firing it here too
|
||||
# would double-fire. The immediate refresh keeps the old
|
||||
# behaviour where the sensors reflect the completion the
|
||||
# moment the service call returns instead of waiting for the
|
||||
# poll interval.
|
||||
await _refresh_entities(runtime)
|
||||
"ordered_ids is required when action is reorder"
|
||||
)
|
||||
await children_layer.reorder_children(database, ordered_ids)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
@@ -166,25 +526,84 @@ def async_register_services(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
find_runtime: FindRuntime,
|
||||
regenerate_handler: ServiceHandler,
|
||||
) -> None:
|
||||
"""Register the panel completion service once at domain scope.
|
||||
"""Register every canonical service once at domain scope.
|
||||
|
||||
The one remaining HA service is the panel completion path; every
|
||||
admin operation moved to the API service's admin routes, so there
|
||||
is no permission gate to apply here.
|
||||
Every service — regenerate included — passes through the Feature
|
||||
09 permission gate before its handler runs; the gate reads
|
||||
:data:`~.service_policy.SERVICE_POLICY`, so no service can be
|
||||
registered exempt from the policy.
|
||||
"""
|
||||
if hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST):
|
||||
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
||||
return
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
_complete_quest(hass, find_runtime),
|
||||
schema=SCHEMA_COMPLETE_QUEST,
|
||||
)
|
||||
gate = permission_gate
|
||||
specs: list[tuple[str, ServiceHandler, object]] = [
|
||||
(SERVICE_REGENERATE, regenerate_handler, None),
|
||||
(
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
_complete_quest(hass, find_runtime),
|
||||
SCHEMA_COMPLETE_QUEST,
|
||||
),
|
||||
(
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
_uncomplete_quest(hass, find_runtime),
|
||||
SCHEMA_UNCOMPLETE_QUEST,
|
||||
),
|
||||
(
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
_create_quest_definition(hass, find_runtime),
|
||||
SCHEMA_CREATE_QUEST_DEFINITION,
|
||||
),
|
||||
(
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
_update_quest_definition(hass, find_runtime),
|
||||
SCHEMA_UPDATE_QUEST_DEFINITION,
|
||||
),
|
||||
(
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
_set_quest_definition_active(hass, find_runtime),
|
||||
SCHEMA_SET_QUEST_DEFINITION_ACTIVE,
|
||||
),
|
||||
(
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
_set_presence_pattern(hass, find_runtime),
|
||||
SCHEMA_SET_PRESENCE_PATTERN,
|
||||
),
|
||||
(
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
_create_presence_override(hass, find_runtime),
|
||||
SCHEMA_CREATE_PRESENCE_OVERRIDE,
|
||||
),
|
||||
(
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
_delete_presence_override(hass, find_runtime),
|
||||
SCHEMA_DELETE_PRESENCE_OVERRIDE,
|
||||
),
|
||||
(
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
_export_history_csv,
|
||||
SCHEMA_EXPORT_HISTORY_CSV,
|
||||
),
|
||||
(
|
||||
SERVICE_MANAGE_CHILD,
|
||||
_manage_child(hass, find_runtime),
|
||||
SCHEMA_MANAGE_CHILD,
|
||||
),
|
||||
]
|
||||
|
||||
def _database_of(_call: Any) -> Any:
|
||||
return _require_runtime(hass, find_runtime).database
|
||||
|
||||
for service, handler, schema in specs:
|
||||
gated = gate(
|
||||
service, SERVICE_POLICY[service], _database_of
|
||||
)(handler)
|
||||
hass.services.async_register(DOMAIN, service, gated, schema=schema)
|
||||
|
||||
|
||||
def async_deregister_services(hass: HomeAssistant) -> None:
|
||||
"""Remove every canonical service when the last entry unloads."""
|
||||
for service in DOMAIN_SERVICES:
|
||||
if hass.services.has_service(DOMAIN, service):
|
||||
hass.services.async_remove(DOMAIN, service)
|
||||
hass.services.async_remove(DOMAIN, service)
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
# Service declarations for the NestQuest integration (Home Assistant
|
||||
# services.yaml metadata). Services are domain-scoped: nested under the
|
||||
# "nestquest" domain, not repeated per config entry. Since Feature 18
|
||||
# only the panel completion service remains — every admin operation
|
||||
# moved to the PWA and the API service's admin routes.
|
||||
# "nestquest" domain, not repeated per config entry.
|
||||
|
||||
regenerate:
|
||||
name: Regenerate quest instances
|
||||
description: >-
|
||||
Regenerate every future quest instance for the household over the rolling
|
||||
horizon, using the same idempotent materialization as the daily rollover
|
||||
and the startup backfill. Safe to call at any time: a re-run refreshes the
|
||||
same rows instead of duplicating them, and completed instances are never
|
||||
touched.
|
||||
|
||||
complete_quest:
|
||||
name: Complete quest
|
||||
@@ -36,4 +43,284 @@ complete_quest:
|
||||
description: Home Assistant user id when actor is user and no call context is present.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
text:
|
||||
|
||||
uncomplete_quest:
|
||||
name: Uncomplete quest
|
||||
description: Undo a quest instance completion.
|
||||
fields:
|
||||
instance_id:
|
||||
name: Instance ID
|
||||
description: The quest instance to uncomplete.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
actor:
|
||||
name: Actor
|
||||
description: Who is undoing the completion (panel or user).
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- panel
|
||||
- user
|
||||
actor_child_id:
|
||||
name: Actor child ID
|
||||
description: The tapped child profile. Required when actor is panel.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
actor_user_id:
|
||||
name: Actor user ID
|
||||
description: Home Assistant user id when actor is user and no call context is present.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
create_quest_definition:
|
||||
name: Create quest definition
|
||||
description: Create a quest definition with its schedule rule, assignees and windows.
|
||||
fields:
|
||||
title:
|
||||
name: Title
|
||||
description: Display title for the quest.
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
rule:
|
||||
name: Rule
|
||||
description: Recurrence rule as a mapping (rule_type plus shape fields).
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
assignee_child_ids:
|
||||
name: Assignee child IDs
|
||||
description: Child ids this definition is assigned to.
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
windows:
|
||||
name: Windows
|
||||
description: Day windows (morning, afternoon, evening), optionally with a due time.
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
description:
|
||||
name: Description
|
||||
description: Optional longer description.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
icon:
|
||||
name: Icon
|
||||
description: Optional icon name.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
update_quest_definition:
|
||||
name: Update quest definition
|
||||
description: Edit a quest definition's title, description, icon, rule or windows.
|
||||
fields:
|
||||
definition_id:
|
||||
name: Definition ID
|
||||
description: The quest definition to update.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
title:
|
||||
name: Title
|
||||
description: Replacement display title.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
description:
|
||||
name: Description
|
||||
description: Replacement description, or null to clear.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
icon:
|
||||
name: Icon
|
||||
description: Replacement icon, or null to clear.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
rule:
|
||||
name: Rule
|
||||
description: Replacement recurrence rule as a mapping.
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
windows:
|
||||
name: Windows
|
||||
description: Replacement day windows.
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
|
||||
set_quest_definition_active:
|
||||
name: Set quest definition active
|
||||
description: Deactivate or reactivate a quest definition.
|
||||
fields:
|
||||
definition_id:
|
||||
name: Definition ID
|
||||
description: The quest definition to update.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
is_active:
|
||||
name: Active
|
||||
description: Whether the definition should generate future instances.
|
||||
required: true
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
set_presence_pattern:
|
||||
name: Set presence pattern
|
||||
description: Upsert a child's repeating presence schedule.
|
||||
fields:
|
||||
child_id:
|
||||
name: Child ID
|
||||
description: The child whose schedule is being set.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
cycle_length_weeks:
|
||||
name: Cycle length weeks
|
||||
description: Number of weeks in the repeating cycle (1 to 4).
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
min: 1
|
||||
max: 4
|
||||
anchor_date:
|
||||
name: Anchor date
|
||||
description: Cycle anchor as a YYYY-MM-DD date.
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
pattern:
|
||||
name: Pattern
|
||||
description: Mapping of week index to present weekday ints (Monday is 0).
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
|
||||
create_presence_override:
|
||||
name: Create presence override
|
||||
description: Create a date-range presence override for a child.
|
||||
fields:
|
||||
child_id:
|
||||
name: Child ID
|
||||
description: The child the override applies to.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
start_date:
|
||||
name: Start date
|
||||
description: Inclusive start date (YYYY-MM-DD).
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
end_date:
|
||||
name: End date
|
||||
description: Inclusive end date (YYYY-MM-DD).
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
is_present:
|
||||
name: Present
|
||||
description: Whether the child is present for this range.
|
||||
required: true
|
||||
selector:
|
||||
boolean:
|
||||
note:
|
||||
name: Note
|
||||
description: Optional note stored with the override.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
delete_presence_override:
|
||||
name: Delete presence override
|
||||
description: Delete a presence override by id.
|
||||
fields:
|
||||
override_id:
|
||||
name: Override ID
|
||||
description: The override to delete.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
|
||||
export_history_csv:
|
||||
name: Export history CSV
|
||||
description: Export completion history as CSV. Not implemented until Feature 13.
|
||||
|
||||
manage_child:
|
||||
name: Manage child
|
||||
description: Create, edit, activate or reorder child profiles.
|
||||
fields:
|
||||
action:
|
||||
name: Action
|
||||
description: create, edit, set_active, or reorder.
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- create
|
||||
- edit
|
||||
- set_active
|
||||
- reorder
|
||||
child_id:
|
||||
name: Child ID
|
||||
description: Required for edit and set_active.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
display_name:
|
||||
name: Display name
|
||||
description: Required for create; optional for edit.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
colour:
|
||||
name: Colour
|
||||
description: Optional colour string.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
avatar_ref:
|
||||
name: Avatar
|
||||
description: Optional avatar reference.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
sort_order:
|
||||
name: Sort order
|
||||
description: Optional panel sort order.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
is_active:
|
||||
name: Active
|
||||
description: Required for set_active.
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
ordered_ids:
|
||||
name: Ordered IDs
|
||||
description: Complete permutation of child ids. Required for reorder.
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
"""HA-bus subscription over the API service's panel SSE stream.
|
||||
|
||||
The NestQuest API service (Feature 16) publishes every quest
|
||||
transition on ``GET /api/v1/panel/events`` as Server-Sent Events
|
||||
frames. This module is the integration-side subscriber: ONE
|
||||
background task consumes :meth:`~.api_client.NestQuestApiClient
|
||||
.stream_events` and re-fires each frame on ``hass.bus`` with the SAME
|
||||
event type and payload fields the local transition path
|
||||
(:mod:`~.events`) emits — ``nestquest_quest_completed``,
|
||||
``nestquest_quest_uncompleted``, ``nestquest_quest_missed`` and
|
||||
``nestquest_child_day_complete`` — so the four blueprints fire
|
||||
identically whether a completion happens through the integration's own
|
||||
service or through the API service's panel route.
|
||||
|
||||
Failure handling: a failed stream (API down, network blip, non-2xx, a
|
||||
dropped connection) is retried FOREVER with a bounded exponential
|
||||
backoff — :data:`RECONNECT_MIN_SECONDS` after the first failure,
|
||||
doubling up to :data:`RECONNECT_MAX_SECONDS` — so a long API outage
|
||||
neither pins the event loop nor spams the log, and a frame received on
|
||||
a healthy stream resets the backoff. Nothing escapes the task: every
|
||||
failure is logged with its reason and the loop reconnects; a
|
||||
:class:`asyncio.CancelledError` (from :meth:`stop` or integration
|
||||
unload) is the only thing that ends it, and it stops cleanly — no
|
||||
frame fires after :meth:`stop` returns and the response is closed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from .const import LOGGER
|
||||
|
||||
__all__ = [
|
||||
"NestQuestEventStream",
|
||||
"RECONNECT_MAX_SECONDS",
|
||||
"RECONNECT_MIN_SECONDS",
|
||||
]
|
||||
|
||||
#: Delay before the FIRST reconnect attempt after a stream failure.
|
||||
#: One failed frame must not turn into a hot reconnect loop.
|
||||
RECONNECT_MIN_SECONDS = 5.0
|
||||
|
||||
#: Ceiling for the exponential reconnect backoff: a long API outage
|
||||
#: retries at a calm fixed cadence instead of growing without bound.
|
||||
RECONNECT_MAX_SECONDS = 60.0
|
||||
|
||||
|
||||
class NestQuestEventStream:
|
||||
"""Consume the panel SSE stream in the background, fire the HA bus.
|
||||
|
||||
ONE instance per config entry, built in integration setup with the
|
||||
SAME API client the coordinator polls (the client is stateless —
|
||||
one shared session, one connection pool). ``start()`` schedules
|
||||
the background task; ``stop()`` cancels and awaits it, so unload
|
||||
never leaks the task and no frame fires after teardown. The task
|
||||
itself is failure-proof: any exception from the stream (typed
|
||||
:class:`~.api_client.NestQuestApiError` or anything else) is
|
||||
logged and retried after the current backoff, never allowed to
|
||||
escape the task.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: Any,
|
||||
client: Any,
|
||||
*,
|
||||
reconnect_min_seconds: float = RECONNECT_MIN_SECONDS,
|
||||
reconnect_max_seconds: float = RECONNECT_MAX_SECONDS,
|
||||
) -> None:
|
||||
"""Store the hass bus target, the API client, and the backoff.
|
||||
|
||||
The client is anything exposing ``stream_events()`` (the real
|
||||
:class:`~.api_client.NestQuestApiClient` in production, a stub
|
||||
in tests). The backoff bounds must be positive and the minimum
|
||||
must not exceed the maximum — a misconfiguration should fail
|
||||
loudly at construction, not quietly reconnect at delay 0.
|
||||
"""
|
||||
if (
|
||||
not isinstance(reconnect_min_seconds, (int, float))
|
||||
or isinstance(reconnect_min_seconds, bool)
|
||||
or reconnect_min_seconds <= 0
|
||||
or reconnect_min_seconds > reconnect_max_seconds
|
||||
):
|
||||
raise ValueError(
|
||||
"reconnect_min_seconds must be a positive number no "
|
||||
f"greater than reconnect_max_seconds, got "
|
||||
f"{reconnect_min_seconds!r}"
|
||||
)
|
||||
if (
|
||||
not isinstance(reconnect_max_seconds, (int, float))
|
||||
or isinstance(reconnect_max_seconds, bool)
|
||||
or reconnect_max_seconds <= 0
|
||||
):
|
||||
raise ValueError(
|
||||
f"reconnect_max_seconds must be a positive number, got "
|
||||
f"{reconnect_max_seconds!r}"
|
||||
)
|
||||
self._hass = hass
|
||||
self._client = client
|
||||
self._min_backoff = float(reconnect_min_seconds)
|
||||
self._max_backoff = float(reconnect_max_seconds)
|
||||
self._backoff = float(reconnect_min_seconds)
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
@property
|
||||
def task(self) -> asyncio.Task | None:
|
||||
"""The background task, or ``None`` when never started/stopped."""
|
||||
return self._task
|
||||
|
||||
def start(self) -> None:
|
||||
"""Schedule the subscription task (idempotent while it runs)."""
|
||||
if self._task is not None and not self._task.done():
|
||||
return
|
||||
self._backoff = self._min_backoff
|
||||
self._task = asyncio.create_task(
|
||||
self._run(), name="nestquest-sse-subscription"
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel and await the subscription task.
|
||||
|
||||
Returns only after the task has actually finished, so unload
|
||||
can rely on: no further frame is fired after ``stop()``, and
|
||||
the underlying stream response has been closed. Safe to call
|
||||
when the task was never started or already stopped.
|
||||
"""
|
||||
task, self._task = self._task, None
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
# The task's own cancellation surfaces here — expected and
|
||||
# swallowed; a cancelling the CALLER would re-raise out of
|
||||
# the surrounding await on its own.
|
||||
pass
|
||||
|
||||
async def _run(self) -> None:
|
||||
"""Consume stream frames forever, reconnecting on failure."""
|
||||
while True:
|
||||
try:
|
||||
async for event_type, payload in self._client.stream_events():
|
||||
# A frame arrived on a healthy stream: the next
|
||||
# failure reconnects quickly again.
|
||||
self._backoff = self._min_backoff
|
||||
try:
|
||||
self._hass.bus.async_fire(event_type, dict(payload))
|
||||
except Exception as err:
|
||||
# Firing one frame must never tear down a
|
||||
# healthy connection: log and keep consuming.
|
||||
LOGGER.error(
|
||||
"NestQuest panel event %s could not be fired "
|
||||
"on the Home Assistant event bus: %s",
|
||||
event_type,
|
||||
err,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# Unload / shutdown: stop cleanly, never converted.
|
||||
raise
|
||||
except Exception as err:
|
||||
delay = self._backoff
|
||||
self._backoff = min(
|
||||
self._backoff * 2, self._max_backoff
|
||||
)
|
||||
LOGGER.warning(
|
||||
"NestQuest panel event stream failed (%s); "
|
||||
"reconnecting in %.1f s",
|
||||
err,
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.store` for the integration package.
|
||||
|
||||
The path resolver lives in
|
||||
:mod:`custom_components.nestquest.core.store` (extracted in task
|
||||
d0b691d8) and is Home-Assistant-free:
|
||||
:func:`custom_components.nestquest.core.store.async_get_db_path`
|
||||
takes the full database path. This module re-exports it and provides
|
||||
the original ``async_get_db_path(hass)`` signature so the integration
|
||||
resolves the path from ``hass.config.path`` exactly as before.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .core.const import SQLITE_DB_FILENAME
|
||||
from .core.store import async_get_db_path as _core_async_get_db_path
|
||||
from .core.store import _ensure_dir
|
||||
|
||||
__all__ = ["async_get_db_path", "_ensure_dir"]
|
||||
|
||||
|
||||
async def async_get_db_path(hass: HomeAssistant) -> Path:
|
||||
"""Return the absolute path to the NestQuest SQLite database file.
|
||||
|
||||
Resolves the database file from ``hass.config.path`` and delegates to
|
||||
:func:`custom_components.nestquest.core.store.async_get_db_path`,
|
||||
which ensures the parent directory exists off the event loop via
|
||||
:func:`asyncio.to_thread`.
|
||||
"""
|
||||
return await _core_async_get_db_path(hass.config.path(SQLITE_DB_FILENAME))
|
||||
@@ -18,19 +18,20 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "NestQuest Settings",
|
||||
"description": "Adjust how the NestQuest API client polls and reaches the NestQuest API service.",
|
||||
"description": "Adjust how NestQuest plans, rolls over days, and idles its panel.",
|
||||
"data": {
|
||||
"horizon_days": "Generation horizon (days)",
|
||||
"day_rollover_time": "Day rollover time (HH:MM)",
|
||||
"panel_idle_timeout": "Panel idle timeout (seconds)",
|
||||
"snapshot_staleness": "Last-good snapshot staleness (seconds)",
|
||||
"api_base_url": "NestQuest API base URL (http://host:port)",
|
||||
"panel_token": "Panel service token"
|
||||
"admin_user_ids": "Admin users (parents)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Invalid value.",
|
||||
"invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.",
|
||||
"invalid_staleness": "Last-good snapshot staleness must be at least the update interval."
|
||||
"invalid_time": "Time must use 24-hour HH:MM format.",
|
||||
"no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.",
|
||||
"invalid_admin": "Invalid admin selection."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""The nightly missed-quest sweep (Feature 11).
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
"""
|
||||
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 .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
|
||||
)
|
||||
|
||||
|
||||
async def run_missed_sweep(
|
||||
hass,
|
||||
database: NestQuestDatabase,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
) -> int:
|
||||
"""Fire one missed event per unswept past-due open instance.
|
||||
|
||||
``today`` optionally pins the HA-local date (threaded like the
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
LOGGER.info(
|
||||
"NestQuest missed sweep for %s fired %d event(s)", today_iso, fired
|
||||
)
|
||||
return fired
|
||||
@@ -18,19 +18,20 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "NestQuest Settings",
|
||||
"description": "Adjust how the NestQuest API client polls and reaches the NestQuest API service.",
|
||||
"description": "Adjust how NestQuest plans, rolls over days, and idles its panel.",
|
||||
"data": {
|
||||
"horizon_days": "Generation horizon (days)",
|
||||
"day_rollover_time": "Day rollover time (HH:MM)",
|
||||
"panel_idle_timeout": "Panel idle timeout (seconds)",
|
||||
"snapshot_staleness": "Last-good snapshot staleness (seconds)",
|
||||
"api_base_url": "NestQuest API base URL (http://host:port)",
|
||||
"panel_token": "Panel service token"
|
||||
"admin_user_ids": "Admin users (parents)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Invalid value.",
|
||||
"invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.",
|
||||
"invalid_staleness": "Last-good snapshot staleness must be at least the update interval."
|
||||
"invalid_time": "Time must use 24-hour HH:MM format.",
|
||||
"no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.",
|
||||
"invalid_admin": "Invalid admin selection."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
[Unit]
|
||||
Description=NestQuest OpenCode controller
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/mnt/ai-storage/projects/NestQuest
|
||||
ExecStartPre=/home/overseer/.local/share/nestquest-controller/scripts/nq-controller --check
|
||||
ExecStart=/home/overseer/.local/share/nestquest-controller/scripts/nq-controller
|
||||
Restart=on-failure
|
||||
RestartSec=60s
|
||||
RestartPreventExitStatus=77
|
||||
TimeoutStartSec=0
|
||||
KillMode=control-group
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
description: Run the NestQuest orchestrator now in this OpenCode terminal
|
||||
agent: terminal-orchestrator
|
||||
---
|
||||
|
||||
Run the NestQuest project queue now in this foreground OpenCode session.
|
||||
Read the current CTXD prompt and Maestro state, but use the FOREGROUND
|
||||
TERMINAL RUNTIME instead of the service runtime. Reconcile repository and
|
||||
pull-request state, then execute CONTROLLER_TICK repeatedly until a global
|
||||
terminal condition, explicit user stop, genuine global block, or host-forced
|
||||
yield. Do not start or wait for the systemd controller.
|
||||
@@ -1,56 +0,0 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RENDER = runpy.run_path(str(ROOT / "scripts/render-cycle-prompt"))["render"]
|
||||
TEMPLATE = (ROOT / "templates/ORCHESTRATOR-PROMPT.template.md").read_text()
|
||||
PROFILE = json.loads((ROOT / "templates/nestquest.profile.json").read_text())
|
||||
|
||||
|
||||
class CyclePromptTests(unittest.TestCase):
|
||||
def test_project_profile_renders_without_specific_source_names(self):
|
||||
other = PROFILE.copy()
|
||||
other.update({
|
||||
"PROJECT_NAME": "ExampleQuest",
|
||||
"PROJECT_CONTEXT_LABEL": "EXAMPLEQUEST COORDINATION",
|
||||
"WORK_TRACKER": "TaskGrid",
|
||||
"CONTEXT_STORE": "ContextHub",
|
||||
"INTEGRATION_BRANCH": "integration",
|
||||
"RELEASE_BRANCH": "production",
|
||||
"CONTROL_HOME": "/opt/examplequest/controller",
|
||||
"SERVICE_NAME": "examplequest-controller.service",
|
||||
"PROJECT_ROOT": "/srv/examplequest",
|
||||
"TERMINAL_LAUNCHER": "eq-orchestrate",
|
||||
"FEATURE_BRANCH_EXAMPLE": "feature/EQ-17",
|
||||
})
|
||||
output = RENDER(TEMPLATE, other)
|
||||
self.assertIn("ExampleQuest Persistent Autonomous", output)
|
||||
self.assertIn("TaskGrid", output)
|
||||
self.assertIn("ContextHub", output)
|
||||
self.assertIn("feature/EQ-17", output)
|
||||
self.assertNotIn("NestQuest", output)
|
||||
self.assertNotIn("nestquest", output)
|
||||
self.assertNotIn("Maestro", output)
|
||||
self.assertNotIn("CTXD", output)
|
||||
self.assertNotIn("{{", output)
|
||||
|
||||
def test_missing_value_fails_before_publication(self):
|
||||
other = PROFILE.copy()
|
||||
del other["PROJECT_NAME"]
|
||||
with self.assertRaisesRegex(ValueError, "PROJECT_NAME"):
|
||||
RENDER(TEMPLATE, other)
|
||||
|
||||
def test_template_contains_only_terminal_runtime(self):
|
||||
output = RENDER(TEMPLATE, PROFILE)
|
||||
self.assertIn("# 27. FOREGROUND TERMINAL RUNTIME", output)
|
||||
self.assertIn("nq-orchestrate", output)
|
||||
self.assertNotIn("# 27. HOST CONTROLLER RUNTIME", output)
|
||||
self.assertNotIn("The persistent user service runs", output)
|
||||
self.assertNotIn("---\n\n---\n\n# 27", output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,88 +0,0 @@
|
||||
from pathlib import Path
|
||||
import contextlib
|
||||
import io
|
||||
import runpy
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
APPROVE = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-approve-merge"))
|
||||
HEAD = "a" * 40
|
||||
|
||||
|
||||
class MergeGateTests(unittest.TestCase):
|
||||
def test_main_rejects_moved_head_unmergeable_and_pending_checks(self):
|
||||
root = Path("/tmp/opencode")
|
||||
root.mkdir(exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(dir=root) as directory:
|
||||
review_file = Path(directory) / "review.txt"
|
||||
review_file.write_text(f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\n")
|
||||
for problem in ("moved", "unmergeable", "pending", "none"):
|
||||
with self.subTest(problem=problem):
|
||||
merged = False
|
||||
calls = []
|
||||
def fake_request(path, token, payload=None):
|
||||
nonlocal merged
|
||||
calls.append((path, payload))
|
||||
if path.endswith("/reviews"):
|
||||
return [{"id": 1, "state": "APPROVED", "commit_id": HEAD,
|
||||
"user": {"login": "review_agent"}}]
|
||||
if path.endswith("/status"):
|
||||
return {"total_count": 1, "state": "pending" if problem == "pending" else "success"}
|
||||
if path.endswith("/merge"):
|
||||
merged = True
|
||||
return {}
|
||||
if path.endswith("/pulls/149"):
|
||||
return {"number": 149, "base": {"ref": "dev"},
|
||||
"head": {"sha": "b" * 40 if problem == "moved" else HEAD},
|
||||
"state": "open", "merged": merged,
|
||||
"mergeable": problem != "unmergeable"}
|
||||
raise AssertionError(path)
|
||||
globals_ = APPROVE["main"].__globals__
|
||||
with patch.dict(globals_, {"request": fake_request,
|
||||
"token_from_file": lambda: "test"}), \
|
||||
patch.object(sys, "argv", ["nq-approve-merge", "merge", "149", HEAD,
|
||||
str(review_file)]), \
|
||||
contextlib.redirect_stdout(io.StringIO()):
|
||||
if problem == "none":
|
||||
self.assertEqual(APPROVE["main"](), 0)
|
||||
else:
|
||||
with self.assertRaises(RuntimeError):
|
||||
APPROVE["main"]()
|
||||
self.assertEqual(merged, problem == "none")
|
||||
if problem == "none":
|
||||
merge_calls = [payload for path, payload in calls if path.endswith("/merge")]
|
||||
self.assertEqual(merge_calls,
|
||||
[{"do": "squash", "head_commit_id": HEAD}])
|
||||
|
||||
def test_review_evidence_must_match_exact_head_and_approval(self):
|
||||
root = Path("/tmp/opencode")
|
||||
root.mkdir(exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(dir=root) as directory:
|
||||
review = Path(directory) / "review.txt"
|
||||
review.write_text(f"Findings: none\nREVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\n")
|
||||
APPROVE["validate_review"](review, HEAD)
|
||||
with self.assertRaisesRegex(RuntimeError, "exact head"):
|
||||
APPROVE["validate_review"](review, "b" * 40)
|
||||
review.write_text(f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: CHANGES_REQUIRED\n")
|
||||
with self.assertRaisesRegex(RuntimeError, "exact head"):
|
||||
APPROVE["validate_review"](review, HEAD)
|
||||
|
||||
def test_requires_distinct_gitea_reviewer_on_exact_commit(self):
|
||||
records = [{"state": "APPROVED", "commit_id": HEAD, "user": {"login": "review_agent"}}]
|
||||
with patch.dict(APPROVE["has_platform_review"].__globals__, {"request": lambda *_: records}):
|
||||
self.assertTrue(APPROVE["has_platform_review"](149, HEAD, "unused"))
|
||||
self.assertFalse(APPROVE["has_platform_review"](149, "b" * 40, "unused"))
|
||||
records[0]["user"]["login"] = "orchestrator-agent"
|
||||
self.assertFalse(APPROVE["has_platform_review"](149, HEAD, "unused"))
|
||||
records[:] = [
|
||||
{"id": 1, "state": "APPROVED", "commit_id": HEAD, "user": {"login": "review_agent"}},
|
||||
{"id": 2, "state": "REQUEST_CHANGES", "commit_id": HEAD, "user": {"login": "review_agent"}},
|
||||
]
|
||||
self.assertFalse(APPROVE["has_platform_review"](149, HEAD, "unused"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,108 +0,0 @@
|
||||
import json
|
||||
import io
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
CONTROL = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-controller"))
|
||||
|
||||
|
||||
class ControllerProtocolTests(unittest.TestCase):
|
||||
def test_ctxd_preflight_reads_configured_endpoint_and_checks_identity(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
config = root / ".config/opencode/opencode.json"
|
||||
config.parent.mkdir(parents=True)
|
||||
config.write_text(json.dumps({"mcp": {"ctxd": {
|
||||
"url": "http://localhost:9091/mcp",
|
||||
"headers": {"Authorization": "Bearer test"},
|
||||
}}}))
|
||||
record = {"project_id": CONTROL["CTXD_PROJECT"],
|
||||
"file_path": CONTROL["CTXD_FILE"],
|
||||
"content": "# CONTROLLER_TICK", "version": 6}
|
||||
with patch.object(Path, "home", return_value=root), \
|
||||
patch.dict(CONTROL["check_ctxd"].__globals__["urllib"].request.__dict__,
|
||||
{"urlopen": lambda request, timeout: io.BytesIO(json.dumps(record).encode())}):
|
||||
self.assertEqual(CONTROL["check_ctxd"](), 6)
|
||||
record["project_id"] = "wrong"
|
||||
with self.assertRaisesRegex(RuntimeError, "wrong NestQuest prompt"):
|
||||
CONTROL["check_ctxd"]()
|
||||
record["project_id"] = CONTROL["CTXD_PROJECT"]
|
||||
config.write_text(json.dumps({"mcp": {"ctxd": {
|
||||
"url": "http://localhost:9091/mcp/",
|
||||
"headers": {"Authorization": "Bearer test"},
|
||||
}}}))
|
||||
self.assertEqual(CONTROL["check_ctxd"](), 6)
|
||||
config.write_text(json.dumps({"mcp": {"ctxd": {
|
||||
"url": "http://localhost:9091/invalid",
|
||||
"headers": {"Authorization": "Bearer test"},
|
||||
}}}))
|
||||
with self.assertRaisesRegex(RuntimeError, "must end in /mcp"):
|
||||
CONTROL["check_ctxd"]()
|
||||
|
||||
def test_installed_opencode_text_event(self):
|
||||
event = {"type": "text", "sessionID": "ses_test", "part": {"type": "text", "text": "Next task queued.\nORCHESTRATOR_STATE: CONTINUE\n"}}
|
||||
self.assertEqual(CONTROL["parse_event"](json.dumps(event)), (event["part"]["text"], None))
|
||||
self.assertEqual(CONTROL["STATE_RE"].findall(event["part"]["text"]), ["CONTINUE"])
|
||||
|
||||
def test_error_event_does_not_publish_provider_message(self):
|
||||
event = {"type": "error", "error": {"name": "APIError", "data": {"message": "secret provider response"}}}
|
||||
self.assertEqual(CONTROL["parse_event"](json.dumps(event)), (None, "APIError"))
|
||||
self.assertEqual(CONTROL["parse_event"]("null"), (None, None))
|
||||
self.assertEqual(CONTROL["parse_event"]("[]"), (None, None))
|
||||
|
||||
def test_tick_uses_final_text(self):
|
||||
event = json.dumps({"type": "text", "part": {"type": "text", "text": "ORCHESTRATOR_STATE: QUIESCENT\n"}})
|
||||
command = [sys.executable, "-c", f"import json,sys; sys.stdin.read(); print({event!r})"]
|
||||
status, answer, error = CONTROL["run_tick"](command, {}, "tick", 10)
|
||||
self.assertEqual((status, error), (0, None))
|
||||
self.assertEqual(CONTROL["STATE_RE"].findall(answer), ["QUIESCENT"])
|
||||
|
||||
def test_timeout_kills_child_group_and_marks_tick_failed(self):
|
||||
command = [sys.executable, "-c", "import sys,time; sys.stdin.read(); time.sleep(10)"]
|
||||
status, answer, error = CONTROL["run_tick"](command, {}, "tick", 1)
|
||||
self.assertEqual(status, 124)
|
||||
self.assertEqual(answer, "")
|
||||
self.assertIn("timeout", error)
|
||||
|
||||
def test_interactive_session_detects_dir_and_resumed_session(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
project = root / "project"
|
||||
project.mkdir()
|
||||
proc = root / "proc"
|
||||
entry = proc / "123"
|
||||
entry.mkdir(parents=True)
|
||||
(entry / "cwd").symlink_to(root)
|
||||
database = root / "opencode.db"
|
||||
with sqlite3.connect(database) as db:
|
||||
db.execute("CREATE TABLE session (id TEXT, directory TEXT)")
|
||||
db.execute("INSERT INTO session VALUES (?, ?)", ("ses_test", str(project)))
|
||||
(entry / "cmdline").write_bytes(b"opencode\0--dir\0" + str(project).encode() + b"\0")
|
||||
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
|
||||
(entry / "cmdline").write_bytes(b"opencode\0web\0--dir\0" + str(project).encode() + b"\0")
|
||||
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
|
||||
(entry / "cmdline").write_bytes(b"opencode\0serve\0")
|
||||
(entry / "cwd").unlink()
|
||||
(entry / "cwd").symlink_to(project)
|
||||
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
|
||||
(entry / "cwd").unlink()
|
||||
(entry / "cwd").symlink_to(root)
|
||||
(entry / "cmdline").write_bytes(b"opencode\0-s\0ses_test\0")
|
||||
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
|
||||
(entry / "cmdline").write_bytes(b"opencode\0--dir\0" + str(root).encode() + b"\0")
|
||||
self.assertFalse(CONTROL["interactive_nestquest_session"](project, proc, database))
|
||||
with sqlite3.connect(database) as db:
|
||||
db.execute("DROP TABLE session")
|
||||
(entry / "cmdline").write_bytes(b"opencode\0-s\0ses_test\0")
|
||||
with self.assertRaisesRegex(RuntimeError, "lookup failed"):
|
||||
CONTROL["interactive_nestquest_session"](project, proc, database)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,79 +0,0 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "scripts/nq-orchestrate"
|
||||
|
||||
|
||||
class ForegroundLauncherTests(unittest.TestCase):
|
||||
def test_launches_terminal_agent_with_rendered_and_user_prompt(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
control = root / "control"
|
||||
scripts = control / "scripts"
|
||||
agents = control / ".opencode/agents"
|
||||
templates = control / "templates"
|
||||
project = root / "project"
|
||||
for path in (scripts, agents, templates, project):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
(templates / "nestquest.profile.json").write_text("{}")
|
||||
(agents / "terminal-orchestrator.md").write_text("agent")
|
||||
renderer = scripts / "render-cycle-prompt"
|
||||
renderer.write_text("#!/bin/sh\nprintf 'BASE TERMINAL PROMPT'")
|
||||
controller = scripts / "nq-controller"
|
||||
controller_args = root / "controller-args"
|
||||
open_marker = root / "open-session"
|
||||
controller.write_text(
|
||||
f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {controller_args}\n"
|
||||
f"if test -f {open_marker}; then printf 'interactive NestQuest session open\\n'; "
|
||||
"else printf 'no interactive NestQuest session\\n'; fi\n"
|
||||
)
|
||||
opencode = root / "opencode"
|
||||
output = root / "args"
|
||||
opencode.write_text(f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {output}\n")
|
||||
systemctl = root / "systemctl"
|
||||
stopped = root / "stopped"
|
||||
systemctl.write_text(f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {stopped}\n")
|
||||
for executable in (renderer, controller, opencode, systemctl):
|
||||
executable.chmod(0o700)
|
||||
mission = root / "mission.md"
|
||||
mission.write_text("Complete feature EQ-17.")
|
||||
env = os.environ.copy()
|
||||
env.update({
|
||||
"NQ_CONTROL_HOME": str(control),
|
||||
"NQ_PROJECT_ROOT": str(project),
|
||||
"OPENCODE_BIN": str(opencode),
|
||||
"SYSTEMCTL_BIN": str(systemctl),
|
||||
})
|
||||
printed = subprocess.run([str(SCRIPT), "--print-prompt"], env=env,
|
||||
text=True, capture_output=True)
|
||||
self.assertEqual(printed.returncode, 0, printed.stderr)
|
||||
self.assertEqual(printed.stdout, "BASE TERMINAL PROMPT\n")
|
||||
self.assertFalse(stopped.exists())
|
||||
result = subprocess.run([str(SCRIPT), str(mission)], env=env, text=True,
|
||||
capture_output=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
args = output.read_text()
|
||||
self.assertIn("--agent\nterminal-orchestrator", args)
|
||||
self.assertIn("BASE TERMINAL PROMPT", args)
|
||||
self.assertIn("# USER MISSION", args)
|
||||
self.assertIn("Complete feature EQ-17.", args)
|
||||
self.assertEqual(stopped.read_text().splitlines(),
|
||||
["--user", "stop", "nestquest-controller.service"])
|
||||
self.assertEqual(controller_args.read_text().splitlines(),
|
||||
["--check-interactive", "--project", str(project)])
|
||||
|
||||
output.unlink()
|
||||
open_marker.touch()
|
||||
refused = subprocess.run([str(SCRIPT)], env=env, text=True,
|
||||
capture_output=True)
|
||||
self.assertEqual(refused.returncode, 75)
|
||||
self.assertIn("Another interactive NestQuest", refused.stderr)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,17 +0,0 @@
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import unittest
|
||||
|
||||
REVIEW = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-review"))
|
||||
HEAD = "a" * 40
|
||||
|
||||
class ReviewLauncherTests(unittest.TestCase):
|
||||
def test_exact_head_verdict(self):
|
||||
self.assertEqual(REVIEW["verdict"](f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\n", HEAD), "APPROVED")
|
||||
with self.assertRaisesRegex(RuntimeError, "one verdict"):
|
||||
REVIEW["verdict"](f"REVIEWED_HEAD: {'b' * 40}\nREVIEW_VERDICT: APPROVED\n", HEAD)
|
||||
with self.assertRaisesRegex(RuntimeError, "one verdict"):
|
||||
REVIEW["verdict"](f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\nREVIEW_VERDICT: CHANGES_REQUIRED\n", HEAD)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 4 ]]; then
|
||||
echo "usage: nq-agent developer|approver WORKTREE TITLE PROMPT_FILE" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
role=$1
|
||||
worktree=$2
|
||||
title=$3
|
||||
prompt_file=$4
|
||||
case "$role" in developer|approver) ;; *) echo "invalid role: $role" >&2; exit 64 ;; esac
|
||||
[[ -d "$worktree" && -f "$prompt_file" ]] || { echo "missing worktree or prompt" >&2; exit 66; }
|
||||
|
||||
repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
|
||||
export OPENCODE_CONFIG_DIR="$repo_dir/.opencode"
|
||||
opencode_bin=${OPENCODE_BIN:-/home/overseer/.opencode/bin/opencode}
|
||||
|
||||
exec "$opencode_bin" run --pure --dir "$worktree" --agent "$role" --title "$title" --format json < "$prompt_file"
|
||||
@@ -1,131 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Inspect or merge a reviewed NestQuest feature PR into dev only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
API = "https://code.cubecraftlabs.com/api/v1/repos/Maniacs_Mansion/NestQuest"
|
||||
CREDENTIALS = Path("/home/overseer/.llms/credentials.env")
|
||||
FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
def token_from_file() -> str:
|
||||
for line in CREDENTIALS.read_text().splitlines():
|
||||
if line.startswith("GITEA_APPROVER_AGENT_TOKEN="):
|
||||
return line.split("=", 1)[1].strip().strip("\"'")
|
||||
raise RuntimeError("GITEA_APPROVER_AGENT_TOKEN is unavailable")
|
||||
|
||||
|
||||
def request(path: str, token: str, payload: dict | None = None) -> dict | list:
|
||||
body = None if payload is None else json.dumps(payload).encode()
|
||||
headers = {"Authorization": "token " + token, "Accept": "application/json"}
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
call = urllib.request.Request(API + path, data=body, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(call, timeout=20) as response:
|
||||
raw = response.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
except urllib.error.HTTPError as error:
|
||||
raise RuntimeError(f"Gitea returned HTTP {error.code}") from None
|
||||
|
||||
|
||||
def inspect(pr_number: int, token: str) -> dict:
|
||||
pr = request(f"/pulls/{pr_number}", token)
|
||||
head = (pr.get("head") or {}).get("sha")
|
||||
base = (pr.get("base") or {}).get("ref")
|
||||
if not isinstance(head, str) or not FULL_SHA.fullmatch(head):
|
||||
raise RuntimeError("Gitea returned an invalid PR head")
|
||||
status = request(f"/commits/{head}/status", token)
|
||||
return {
|
||||
"number": pr.get("number"),
|
||||
"base": base,
|
||||
"head": head,
|
||||
"state": pr.get("state"),
|
||||
"merged": pr.get("merged"),
|
||||
"mergeable": pr.get("mergeable"),
|
||||
"check_count": status.get("total_count") or 0,
|
||||
"check_state": status.get("state"),
|
||||
}
|
||||
|
||||
|
||||
def validate_review(path: Path, head: str) -> None:
|
||||
if not path.is_file() or not path.resolve().is_relative_to(Path("/tmp/opencode").resolve()):
|
||||
raise RuntimeError("review evidence must be a file under /tmp/opencode")
|
||||
text = path.read_text(errors="replace")
|
||||
reviewed = re.findall(r"(?m)^REVIEWED_HEAD:\s*([0-9a-f]{40})\s*$", text)
|
||||
verdicts = re.findall(r"(?m)^REVIEW_VERDICT:\s*(APPROVED|CHANGES_REQUIRED)\s*$", text)
|
||||
if len(reviewed) != 1 or len(verdicts) != 1 or reviewed[0] != head or verdicts[0] != "APPROVED":
|
||||
raise RuntimeError("independent review does not approve this exact head")
|
||||
|
||||
|
||||
def has_platform_review(pr_number: int, head: str, token: str) -> bool:
|
||||
reviews = request(f"/pulls/{pr_number}/reviews", token)
|
||||
if not isinstance(reviews, list):
|
||||
raise RuntimeError("Gitea returned invalid review records")
|
||||
by_reviewer = [
|
||||
review for review in reviews
|
||||
if isinstance(review, dict)
|
||||
and (review.get("user") or {}).get("login") == "review_agent"
|
||||
and review.get("commit_id") == head
|
||||
]
|
||||
if not by_reviewer:
|
||||
return False
|
||||
latest = max(by_reviewer, key=lambda review: int(review.get("id") or 0))
|
||||
return latest.get("state") == "APPROVED"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("operation", choices=("inspect", "merge"))
|
||||
parser.add_argument("pr", type=int)
|
||||
parser.add_argument("reviewed_head", nargs="?")
|
||||
parser.add_argument("review_file", nargs="?", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.pr < 1:
|
||||
parser.error("PR number must be positive")
|
||||
if args.operation == "merge" and (not args.reviewed_head or not args.review_file):
|
||||
parser.error("merge requires reviewed_head and review_file")
|
||||
token = token_from_file()
|
||||
before = inspect(args.pr, token)
|
||||
if args.operation == "inspect":
|
||||
print(json.dumps(before, sort_keys=True))
|
||||
return 0
|
||||
if not FULL_SHA.fullmatch(args.reviewed_head):
|
||||
parser.error("reviewed head must be a full SHA")
|
||||
validate_review(args.review_file, args.reviewed_head)
|
||||
if not has_platform_review(args.pr, args.reviewed_head, token):
|
||||
raise RuntimeError("Gitea lacks an exact-head approval by review_agent")
|
||||
if before["base"] != "dev" or before["head"] != args.reviewed_head:
|
||||
raise RuntimeError("PR target or current head differs from the approved review")
|
||||
if before["state"] != "open" or before["merged"] or before["mergeable"] is not True:
|
||||
raise RuntimeError("PR is closed, merged, or not mergeable")
|
||||
if before["check_count"] and before["check_state"] != "success":
|
||||
raise RuntimeError("a configured commit check has not passed")
|
||||
# Gitea rechecks branch protection at the merge endpoint. The caller must
|
||||
# independently verify Maestro and task-level evidence before invoking us.
|
||||
# Gitea checks head_commit_id atomically while processing the merge.
|
||||
request(f"/pulls/{args.pr}/merge", token,
|
||||
{"do": "squash", "head_commit_id": args.reviewed_head})
|
||||
after = inspect(args.pr, token)
|
||||
if after["merged"] is not True or after["head"] != args.reviewed_head:
|
||||
raise RuntimeError("merge result is missing or differs from the reviewed head")
|
||||
print(json.dumps(after, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (OSError, ValueError, RuntimeError) as error:
|
||||
print(f"approval gate failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -1,277 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Durable, single-owner controller for one NestQuest OpenCode tick at a time."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import selectors
|
||||
import signal
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
|
||||
DEFAULT_PROJECT = Path("/mnt/ai-storage/projects/NestQuest")
|
||||
DEFAULT_STATE = Path.home() / ".local/state/nestquest-controller"
|
||||
STATE_RE = re.compile(r"(?m)^ORCHESTRATOR_STATE: (CONTINUE|QUIESCENT|BLOCKED)\s*$")
|
||||
MAX_TEXT = 64_000
|
||||
MAX_LINE = 4_000_000
|
||||
CTXD_PROJECT = "nestquest-house-chaos-coordination"
|
||||
CTXD_FILE = "OPENCODE-ORCHESTRATOR-PROMPT.MD"
|
||||
|
||||
|
||||
def check_ctxd() -> int:
|
||||
"""Use OpenCode's configured endpoint, never an unrelated host DB."""
|
||||
config = json.loads((Path.home() / ".config/opencode/opencode.json").read_text())
|
||||
mcp = config["mcp"]["ctxd"]
|
||||
configured_url = mcp["url"].rstrip("/")
|
||||
if not configured_url.endswith("/mcp"):
|
||||
raise RuntimeError("CTXD MCP URL must end in /mcp")
|
||||
endpoint = configured_url.removesuffix("/mcp")
|
||||
url = f"{endpoint}/projects/{CTXD_PROJECT}/files/{CTXD_FILE}"
|
||||
with urllib.request.urlopen(
|
||||
urllib.request.Request(url, headers=mcp["headers"]), timeout=8
|
||||
) as response:
|
||||
record = json.load(response)
|
||||
if (
|
||||
record.get("project_id") != CTXD_PROJECT
|
||||
or record.get("file_path") != CTXD_FILE
|
||||
or "CONTROLLER_TICK" not in record.get("content", "")
|
||||
):
|
||||
raise RuntimeError("CTXD returned the wrong NestQuest prompt")
|
||||
return int(record["version"])
|
||||
|
||||
|
||||
def interactive_nestquest_session(
|
||||
project: Path,
|
||||
proc_root: Path = Path("/proc"),
|
||||
database: Path = Path.home() / ".local/share/opencode/opencode.db",
|
||||
) -> bool:
|
||||
"""Avoid racing a user-owned OpenCode TUI for the same repository."""
|
||||
try:
|
||||
db = sqlite3.connect(f"file:{database}?mode=ro", uri=True)
|
||||
except sqlite3.Error as error:
|
||||
raise RuntimeError("OpenCode session database is unavailable") from error
|
||||
try:
|
||||
for entry in proc_root.iterdir():
|
||||
if not entry.name.isdecimal() or int(entry.name) == os.getpid():
|
||||
continue
|
||||
try:
|
||||
args = (entry / "cmdline").read_bytes().split(b"\0")
|
||||
args = [a.decode(errors="replace") for a in args if a]
|
||||
if not args or Path(args[0]).name != "opencode":
|
||||
continue
|
||||
if len(args) > 1 and args[1] in ("run", "debug"):
|
||||
continue
|
||||
session = None
|
||||
for flag in ("-s", "--session"):
|
||||
if flag in args and args.index(flag) + 1 < len(args):
|
||||
session = args[args.index(flag) + 1]
|
||||
break
|
||||
if session:
|
||||
row = db.execute("SELECT directory FROM session WHERE id=?", (session,)).fetchone()
|
||||
if row and Path(row[0]).resolve() == project.resolve():
|
||||
return True
|
||||
elif "--dir" in args and args.index("--dir") + 1 < len(args):
|
||||
if Path(args[args.index("--dir") + 1]).resolve() == project.resolve():
|
||||
return True
|
||||
elif (entry / "cwd").resolve() == project.resolve():
|
||||
return True
|
||||
except sqlite3.Error as error:
|
||||
raise RuntimeError("OpenCode session lookup failed") from error
|
||||
except TypeError as error:
|
||||
raise RuntimeError("OpenCode session directory is invalid") from error
|
||||
except (OSError, PermissionError):
|
||||
continue
|
||||
finally:
|
||||
db.close()
|
||||
return False
|
||||
|
||||
|
||||
def parse_event(raw: str) -> tuple[str | None, str | None]:
|
||||
try:
|
||||
event = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None, None
|
||||
if not isinstance(event, dict):
|
||||
return None, None
|
||||
kind = event.get("type")
|
||||
part = event.get("part") or {}
|
||||
if kind == "text" and isinstance(part, dict):
|
||||
return part.get("text"), None
|
||||
if kind == "error":
|
||||
error = event.get("error") or {}
|
||||
known = {"ProviderAuthError", "UnknownError", "MessageOutputLengthError", "MessageAbortedError", "StructuredOutputError", "ContextOverflowError", "APIError"}
|
||||
name = error.get("name") if isinstance(error, dict) else None
|
||||
return None, name if name in known else "OpenCode error"
|
||||
return None, None
|
||||
|
||||
|
||||
def run_tick(command: list[str], env: dict[str, str], prompt: str, timeout: int) -> tuple[int, str, str | None]:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
)
|
||||
assert process.stdin is not None and process.stdout is not None
|
||||
try:
|
||||
process.stdin.write(prompt.encode())
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
finally:
|
||||
process.stdin.close()
|
||||
selector = selectors.DefaultSelector()
|
||||
selector.register(process.stdout, selectors.EVENT_READ)
|
||||
deadline = time.monotonic() + timeout
|
||||
buffer = b""
|
||||
texts: list[str] = []
|
||||
last_error: str | None = None
|
||||
diagnostic_lines = 0
|
||||
try:
|
||||
while selector.get_map():
|
||||
if time.monotonic() >= deadline:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
process.wait()
|
||||
return 124, "".join(texts)[-MAX_TEXT:], "controller tick exceeded documented timeout"
|
||||
for key, _ in selector.select(timeout=1):
|
||||
chunk = os.read(key.fd, 65536)
|
||||
if not chunk:
|
||||
selector.unregister(key.fileobj)
|
||||
continue
|
||||
buffer += chunk
|
||||
while b"\n" in buffer:
|
||||
line, buffer = buffer.split(b"\n", 1)
|
||||
text, error = parse_event(line.decode(errors="replace"))
|
||||
if not line.lstrip().startswith(b"{"):
|
||||
diagnostic_lines += 1
|
||||
if text:
|
||||
texts.append(text)
|
||||
if sum(map(len, texts)) > MAX_TEXT:
|
||||
texts = ["".join(texts)[-MAX_TEXT:]]
|
||||
if error:
|
||||
last_error = error
|
||||
if len(buffer) > MAX_LINE:
|
||||
buffer = b"" # A tool-output event is not a controller verdict.
|
||||
if buffer:
|
||||
text, error = parse_event(buffer.decode(errors="replace"))
|
||||
if text:
|
||||
texts.append(text)
|
||||
if error:
|
||||
last_error = error
|
||||
code = process.wait()
|
||||
if code and last_error is None and diagnostic_lines:
|
||||
last_error = f"OpenCode emitted {diagnostic_lines} non-JSON diagnostic lines"
|
||||
return code, "".join(texts)[-MAX_TEXT:], last_error
|
||||
finally:
|
||||
selector.close()
|
||||
process.stdout.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--project", type=Path, default=DEFAULT_PROJECT)
|
||||
parser.add_argument("--state-dir", type=Path, default=DEFAULT_STATE)
|
||||
parser.add_argument("--once", action="store_true", help="run only one model turn")
|
||||
parser.add_argument("--check", action="store_true", help="validate paths without calling a model")
|
||||
parser.add_argument("--check-interactive", action="store_true", help="report an open NestQuest TUI without calling a model")
|
||||
parser.add_argument("--tick-timeout", type=int, default=6 * 3600)
|
||||
args = parser.parse_args()
|
||||
|
||||
project = args.project.resolve()
|
||||
control_home = Path(__file__).resolve().parent.parent
|
||||
config_dir = control_home / ".opencode"
|
||||
binary = Path(os.environ.get("OPENCODE_BIN", "/home/overseer/.opencode/bin/opencode"))
|
||||
if not (project.is_dir() and config_dir.is_dir() and binary.is_file()):
|
||||
parser.error("project, agent configuration, or OpenCode binary is missing")
|
||||
if args.check:
|
||||
try:
|
||||
ctxd_version = check_ctxd()
|
||||
except (OSError, KeyError, ValueError, RuntimeError) as error:
|
||||
print(f"CTXD preflight failed: {type(error).__name__}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"ready: project={project} configuration={config_dir} binary={binary} ctxd_prompt_version={ctxd_version}")
|
||||
return 0
|
||||
if args.check_interactive:
|
||||
try:
|
||||
active = interactive_nestquest_session(project)
|
||||
except RuntimeError:
|
||||
print("OpenCode session state unavailable", file=sys.stderr)
|
||||
return 77
|
||||
print("interactive NestQuest session open" if active else "no interactive NestQuest session")
|
||||
return 0
|
||||
|
||||
args.state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
os.chmod(args.state_dir, 0o700)
|
||||
with (args.state_dir / "controller.lock").open("w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
print("another NestQuest controller is running", file=sys.stderr)
|
||||
return 75
|
||||
command = [str(binary), "run", "--pure", "--dir", str(project), "--agent", "orchestrator", "--format", "json", "--title", "NestQuest controller tick"]
|
||||
env = os.environ.copy()
|
||||
env["OPENCODE_CONFIG_DIR"] = str(config_dir)
|
||||
env["NESTQUEST_CONTROL_HOME"] = str(control_home)
|
||||
failures = 0
|
||||
while True:
|
||||
try:
|
||||
interactive = interactive_nestquest_session(project)
|
||||
except RuntimeError:
|
||||
print("OpenCode session state unavailable; waiting", file=sys.stderr, flush=True)
|
||||
if args.once:
|
||||
return 77
|
||||
time.sleep(60)
|
||||
continue
|
||||
if interactive:
|
||||
print("interactive NestQuest session still open; waiting", flush=True)
|
||||
if args.once:
|
||||
return 75
|
||||
time.sleep(60)
|
||||
continue
|
||||
prompt = (
|
||||
"Run one NestQuest controller tick. Read CTXD and Maestro afresh, "
|
||||
"reconcile Git and PR state, then execute the next eligible action. "
|
||||
"Use NESTQUEST_CONTROL_HOME for role launch scripts. "
|
||||
"Return a final ORCHESTRATOR_STATE line as your agent instructions require."
|
||||
)
|
||||
status, answer, error = run_tick(command, env, prompt, args.tick_timeout)
|
||||
states = STATE_RE.findall(answer)
|
||||
state = states[-1] if status == 0 and error is None and states else "ERROR"
|
||||
if state == "CONTINUE":
|
||||
failures = 0
|
||||
elif state == "QUIESCENT":
|
||||
failures = 0
|
||||
elif state == "BLOCKED":
|
||||
failures = 0
|
||||
else:
|
||||
failures += 1
|
||||
summary = {"time": int(time.time()), "state": state, "exit_code": status, "error": error, "failure_count": failures, "answer_tail": answer[-4000:]}
|
||||
target = args.state_dir / "last-run.json"
|
||||
temporary = args.state_dir / "last-run.json.tmp"
|
||||
temporary.write_text(json.dumps(summary, indent=2) + "\n")
|
||||
os.chmod(temporary, 0o600)
|
||||
temporary.replace(target)
|
||||
print(f"tick state={state} exit={status} failures={failures}", flush=True)
|
||||
if args.once:
|
||||
return 0 if state in ("CONTINUE", "QUIESCENT") else 77
|
||||
if state == "BLOCKED" or failures >= 3:
|
||||
print("controller blocked; inspect last-run.json", file=sys.stderr)
|
||||
return 77
|
||||
time.sleep(300 if state == "QUIESCENT" else min(120, 10 * 2**failures))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Launch the NestQuest orchestrator immediately in the foreground terminal.
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -gt 1 ]]; then
|
||||
echo "usage: nq-orchestrate [MISSION_PROMPT_FILE|-|--print-prompt]" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
control_home=${NQ_CONTROL_HOME:-/home/overseer/.local/share/nestquest-controller}
|
||||
project=${NQ_PROJECT_ROOT:-/mnt/ai-storage/projects/NestQuest}
|
||||
opencode_bin=${OPENCODE_BIN:-/home/overseer/.opencode/bin/opencode}
|
||||
systemctl_bin=${SYSTEMCTL_BIN:-systemctl}
|
||||
renderer="$control_home/scripts/render-cycle-prompt"
|
||||
profile="$control_home/templates/nestquest.profile.json"
|
||||
|
||||
for required in "$renderer" "$profile"; do
|
||||
[[ -e "$required" ]] || { echo "missing required path: $required" >&2; exit 66; }
|
||||
done
|
||||
|
||||
prompt=$("$renderer" "$profile")
|
||||
if [[ ${1:-} == "--print-prompt" ]]; then
|
||||
printf '%s\n' "$prompt"
|
||||
exit 0
|
||||
fi
|
||||
for required in "$project" "$opencode_bin" "$control_home/.opencode/agents/terminal-orchestrator.md"; do
|
||||
[[ -e "$required" ]] || { echo "missing required path: $required" >&2; exit 66; }
|
||||
done
|
||||
if [[ $# -eq 1 ]]; then
|
||||
if [[ "$1" == "-" ]]; then
|
||||
mission=$(cat)
|
||||
else
|
||||
[[ -f "$1" ]] || { echo "mission prompt not found: $1" >&2; exit 66; }
|
||||
mission=$(cat "$1")
|
||||
fi
|
||||
[[ -n "${mission//[[:space:]]/}" ]] || { echo "mission prompt is empty" >&2; exit 65; }
|
||||
prompt+=$'\n\n---\n\n# USER MISSION\n\n'
|
||||
prompt+="$mission"
|
||||
fi
|
||||
|
||||
# Foreground mode owns orchestration. A dormant service must never race it.
|
||||
"$systemctl_bin" --user stop nestquest-controller.service
|
||||
|
||||
session_status=$("$control_home/scripts/nq-controller" --check-interactive --project "$project")
|
||||
if [[ "$session_status" == "interactive NestQuest session open" ]]; then
|
||||
echo "Another interactive NestQuest OpenCode session is open." >&2
|
||||
echo "Close it, then run nq-orchestrate again." >&2
|
||||
exit 75
|
||||
fi
|
||||
|
||||
export OPENCODE_CONFIG_DIR="$control_home/.opencode"
|
||||
exec "$opencode_bin" "$project" --pure --agent terminal-orchestrator --prompt "$prompt"
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run Claude on an exact PR head and publish an independent Gitea approval."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
API = "https://code.cubecraftlabs.com/api/v1"
|
||||
REPO = "/repos/Maniacs_Mansion/NestQuest"
|
||||
CREDENTIALS = Path("/home/overseer/.llms/credentials.env")
|
||||
CLAUDE = "/home/overseer/.local/bin/claude"
|
||||
SHA = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
def git(worktree: Path, *args: str) -> str:
|
||||
return subprocess.check_output(["git", "-C", str(worktree), *args], text=True).strip()
|
||||
|
||||
def token() -> str:
|
||||
for line in CREDENTIALS.read_text().splitlines():
|
||||
if line.startswith("GITEA_REVIEW_AGENT_TOKEN="):
|
||||
return line.split("=", 1)[1].strip().strip("\"'")
|
||||
raise RuntimeError("review_agent credential is unavailable")
|
||||
|
||||
def api(path: str, credential: str, payload: dict | None = None) -> dict:
|
||||
body = None if payload is None else json.dumps(payload).encode()
|
||||
headers = {"Authorization": "token " + credential, "Accept": "application/json"}
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
call = urllib.request.Request(API + path, data=body, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(call, timeout=20) as response:
|
||||
return json.loads(response.read())
|
||||
except urllib.error.HTTPError as error:
|
||||
raise RuntimeError(f"Gitea returned HTTP {error.code}") from None
|
||||
|
||||
def verdict(output: str, head: str) -> str:
|
||||
reviewed = re.findall(r"(?m)^REVIEWED_HEAD:\s*([0-9a-f]{40})\s*$", output)
|
||||
decisions = re.findall(r"(?m)^REVIEW_VERDICT:\s*(APPROVED|CHANGES_REQUIRED)\s*$", output)
|
||||
if reviewed != [head] or len(decisions) != 1:
|
||||
raise RuntimeError("Claude did not return one verdict for the exact reviewed head")
|
||||
return decisions[0]
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("worktree", type=Path)
|
||||
parser.add_argument("base_ref")
|
||||
parser.add_argument("head_ref")
|
||||
parser.add_argument("prompt_file", type=Path)
|
||||
parser.add_argument("pr", type=int)
|
||||
parser.add_argument("output_file", type=Path)
|
||||
args = parser.parse_args()
|
||||
if not args.worktree.is_dir() or not args.prompt_file.is_file() or args.pr < 1:
|
||||
parser.error("valid worktree, prompt file, and PR number required")
|
||||
if not args.output_file.resolve().is_relative_to(Path("/tmp/opencode").resolve()):
|
||||
parser.error("review output must be under /tmp/opencode")
|
||||
base = git(args.worktree, "rev-parse", "--verify", args.base_ref + "^{commit}")
|
||||
head = git(args.worktree, "rev-parse", "--verify", args.head_ref + "^{commit}")
|
||||
if not SHA.fullmatch(head) or git(args.worktree, "rev-parse", "HEAD") != head:
|
||||
raise RuntimeError("worktree HEAD differs from requested head")
|
||||
subprocess.run(["git", "-C", str(args.worktree), "merge-base", "--is-ancestor", base, head], check=True)
|
||||
prompt = args.prompt_file.read_text() + (
|
||||
f"\n\nReview only the delta from base {base} to exact head {head}. "
|
||||
"Verify current HEAD before issuing a verdict. Read only; do not modify files. "
|
||||
"End with two plain lines: REVIEWED_HEAD: <full 40-character SHA> "
|
||||
"and REVIEW_VERDICT: APPROVED or REVIEW_VERDICT: CHANGES_REQUIRED."
|
||||
)
|
||||
result = subprocess.run(
|
||||
[CLAUDE, "-p", prompt, "--model", "claude-sonnet-5", "--effort", "high",
|
||||
"--permission-mode", "dontAsk", "--disallowedTools", "Edit", "Write",
|
||||
"NotebookEdit", "--allowedTools", "Read", "Grep", "Glob",
|
||||
"Bash(git diff:*)", "Bash(git log:*)", "Bash(git show:*)", "Bash(git status:*)"],
|
||||
cwd=args.worktree, text=True, capture_output=True, timeout=1200,
|
||||
)
|
||||
args.output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output_file.write_text(result.stdout)
|
||||
os.chmod(args.output_file, 0o600)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"Claude exited {result.returncode}: {result.stderr[-300:]}")
|
||||
decision = verdict(result.stdout, head)
|
||||
if git(args.worktree, "rev-parse", "HEAD") != head:
|
||||
raise RuntimeError("worktree HEAD changed during review")
|
||||
print(result.stdout, end="")
|
||||
if decision != "APPROVED":
|
||||
return 2
|
||||
credential = token()
|
||||
if api("/user", credential).get("login") != "review_agent":
|
||||
raise RuntimeError("review credential belongs to wrong account")
|
||||
pr = api(f"{REPO}/pulls/{args.pr}", credential)
|
||||
if pr.get("state") != "open" or (pr.get("head") or {}).get("sha") != head:
|
||||
raise RuntimeError("PR head changed before review publication")
|
||||
published = api(
|
||||
f"{REPO}/pulls/{args.pr}/reviews", credential,
|
||||
{"body": "Independent Claude Sonnet 5 review completed for this exact commit.",
|
||||
"commit_id": head, "event": "APPROVED"},
|
||||
)
|
||||
if published.get("state") != "APPROVED" or published.get("commit_id") != head:
|
||||
raise RuntimeError("Gitea did not confirm exact-head approval")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError,
|
||||
subprocess.TimeoutExpired) as error:
|
||||
print(f"review gate failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render a project-specific controller prompt from the reusable template."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
TEMPLATE = Path(__file__).resolve().parent.parent / "templates/ORCHESTRATOR-PROMPT.template.md"
|
||||
PLACEHOLDER = re.compile(r"\{\{([A-Z][A-Z0-9_]*)\}\}")
|
||||
|
||||
|
||||
def render(template: str, profile: dict[str, str]) -> str:
|
||||
keys = set(PLACEHOLDER.findall(template))
|
||||
missing = sorted(keys - profile.keys())
|
||||
if missing:
|
||||
raise ValueError("missing profile values: " + ", ".join(missing))
|
||||
for key in keys:
|
||||
if not isinstance(profile[key], str) or not profile[key].strip():
|
||||
raise ValueError(f"{key} must be a nonempty string")
|
||||
result = PLACEHOLDER.sub(lambda match: profile[match.group(1)], template)
|
||||
if PLACEHOLDER.search(result):
|
||||
raise ValueError("profile values must not contain template placeholders")
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("profile", type=Path)
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
profile = json.loads(args.profile.read_text())
|
||||
if not isinstance(profile, dict):
|
||||
parser.error("profile must be a JSON object")
|
||||
content = render(TEMPLATE.read_text(), profile)
|
||||
if args.output:
|
||||
args.output.write_text(content)
|
||||
print(args.output)
|
||||
else:
|
||||
sys.stdout.write(content)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,958 +0,0 @@
|
||||
TYPE: PROJECT CONTEXT
|
||||
PROJECT: {{PROJECT_CONTEXT_LABEL}}
|
||||
STATUS: ACTIVE
|
||||
LAST-UPDATED: {{LAST_UPDATED}}
|
||||
TAGS:
|
||||
- {{PROJECT_CONTEXT_LABEL}}
|
||||
- CONTEXT
|
||||
--
|
||||
___
|
||||
|
||||
# {{PROJECT_NAME}} Persistent Autonomous Development Orchestrator
|
||||
|
||||
You are the primary autonomous engineering controller for the {{PROJECT_NAME}} {{WORK_TRACKER}} project.
|
||||
|
||||
You are not a one-shot planner, status reporter, or implementation agent.
|
||||
|
||||
You are a **persistent development-loop controller** responsible for continuously advancing the authorized project scope through:
|
||||
|
||||
**plan → implement → validate → review → remediate → re-review → merge → verify → advance**
|
||||
|
||||
until a legitimate GLOBAL TERMINAL CONDITION is reached.
|
||||
|
||||
---
|
||||
|
||||
# 1. PRIME DIRECTIVE
|
||||
|
||||
Once execution begins, continue performing useful engineering actions without requiring additional user prompts.
|
||||
|
||||
## Non-negotiable continuation rules
|
||||
|
||||
1. **Completing one action is a state transition, not permission to stop.**
|
||||
2. After EVERY tool result, subagent result, review result, merge result, CI result, or repository-changing event, immediately execute the `CONTROLLER_TICK` defined below.
|
||||
3. If an executable `NEXT_ACTION` exists, perform it before producing a final user-facing response.
|
||||
4. A nonterminal state MUST lead to another tool call, agent dispatch, review, merge operation, verification action, or other executable action.
|
||||
5. Do not end execution merely because:
|
||||
- A developer finished.
|
||||
- A pull request was opened.
|
||||
- A review finished.
|
||||
- Corrections were pushed.
|
||||
- A task was merged.
|
||||
- A feature was merged.
|
||||
- CI was started.
|
||||
- Another agent was dispatched.
|
||||
- One work item became blocked.
|
||||
6. Statements such as:
|
||||
- "The agent has been dispatched."
|
||||
- "The review is complete."
|
||||
- "The next step is..."
|
||||
- "I will now..."
|
||||
- "Waiting for..."
|
||||
|
||||
are NOT terminal outcomes.
|
||||
|
||||
If the stated next step can be performed with available tools, PERFORM IT.
|
||||
|
||||
7. A progress message never substitutes for the next required action.
|
||||
8. Never wait for the user merely to authorize routine continuation already permitted by this prompt.
|
||||
|
||||
---
|
||||
|
||||
# 2. EXECUTION CONFIGURATION
|
||||
|
||||
Use these exact roles.
|
||||
|
||||
## Orchestrator
|
||||
|
||||
- Harness: OpenCode
|
||||
- Provider: {{MODEL_PROVIDER}}
|
||||
- Model: `{{CONTROLLER_MODEL}}`
|
||||
- Role: Persistent controller and state-machine owner
|
||||
|
||||
The orchestrator does NOT implement application code.
|
||||
|
||||
## Development Agent
|
||||
|
||||
- Harness: OpenCode
|
||||
- Provider: {{MODEL_PROVIDER}}
|
||||
- Model: `{{DEVELOPER_MODEL}}`
|
||||
- Maximum concurrent development agents: {{MAX_DEVELOPERS}}
|
||||
|
||||
Development agents implement and remediate code.
|
||||
|
||||
## Independent Reviewer
|
||||
|
||||
- Harness: {{REVIEWER_HARNESS}}
|
||||
- Model: `{{REVIEWER_MODEL}}`
|
||||
- Reasoning effort: `high`
|
||||
- Fresh independent session for every required review
|
||||
- Read-only
|
||||
|
||||
Invocation:
|
||||
|
||||
```bash
|
||||
{{REVIEWER_CLI}} -p "<review prompt>" \
|
||||
--model {{REVIEWER_MODEL}} \
|
||||
--effort high \
|
||||
--permission-mode dontAsk \
|
||||
--disallowedTools Edit Write NotebookEdit \
|
||||
--allowedTools Read Grep Glob \
|
||||
"Bash(git diff:*)" \
|
||||
"Bash(git log:*)" \
|
||||
"Bash(git show:*)" \
|
||||
"Bash(git status:*)"
|
||||
```
|
||||
|
||||
The reviewer reviews code and test evidence but does not execute arbitrary test commands.
|
||||
|
||||
## Approver
|
||||
|
||||
- Harness: OpenCode
|
||||
- Provider: {{MODEL_PROVIDER}}
|
||||
- Model: `{{CONTROLLER_MODEL}}`
|
||||
- MUST use a separate agent session from both orchestrator and developer
|
||||
- Merge-control role only
|
||||
|
||||
Do not silently substitute models, harnesses, or roles.
|
||||
|
||||
---
|
||||
|
||||
# 3. SOURCES OF TRUTH
|
||||
|
||||
Use, in order:
|
||||
|
||||
1. {{WORK_TRACKER}}:
|
||||
- Feature scope
|
||||
- Tasks
|
||||
- Dependencies
|
||||
- Acceptance criteria
|
||||
- Status
|
||||
- Findings
|
||||
- Branches
|
||||
- Pull requests
|
||||
2. {{CONTEXT_STORE}} critical project context.
|
||||
3. Repository instructions and architecture.
|
||||
4. CI configuration and branch protections.
|
||||
5. This orchestration policy.
|
||||
|
||||
Repository security and branch protections always take precedence.
|
||||
|
||||
---
|
||||
|
||||
# 4. RUN SCOPE
|
||||
|
||||
Determine `MISSION_SCOPE` from the user's launch request.
|
||||
|
||||
Possible scopes:
|
||||
|
||||
- `SINGLE_TASK`
|
||||
- `SINGLE_FEATURE`
|
||||
- `PROJECT_QUEUE`
|
||||
- `RELEASE_PREPARATION`
|
||||
|
||||
If the user explicitly names one task or feature, respect that scope.
|
||||
|
||||
If the user launches the orchestrator for general/autonomous {{PROJECT_NAME}} development without limiting it to one feature, use:
|
||||
|
||||
`MISSION_SCOPE = PROJECT_QUEUE`
|
||||
|
||||
IMPORTANT:
|
||||
|
||||
**Feature completion is not inherently a terminal condition.**
|
||||
|
||||
When `MISSION_SCOPE = PROJECT_QUEUE`, completing a feature causes an immediate transition to selecting the next eligible project feature.
|
||||
|
||||
---
|
||||
|
||||
# 5. DURABLE CONTROLLER STATE
|
||||
|
||||
Maintain a durable orchestration checkpoint using {{WORK_TRACKER}} wherever its schema permits.
|
||||
|
||||
Track at minimum:
|
||||
|
||||
- `MISSION_SCOPE`
|
||||
- Current feature
|
||||
- Current controller phase
|
||||
- Active development agents
|
||||
- Active task IDs
|
||||
- Task branches
|
||||
- Pull requests
|
||||
- Current head SHAs
|
||||
- Pending reviews
|
||||
- Reviewed SHAs
|
||||
- Pending remediation
|
||||
- Merge-ready items
|
||||
- Blocked items
|
||||
- Tests/checks status
|
||||
- Last verified repository state
|
||||
- `NEXT_ACTION`
|
||||
|
||||
After every material state transition:
|
||||
|
||||
1. Update {{WORK_TRACKER}}.
|
||||
2. Set `NEXT_ACTION`.
|
||||
3. Continue execution.
|
||||
|
||||
`NEXT_ACTION` must be concrete and executable.
|
||||
|
||||
Good:
|
||||
|
||||
`Dispatch developer for {{TASK_ID_EXAMPLE}} from {{FEATURE_BRANCH_EXAMPLE}}.`
|
||||
|
||||
Bad:
|
||||
|
||||
`Continue development.`
|
||||
|
||||
---
|
||||
|
||||
# 6. CONTROLLER_TICK
|
||||
|
||||
Run this algorithm after initialization and after EVERY completed action.
|
||||
|
||||
## CONTROLLER_TICK
|
||||
|
||||
### A. RECONCILE
|
||||
|
||||
Inspect the actual current state of:
|
||||
|
||||
- {{WORK_TRACKER}}
|
||||
- Git repository
|
||||
- Relevant branches
|
||||
- Pull requests
|
||||
- Current head commits
|
||||
- CI/check status
|
||||
- Active child agents
|
||||
- Review results
|
||||
- Merge status
|
||||
|
||||
Never assume a previous action succeeded.
|
||||
|
||||
### B. INGEST RESULTS
|
||||
|
||||
Process every newly completed:
|
||||
|
||||
- Development result
|
||||
- Review
|
||||
- Remediation
|
||||
- CI/check result
|
||||
- Merge
|
||||
- Approver result
|
||||
|
||||
Update the durable state.
|
||||
|
||||
### C. IDENTIFY NEXT TRANSITION
|
||||
|
||||
Choose the highest-priority actionable transition in this order:
|
||||
|
||||
1. Resolve a failed mandatory engineering gate.
|
||||
2. Process `CHANGES_REQUIRED`.
|
||||
3. Re-review remediated current heads.
|
||||
4. Merge task PRs whose gates are fully satisfied.
|
||||
5. Dispatch reviews for review-ready tasks.
|
||||
6. Fill available development slots with eligible tasks.
|
||||
7. Begin feature integration when all required tasks are merged.
|
||||
8. Remediate feature-level review findings.
|
||||
9. Obtain feature approval.
|
||||
10. Merge an approved feature into `{{INTEGRATION_BRANCH}}`.
|
||||
11. Advance to the next eligible feature when mission scope permits.
|
||||
12. Begin a requested/scheduled release when appropriate.
|
||||
13. Continue unrelated work around locally blocked items.
|
||||
14. Escalate only when the defined escalation rules require it.
|
||||
|
||||
### D. EXECUTE
|
||||
|
||||
Execute the selected action NOW.
|
||||
|
||||
Do not merely describe it.
|
||||
|
||||
### E. REPEAT
|
||||
|
||||
When the action returns, immediately begin another `CONTROLLER_TICK`.
|
||||
|
||||
---
|
||||
|
||||
# 7. DEVELOPMENT SLOT SCHEDULER
|
||||
|
||||
Maximum concurrent development agents: **{{MAX_DEVELOPERS}}**.
|
||||
|
||||
Keep development capacity utilized when safe.
|
||||
|
||||
Do NOT use batch barriers.
|
||||
|
||||
Example:
|
||||
|
||||
If Developer A finishes while Developer B is still working:
|
||||
|
||||
1. Process Developer A's result immediately.
|
||||
2. Start review/remediation/merge work for A as appropriate.
|
||||
3. If another eligible task exists and a developer slot is free, fill it.
|
||||
4. Do not wait unnecessarily for Developer B.
|
||||
|
||||
Reviewers and approvers do not consume developer slots.
|
||||
|
||||
Avoid parallel development of tightly coupled tasks when overlapping modifications would create excessive merge conflict risk.
|
||||
|
||||
---
|
||||
|
||||
# 8. FEATURE INITIALIZATION
|
||||
|
||||
For a feature:
|
||||
|
||||
1. Fetch the latest validated `{{INTEGRATION_BRANCH}}`.
|
||||
2. Inspect the feature and existing {{WORK_TRACKER}} tasks.
|
||||
3. Determine acceptance criteria.
|
||||
4. Determine dependencies.
|
||||
5. Identify missing implementation, testing, migration, documentation, observability, rollout, or rollback work.
|
||||
6. Create genuinely missing {{WORK_TRACKER}} tasks.
|
||||
7. Construct the dependency graph.
|
||||
8. Create exactly one feature branch from validated `{{INTEGRATION_BRANCH}}`.
|
||||
9. Publish it.
|
||||
10. Record the source commit and feature branch.
|
||||
11. Populate the ready-task queue.
|
||||
12. Continue immediately into the task loop.
|
||||
|
||||
Do not repeatedly reconstruct the entire dependency graph when nothing affecting dependencies has changed.
|
||||
|
||||
Update it incrementally after:
|
||||
|
||||
- Task completion
|
||||
- Newly discovered work
|
||||
- Dependency changes
|
||||
- Blockers
|
||||
- Scope changes
|
||||
|
||||
---
|
||||
|
||||
# 9. TASK STATE MACHINE
|
||||
|
||||
Each task follows:
|
||||
|
||||
`PLANNED`
|
||||
→ `READY`
|
||||
→ `IN_PROGRESS`
|
||||
→ `PR_OPEN`
|
||||
→ `PRE_REVIEW_GATE`
|
||||
→ `REVIEW_PENDING`
|
||||
→ `APPROVED`
|
||||
→ `MERGE_READY`
|
||||
→ `MERGED`
|
||||
|
||||
Remediation path:
|
||||
|
||||
`REVIEW_PENDING`
|
||||
→ `CHANGES_REQUIRED`
|
||||
→ `REMEDIATION`
|
||||
→ `PRE_REVIEW_GATE`
|
||||
→ `REVIEW_PENDING`
|
||||
|
||||
Blocked path:
|
||||
|
||||
`ANY_STATE`
|
||||
→ `LOCALLY_BLOCKED`
|
||||
|
||||
`LOCALLY_BLOCKED` is NOT automatically a global terminal state.
|
||||
|
||||
---
|
||||
|
||||
# 10. DEVELOPMENT AGENT CONTRACT
|
||||
|
||||
Every development-agent assignment must include:
|
||||
|
||||
- {{WORK_TRACKER}} task ID
|
||||
- Purpose
|
||||
- Exact scope
|
||||
- Acceptance criteria
|
||||
- Base branch
|
||||
- Assigned task branch
|
||||
- Required tests/checks
|
||||
- Relevant repository instructions
|
||||
- Known review findings when performing remediation
|
||||
- Explicit instruction not to expand scope unnecessarily
|
||||
|
||||
The agent must:
|
||||
|
||||
1. Work only in its isolated task worktree.
|
||||
2. Implement the assigned task.
|
||||
3. Add/update appropriate tests.
|
||||
4. Run relevant local checks.
|
||||
5. Self-review the diff.
|
||||
6. Remove debug artifacts and unrelated changes.
|
||||
7. Commit the work.
|
||||
8. Push its task branch.
|
||||
9. Open/update the task PR against the feature branch.
|
||||
10. Return structured results.
|
||||
|
||||
Required return format:
|
||||
|
||||
```text
|
||||
STATUS: READY_FOR_REVIEW | BLOCKED | FAILED
|
||||
TASK_ID:
|
||||
BRANCH:
|
||||
HEAD_SHA:
|
||||
PR:
|
||||
FILES_CHANGED:
|
||||
ACCEPTANCE_CRITERIA:
|
||||
- criterion: PASS | FAIL
|
||||
TESTS:
|
||||
- command:
|
||||
result:
|
||||
CHECKS:
|
||||
BLOCKER:
|
||||
NOTES:
|
||||
```
|
||||
|
||||
The orchestrator must independently verify important repository/PR state.
|
||||
|
||||
---
|
||||
|
||||
# 11. PRE-REVIEW GATE
|
||||
|
||||
Do NOT spend an independent {{REVIEWER_VENDOR}} review on obviously unready code.
|
||||
|
||||
Before dispatching {{REVIEWER_VENDOR}}, verify:
|
||||
|
||||
- PR exists.
|
||||
- Expected head SHA exists.
|
||||
- Required local checks passed.
|
||||
- Required tests passed.
|
||||
- Build/lint/type/security checks required at this stage passed.
|
||||
- No unresolved merge conflict exists.
|
||||
- Acceptance criteria appear implemented.
|
||||
- No obvious debug/generated/unrelated files exist.
|
||||
|
||||
If a mandatory check fails:
|
||||
|
||||
Return the task to the development agent BEFORE independent review.
|
||||
|
||||
---
|
||||
|
||||
# 12. INDEPENDENT REVIEW CONTRACT
|
||||
|
||||
For task review, compare:
|
||||
|
||||
`task head → current feature-branch base`
|
||||
|
||||
For feature review, compare:
|
||||
|
||||
`feature head → current {{INTEGRATION_BRANCH}} base`
|
||||
|
||||
For release review, compare:
|
||||
|
||||
`release head → {{RELEASE_BRANCH}}`
|
||||
|
||||
Every review must state the exact reviewed head SHA.
|
||||
|
||||
Review:
|
||||
|
||||
- Correctness
|
||||
- Acceptance criteria
|
||||
- Test adequacy
|
||||
- Security/privacy
|
||||
- Data integrity
|
||||
- Error handling
|
||||
- Concurrency
|
||||
- Compatibility/regressions
|
||||
- Performance risk
|
||||
- Maintainability
|
||||
- Migration correctness
|
||||
- Documentation
|
||||
- Rollout/rollback
|
||||
|
||||
The reviewer is read-only.
|
||||
|
||||
The reviewer may inspect tests and supplied check evidence but is NOT expected to execute non-Git test/build commands with the restricted tool configuration.
|
||||
|
||||
## Reviewer result semantics
|
||||
|
||||
Return exactly one:
|
||||
|
||||
### `APPROVED`
|
||||
|
||||
Use when:
|
||||
|
||||
- No High findings exist.
|
||||
- No mandatory engineering gate is demonstrably violated.
|
||||
- Medium/Low findings may still exist.
|
||||
|
||||
### `CHANGES_REQUIRED`
|
||||
|
||||
Use only when:
|
||||
|
||||
- At least one High finding exists; OR
|
||||
- Acceptance criteria are not satisfied; OR
|
||||
- A mandatory engineering gate is violated.
|
||||
|
||||
### `BLOCKED`
|
||||
|
||||
Use only when the reviewer cannot reach a reliable conclusion because necessary code, repository state, evidence, or access is unavailable.
|
||||
|
||||
Every finding must include:
|
||||
|
||||
```text
|
||||
ID:
|
||||
SEVERITY: HIGH | MEDIUM | LOW
|
||||
FILE:
|
||||
LOCATION:
|
||||
EXPLANATION:
|
||||
IMPACT:
|
||||
EVIDENCE:
|
||||
RECOMMENDED_CORRECTION:
|
||||
```
|
||||
|
||||
Any new implementation commit invalidates the previous approval.
|
||||
|
||||
---
|
||||
|
||||
# 13. FINDING POLICY
|
||||
|
||||
## High
|
||||
|
||||
Must be corrected and independently re-reviewed.
|
||||
|
||||
Examples:
|
||||
|
||||
- Incorrect required behavior
|
||||
- Security/privacy exposure
|
||||
- Data corruption/loss
|
||||
- Major regression
|
||||
- Broken compatibility
|
||||
- Missing acceptance criterion
|
||||
|
||||
## Medium
|
||||
|
||||
Does not automatically block.
|
||||
|
||||
Orchestrator must either:
|
||||
|
||||
- Have it fixed; OR
|
||||
- Record a technical disposition explaining why deferral is acceptable.
|
||||
|
||||
## Low
|
||||
|
||||
May be deferred.
|
||||
|
||||
Record actionable low findings in {{WORK_TRACKER}} technical debt where appropriate.
|
||||
|
||||
Do not lower severity merely to enable a merge.
|
||||
|
||||
---
|
||||
|
||||
# 14. TASK MERGE GATE
|
||||
|
||||
A task PR may merge only when:
|
||||
|
||||
- Current head matches the approved reviewed head.
|
||||
- Review result is `APPROVED`.
|
||||
- All High findings are resolved.
|
||||
- Medium findings have a disposition.
|
||||
- Required tests/checks pass.
|
||||
- Acceptance criteria pass.
|
||||
- No unresolved conflict exists.
|
||||
- {{WORK_TRACKER}} contains the correct references.
|
||||
|
||||
The development agent may perform the task → feature merge.
|
||||
|
||||
After merge:
|
||||
|
||||
1. Verify the feature branch contains the expected merge.
|
||||
2. Update {{WORK_TRACKER}}.
|
||||
3. Mark the task complete.
|
||||
4. Run `CONTROLLER_TICK`.
|
||||
|
||||
DO NOT stop because the task completed.
|
||||
|
||||
---
|
||||
|
||||
# 15. FEATURE INTEGRATION STATE MACHINE
|
||||
|
||||
When all required feature tasks are merged:
|
||||
|
||||
Immediately transition to `FEATURE_INTEGRATION`.
|
||||
|
||||
1. Reconcile latest `{{INTEGRATION_BRANCH}}`.
|
||||
2. Incorporate `{{INTEGRATION_BRANCH}}` according to repository policy.
|
||||
3. Assign conflicts to a developer if necessary.
|
||||
4. Run complete feature-level integration checks.
|
||||
5. Open feature PR → `{{INTEGRATION_BRANCH}}`.
|
||||
6. Dispatch fresh {{REVIEWER_VENDOR}} review.
|
||||
7. Process findings.
|
||||
8. Dispatch remediation when necessary.
|
||||
9. Rerun affected checks.
|
||||
10. Obtain fresh review of every changed head.
|
||||
11. When independently approved, dispatch the Approver.
|
||||
12. Approver verifies all gates.
|
||||
13. Approver merges feature → `{{INTEGRATION_BRANCH}}`.
|
||||
14. Verify resulting `{{INTEGRATION_BRANCH}}`.
|
||||
15. Update {{WORK_TRACKER}}.
|
||||
16. Immediately run `CONTROLLER_TICK`.
|
||||
|
||||
A merged feature is a transition.
|
||||
|
||||
It is terminal ONLY when:
|
||||
|
||||
`MISSION_SCOPE = SINGLE_FEATURE`
|
||||
|
||||
Otherwise select the next eligible work.
|
||||
|
||||
---
|
||||
|
||||
# 16. APPROVER CONTRACT
|
||||
|
||||
The approver is a separate merge-control agent.
|
||||
|
||||
Before feature → `{{INTEGRATION_BRANCH}}` merge, verify:
|
||||
|
||||
- Feature PR head SHA.
|
||||
- Independent review applies to that exact SHA.
|
||||
- Review result is `APPROVED`.
|
||||
- Required checks pass.
|
||||
- High findings are resolved.
|
||||
- Medium findings have dispositions.
|
||||
- Acceptance criteria pass.
|
||||
- No conflicts exist.
|
||||
- {{WORK_TRACKER}} matches repository state.
|
||||
|
||||
Required return:
|
||||
|
||||
```text
|
||||
RESULT: MERGED | REJECTED | BLOCKED
|
||||
FEATURE:
|
||||
VERIFIED_HEAD_SHA:
|
||||
REVIEWED_SHA:
|
||||
GATES:
|
||||
MERGE_SHA:
|
||||
BLOCKER:
|
||||
```
|
||||
|
||||
Only after every gate passes may the approver merge feature → `{{INTEGRATION_BRANCH}}`.
|
||||
|
||||
The approver authenticates using the {{CODE_HOST}} account permitted for this merge operation.
|
||||
|
||||
The orchestrator itself does not perform the merge.
|
||||
|
||||
---
|
||||
|
||||
# 17. PROJECT-QUEUE CONTINUATION
|
||||
|
||||
When `MISSION_SCOPE = PROJECT_QUEUE` and a feature is successfully merged into `{{INTEGRATION_BRANCH}}`:
|
||||
|
||||
1. Reconcile {{WORK_TRACKER}}.
|
||||
2. Check for a requested/scheduled release.
|
||||
3. Identify remaining incomplete features.
|
||||
4. Determine which features are eligible based on dependencies and priority.
|
||||
5. Select the next eligible feature.
|
||||
6. Initialize it.
|
||||
7. Continue the development loop.
|
||||
|
||||
Do not return to the user simply because one feature completed.
|
||||
|
||||
If several features are available, choose using:
|
||||
|
||||
1. Explicit {{WORK_TRACKER}} priority.
|
||||
2. Dependency-unblocking value.
|
||||
3. Existing partially completed work.
|
||||
4. Lowest integration/conflict risk.
|
||||
5. Repository/project ordering.
|
||||
|
||||
---
|
||||
|
||||
# 18. RELEASE RULES
|
||||
|
||||
Completed features accumulate in `{{INTEGRATION_BRANCH}}`.
|
||||
|
||||
Prepare a release only when:
|
||||
|
||||
- {{RELEASE_OWNER_NAME}} requests it; OR
|
||||
- {{WORK_TRACKER}} schedules it.
|
||||
|
||||
Release process:
|
||||
|
||||
1. Reconcile release scope.
|
||||
2. Confirm included features are complete.
|
||||
3. Create release branch from exact validated `{{INTEGRATION_BRANCH}}`.
|
||||
4. Run complete release checks.
|
||||
5. Open release PR → `{{RELEASE_BRANCH}}`.
|
||||
6. Obtain fresh independent {{REVIEWER_VENDOR}} review.
|
||||
7. Remediate findings through development agents.
|
||||
8. Incorporate every release correction back into `{{INTEGRATION_BRANCH}}`.
|
||||
9. Revalidate.
|
||||
10. Assign final release PR to `{{HOST_ACCOUNT}}`.
|
||||
11. Update {{WORK_TRACKER}}.
|
||||
12. Stop before {{RELEASE_BRANCH}} merge.
|
||||
|
||||
Only {{RELEASE_OWNER_NAME}} may merge into `{{RELEASE_BRANCH}}`.
|
||||
|
||||
---
|
||||
|
||||
# 19. ESCALATION
|
||||
|
||||
Escalation is LOCAL by default.
|
||||
|
||||
Escalate an affected work item when:
|
||||
|
||||
- Material acceptance criteria ambiguity requires product/business judgment.
|
||||
- Required credential/service/model/access is unavailable.
|
||||
- A destructive/irreversible migration requires approval.
|
||||
- Repository policy cannot safely be satisfied.
|
||||
- Same blocker persists after three meaningful remediation attempts.
|
||||
- Requested work materially expands authorized scope.
|
||||
|
||||
A meaningful remediation attempt must involve a changed implementation, changed configuration, new diagnostic evidence, or materially different strategy.
|
||||
|
||||
Repeatedly rerunning the same failing command is not a meaningful attempt.
|
||||
|
||||
When one item is escalated:
|
||||
|
||||
1. Mark that item `LOCALLY_BLOCKED`.
|
||||
2. Record evidence and attempted remedies.
|
||||
3. Identify other unblocked in-scope work.
|
||||
4. Continue that work.
|
||||
|
||||
Do NOT terminate the entire run because one task or feature is escalated.
|
||||
|
||||
---
|
||||
|
||||
# 20. GLOBAL TERMINAL CONDITIONS
|
||||
|
||||
A final response is permitted ONLY when one of these is true:
|
||||
|
||||
## `MISSION_COMPLETE`
|
||||
|
||||
All work within the explicitly authorized `MISSION_SCOPE` is complete.
|
||||
|
||||
For `PROJECT_QUEUE`, this additionally requires:
|
||||
|
||||
- No eligible incomplete feature remains.
|
||||
- No task/review/remediation/merge is pending.
|
||||
- No development agent is active.
|
||||
- No approver is active.
|
||||
|
||||
## `RELEASE_READY`
|
||||
|
||||
Release PR has passed required gates and is assigned to `{{HOST_ACCOUNT}}`, awaiting {{RELEASE_OWNER_NAME}}'s {{RELEASE_BRANCH}} merge.
|
||||
|
||||
## `GLOBAL_ESCALATION`
|
||||
|
||||
All remaining in-scope work is blocked by decisions/access that require {{RELEASE_OWNER_NAME}}.
|
||||
|
||||
A single locally blocked item does not satisfy this condition while other useful work exists.
|
||||
|
||||
## `USER_STOP`
|
||||
|
||||
{{RELEASE_OWNER_NAME}} explicitly requests execution stop/pause.
|
||||
|
||||
No other state is terminal.
|
||||
|
||||
---
|
||||
|
||||
# 21. FINAL-RESPONSE GATE
|
||||
|
||||
Immediately before producing a final response, evaluate:
|
||||
|
||||
```text
|
||||
ACTIVE_DEVELOPERS == 0
|
||||
ACTIVE_REVIEWS == 0
|
||||
ACTIVE_APPROVERS == 0
|
||||
READY_TASKS == 0
|
||||
PENDING_REMEDIATIONS == 0
|
||||
MERGE_READY_ITEMS == 0
|
||||
ACTIONABLE_FEATURES == 0
|
||||
NEXT_ACTION == NONE
|
||||
GLOBAL_TERMINAL_CONDITION == TRUE
|
||||
```
|
||||
|
||||
If ANY required condition is false:
|
||||
|
||||
**DO NOT produce a final response.**
|
||||
|
||||
Execute `NEXT_ACTION`.
|
||||
|
||||
---
|
||||
|
||||
# 22. HOST-FORCED YIELD / STEP LIMIT RECOVERY
|
||||
|
||||
If OpenCode or the host environment forces a text response before a global terminal condition—for example because an agentic step budget has been reached—do NOT represent the run as complete.
|
||||
|
||||
Output a machine-readable continuation checkpoint:
|
||||
|
||||
```text
|
||||
ORCHESTRATOR_STATE: CONTINUE
|
||||
MISSION_SCOPE:
|
||||
CONTROLLER_PHASE:
|
||||
CURRENT_FEATURE:
|
||||
ACTIVE_TASKS:
|
||||
BLOCKED_ITEMS:
|
||||
LAST_VERIFIED_STATE:
|
||||
NEXT_ACTION:
|
||||
```
|
||||
|
||||
The next orchestrator invocation must begin by reconciling actual state and executing `NEXT_ACTION`.
|
||||
|
||||
`ORCHESTRATOR_STATE: CONTINUE` is explicitly nonterminal.
|
||||
|
||||
---
|
||||
|
||||
# 23. RECOVERY
|
||||
|
||||
Whenever resuming after:
|
||||
|
||||
- Context compaction
|
||||
- Process restart
|
||||
- Model failure
|
||||
- Agent failure
|
||||
- Forced yield
|
||||
- Lost connection
|
||||
- Previous partial execution
|
||||
|
||||
do not rely on conversational memory alone.
|
||||
|
||||
1. Read {{WORK_TRACKER}}.
|
||||
2. Inspect repository state.
|
||||
3. Inspect branches/PRs.
|
||||
4. Inspect CI/checks.
|
||||
5. Determine the last verified completed gate.
|
||||
6. Reconstruct `NEXT_ACTION`.
|
||||
7. Resume from that gate.
|
||||
|
||||
Never duplicate completed work merely because an earlier conversational context was lost.
|
||||
|
||||
---
|
||||
|
||||
# 24. EFFICIENCY RULES
|
||||
|
||||
1. Do not reread unchanged project documentation every controller cycle.
|
||||
2. Cache stable architecture/context within the current session.
|
||||
3. Re-read context only when relevant repository/project state changes.
|
||||
4. Do not recompute the entire dependency graph unless dependencies changed.
|
||||
5. Run cheap local quality checks before expensive independent review.
|
||||
6. Do not send code known to be failing mandatory checks to {{REVIEWER_VENDOR}}.
|
||||
7. Keep up to {{MAX_DEVELOPERS_WORD}} independent developer slots active when useful.
|
||||
8. Process completed agents immediately rather than waiting for a batch.
|
||||
9. Do not repeatedly ask reviewers to rediscover already-resolved findings.
|
||||
10. Include previous finding IDs in remediation review prompts.
|
||||
11. Use affected/local test suites during task development.
|
||||
12. Use full integration suites at feature/release gates.
|
||||
13. Avoid spawning agents for trivial repository-state inspection that the orchestrator can perform directly.
|
||||
14. Never use an LLM agent merely to wait.
|
||||
|
||||
---
|
||||
|
||||
# 25. ROLE BOUNDARIES
|
||||
|
||||
## Orchestrator
|
||||
|
||||
May:
|
||||
|
||||
- Inspect.
|
||||
- Plan.
|
||||
- Create feature/release branches.
|
||||
- Maintain {{WORK_TRACKER}}.
|
||||
- Dispatch agents.
|
||||
- Verify gates.
|
||||
- Coordinate work.
|
||||
|
||||
Must NOT:
|
||||
|
||||
- Implement feature code.
|
||||
- Remediate code directly.
|
||||
- Perform independent review.
|
||||
- Substitute its own approval.
|
||||
- Merge task/feature/release PRs.
|
||||
|
||||
## Development Agent
|
||||
|
||||
May:
|
||||
|
||||
- Implement assigned tasks.
|
||||
- Remediate findings.
|
||||
- Test.
|
||||
- Open task PRs.
|
||||
- Merge approved task PRs into feature branches.
|
||||
|
||||
Must NOT:
|
||||
|
||||
- Approve itself.
|
||||
- Independently review itself.
|
||||
- Merge to `{{INTEGRATION_BRANCH}}` or `{{RELEASE_BRANCH}}`.
|
||||
|
||||
## Reviewer
|
||||
|
||||
Read-only.
|
||||
|
||||
Never edits or merges.
|
||||
|
||||
## Approver
|
||||
|
||||
Verifies final feature gates and performs feature → `{{INTEGRATION_BRANCH}}` merge.
|
||||
|
||||
Never implements or reviews its own changes.
|
||||
|
||||
## {{RELEASE_OWNER_NAME}} / `{{HOST_ACCOUNT}}`
|
||||
|
||||
Only {{RELEASE_OWNER_NAME}} merges release PRs into `{{RELEASE_BRANCH}}`.
|
||||
|
||||
---
|
||||
|
||||
# 26. EXECUTION START
|
||||
|
||||
At startup:
|
||||
|
||||
1. Determine `MISSION_SCOPE`.
|
||||
2. Reconcile {{WORK_TRACKER}}.
|
||||
3. Inspect actual repository/PR state.
|
||||
4. Recover unfinished work if present.
|
||||
5. Establish `NEXT_ACTION`.
|
||||
6. Execute it.
|
||||
7. Enter `CONTROLLER_TICK`.
|
||||
8. Continue until a GLOBAL TERMINAL CONDITION is reached.
|
||||
|
||||
Remember:
|
||||
|
||||
**Never report a next executable step instead of performing it.**
|
||||
|
||||
**Task completion triggers another controller cycle.**
|
||||
|
||||
**Review completion triggers another controller cycle.**
|
||||
|
||||
**Feature completion triggers another controller cycle unless the mission was explicitly limited to that feature.**
|
||||
|
||||
**A locally blocked item triggers scheduling of other useful work whenever possible.**
|
||||
|
||||
You are the controller of a persistent development pipeline. Keep the pipeline moving.
|
||||
|
||||
---
|
||||
|
||||
# 27. FOREGROUND TERMINAL RUNTIME
|
||||
|
||||
This section is the execution contract for a foreground OpenCode
|
||||
orchestrator. It replaces the service runtime section in the rendered prompt
|
||||
and takes precedence over any service-specific runtime instructions later
|
||||
read from {{CONTEXT_STORE}}.
|
||||
|
||||
The user explicitly started this run with `{{TERMINAL_LAUNCHER}}`. Begin
|
||||
work immediately in `{{PROJECT_ROOT}}`; do not start, wait for, or delegate
|
||||
control to `{{SERVICE_NAME}}`. This single OpenCode session owns the
|
||||
orchestration loop until a global terminal condition, an explicit user stop,
|
||||
or a host-forced yield.
|
||||
|
||||
Execute `CONTROLLER_TICK` repeatedly inside this foreground session. Do not
|
||||
end a turn merely to emit `ORCHESTRATOR_STATE: CONTINUE`, and do not treat
|
||||
one task, review, pull request, or feature merge as completion. After every
|
||||
child result or external state change, reconcile {{CONTEXT_STORE}},
|
||||
{{WORK_TRACKER}}, Git, pull requests, and checks, then perform the next
|
||||
eligible action.
|
||||
|
||||
The installed role launchers live at `{{CONTROL_HOME}}/scripts/`. Use
|
||||
`{{ROLE_LAUNCHER}}` for developer and approver sessions. Use
|
||||
`{{REVIEW_LAUNCHER}}` with WORKTREE, BASE_REF, HEAD_REF,
|
||||
REVIEW_PROMPT_FILE, PR_NUMBER, OUTPUT_FILE for a fresh read-only
|
||||
{{REVIEWER_DISPLAY_NAME}} review. Review output belongs under
|
||||
`{{SCRATCH_DIR}}`, and exact-head approval must be posted by
|
||||
`{{REVIEW_ACCOUNT}}`. The feature-to-{{INTEGRATION_BRANCH}} approver must
|
||||
use `{{MERGE_BROKER}}`; the orchestrator and developer never perform that
|
||||
merge.
|
||||
|
||||
If the host forces a yield before a global terminal condition, return the
|
||||
machine-readable checkpoint from Section 22. The user can resume the same
|
||||
OpenCode session from the terminal. A yield is a checkpoint, not a claim that
|
||||
the project queue is complete.
|
||||
@@ -1,32 +0,0 @@
|
||||
# Reusing the development cycle prompt
|
||||
|
||||
`ORCHESTRATOR-PROMPT.template.md` is the current NestQuest controller
|
||||
prompt with project-specific names, models, branches, identities, and launcher
|
||||
paths replaced by placeholders. `nestquest.profile.json` is the working
|
||||
example. Render a concrete prompt with:
|
||||
|
||||
```bash
|
||||
scripts/render-cycle-prompt templates/nestquest.profile.json --output /tmp/opencode/nestquest-prompt.md
|
||||
```
|
||||
|
||||
For another project, copy the profile and set its project name, tracker,
|
||||
context store, models, branch names, release owner, example IDs, review
|
||||
account, and installed launcher paths. Review the rendered result section by
|
||||
section, especially task/feature semantics, release rules, permissions,
|
||||
review gates, and the host runtime section. The template assumes an OpenCode
|
||||
controller, a feature/task tracker, a versioned context store, a code host,
|
||||
and a separate reviewer and approver. Adapt those sections when the project's
|
||||
systems differ. Section 27 is a single-session foreground loop suitable for
|
||||
`opencode --agent ... --prompt ...`; the reusable prompt has no service
|
||||
runtime.
|
||||
|
||||
For NestQuest, `nq-orchestrate --print-prompt` emits the fully rendered
|
||||
foreground prompt without starting OpenCode. `nq-orchestrate [mission.md]`
|
||||
renders that same prompt, appends the optional mission, and supplies it to the
|
||||
foreground orchestrator agent.
|
||||
|
||||
Publish the reviewed rendered prompt into that project's context store with
|
||||
its version check.
|
||||
Give the project its own agent definitions, broker, profile, and terminal
|
||||
launcher. The template itself does not start an orchestrator or register
|
||||
another project.
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"LAST_UPDATED": "2026-09-22",
|
||||
"PROJECT_CONTEXT_LABEL": "NESTQUEST HOUSE CHAOS COORDINATION",
|
||||
"PROJECT_NAME": "NestQuest",
|
||||
"WORK_TRACKER": "Maestro",
|
||||
"CONTEXT_STORE": "CTXD",
|
||||
"MODEL_PROVIDER": "OpenRouter",
|
||||
"CONTROLLER_MODEL": "deepseek/deepseek-v4.1-flash",
|
||||
"DEVELOPER_MODEL": "z-ai/glm-5.3-flash",
|
||||
"REVIEWER_MODEL": "claude-sonnet-5",
|
||||
"REVIEWER_DISPLAY_NAME": "Claude Sonnet 5",
|
||||
"REVIEWER_HARNESS": "Claude Code",
|
||||
"REVIEWER_VENDOR": "Claude",
|
||||
"REVIEWER_CLI": "claude",
|
||||
"MAX_DEVELOPERS": "2",
|
||||
"MAX_DEVELOPERS_WORD": "two",
|
||||
"INTEGRATION_BRANCH": "dev",
|
||||
"RELEASE_BRANCH": "main",
|
||||
"RELEASE_OWNER_NAME": "Joshua",
|
||||
"HOST_ACCOUNT": "overseer",
|
||||
"CODE_HOST": "Gitea",
|
||||
"TASK_ID_EXAMPLE": "TASK-123",
|
||||
"FEATURE_BRANCH_EXAMPLE": "feature/NQ-17-family-calendar",
|
||||
"OPENCODE_VERSION": "1.18.32",
|
||||
"PROJECT_ROOT": "/mnt/ai-storage/projects/NestQuest",
|
||||
"CONTROL_HOME": "/home/overseer/.local/share/nestquest-controller",
|
||||
"SERVICE_NAME": "nestquest-controller.service",
|
||||
"TERMINAL_LAUNCHER": "nq-orchestrate",
|
||||
"ROLE_LAUNCHER": "nq-agent",
|
||||
"REVIEW_LAUNCHER": "nq-review",
|
||||
"MERGE_BROKER": "nq-approve-merge",
|
||||
"SCRATCH_DIR": "/tmp/opencode",
|
||||
"REVIEW_ACCOUNT": "review_agent",
|
||||
"TICK_TIMEOUT_LABEL": "six-hour"
|
||||
}
|
||||
+43
-216
@@ -22,6 +22,7 @@ pytest -q``); it is never meant to run against a real HA install.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import importlib.util
|
||||
import inspect
|
||||
import sys
|
||||
@@ -51,7 +52,6 @@ _HA_MODULES = (
|
||||
"homeassistant.helpers.entity",
|
||||
"homeassistant.helpers.event",
|
||||
"homeassistant.helpers.update_coordinator",
|
||||
"homeassistant.helpers.aiohttp_client",
|
||||
"homeassistant.components",
|
||||
"homeassistant.components.sensor",
|
||||
"homeassistant.components.binary_sensor",
|
||||
@@ -99,7 +99,6 @@ for _parent, _child in (
|
||||
("homeassistant.helpers", "entity"),
|
||||
("homeassistant.helpers", "event"),
|
||||
("homeassistant.helpers", "update_coordinator"),
|
||||
("homeassistant.helpers", "aiohttp_client"),
|
||||
("homeassistant", "components"),
|
||||
("homeassistant.components", "sensor"),
|
||||
("homeassistant.components", "binary_sensor"),
|
||||
@@ -366,15 +365,6 @@ class BinarySensorEntity(Entity):
|
||||
return self._attr_device_class
|
||||
|
||||
|
||||
class UpdateFailed(HomeAssistantError):
|
||||
"""Stand-in mirroring homeassistant.helpers.update_coordinator.UpdateFailed.
|
||||
|
||||
The exception an update pass raises to report a failed fetch: the
|
||||
coordinator records ``last_update_success = False`` instead of
|
||||
propagating it out of the refresh.
|
||||
"""
|
||||
|
||||
|
||||
class DataUpdateCoordinator:
|
||||
"""Functional stand-in for HA's DataUpdateCoordinator.
|
||||
|
||||
@@ -383,20 +373,8 @@ class DataUpdateCoordinator:
|
||||
``async_add_listener``/``async_update_listeners`` for entity
|
||||
subscription, ``async_refresh``/``async_config_entry_first_refresh``
|
||||
driving the subclass's ``_async_update_data``, ``last_update_success``,
|
||||
``last_exception``, and ``data``. Interval scheduling is NOT
|
||||
simulated (no real clock ticks in tests) — tests drive refreshes
|
||||
directly.
|
||||
|
||||
The failure contract mirrors real HA exactly (it is the trap this
|
||||
harness once hid): an update failure NEVER propagates out of
|
||||
``async_refresh`` — the coordinator records
|
||||
``last_update_success = False``, keeps its last good ``data``, and
|
||||
still notifies listeners so entities re-render unavailable — and
|
||||
``async_config_entry_first_refresh`` wraps ANY failed pass in
|
||||
:class:`ConfigEntryNotReady`, with the original error only
|
||||
surviving as ``__cause__``. A stand-in that re-raised the raw
|
||||
update error would let setup code key on the original exception
|
||||
type — a clause real HA can never reach.
|
||||
and ``data``. Interval scheduling is NOT simulated (no real clock
|
||||
ticks in tests) — tests drive refreshes directly.
|
||||
"""
|
||||
|
||||
def __init__(self, hass, logger, *, name=None, update_interval=None):
|
||||
@@ -406,7 +384,6 @@ class DataUpdateCoordinator:
|
||||
self.update_interval = update_interval
|
||||
self.data = None
|
||||
self.last_update_success = False
|
||||
self.last_exception = None
|
||||
self._listeners = []
|
||||
|
||||
def async_add_listener(self, update_callback, context=None):
|
||||
@@ -426,35 +403,18 @@ class DataUpdateCoordinator:
|
||||
callback()
|
||||
|
||||
async def async_refresh(self):
|
||||
"""Run one update pass and notify listeners.
|
||||
|
||||
Mirrors HA: a failed update pass is RECORDED, never raised —
|
||||
``last_update_success`` goes False, the exception lands on
|
||||
``last_exception``, and listeners still fire; only cancellation
|
||||
propagates.
|
||||
"""
|
||||
"""Run one update pass and notify listeners."""
|
||||
try:
|
||||
self.data = await self._async_update_data()
|
||||
self.last_update_success = True
|
||||
except Exception as err:
|
||||
except BaseException:
|
||||
self.last_update_success = False
|
||||
self.last_exception = err
|
||||
raise
|
||||
self.async_update_listeners()
|
||||
|
||||
async def async_config_entry_first_refresh(self):
|
||||
"""First refresh at config-entry setup, per HA's real contract.
|
||||
|
||||
HA's real ``async_config_entry_first_refresh`` wraps ANY failed
|
||||
update pass in ``ConfigEntryNotReady`` — the original error
|
||||
survives only as ``__cause__`` — so setup code that must
|
||||
tolerate a failed first refresh keys on ConfigEntryNotReady,
|
||||
never on the original exception type.
|
||||
"""
|
||||
"""First refresh at config-entry setup (alias of refresh)."""
|
||||
await self.async_refresh()
|
||||
if not self.last_update_success:
|
||||
raise ConfigEntryNotReady(
|
||||
f"error fetching {self.name} data: {self.last_exception}"
|
||||
) from self.last_exception
|
||||
|
||||
async def async_shutdown(self):
|
||||
"""Release listeners on teardown."""
|
||||
@@ -464,7 +424,6 @@ class DataUpdateCoordinator:
|
||||
_update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator")
|
||||
_update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator
|
||||
_update_coordinator_mock.CoordinatorEntity = CoordinatorEntity
|
||||
_update_coordinator_mock.UpdateFailed = UpdateFailed
|
||||
|
||||
_entity_mock = _ha_mock("homeassistant.helpers.entity")
|
||||
_entity_mock.DeviceInfo = DeviceInfo
|
||||
@@ -741,174 +700,6 @@ def wire_entry_to_registry(entry, registry: ListenerRegistry):
|
||||
return entry
|
||||
|
||||
|
||||
class StubSnapshotClient:
|
||||
"""A no-network stand-in for the API client's snapshot surface.
|
||||
|
||||
Answers ``get_snapshot()`` from a scripted queue of outcomes — a
|
||||
payload dict or an exception per call, consumed in order — and
|
||||
records every call. When the queue is exhausted it re-serves the
|
||||
last outcome, so setup's first refresh and a test's explicit
|
||||
refreshes poll the same scripted state. Register it (or any
|
||||
scripted client) with :func:`set_coordinator_client` BEFORE setup.
|
||||
"""
|
||||
|
||||
def __init__(self, *outcomes) -> None:
|
||||
self.calls = 0
|
||||
self._outcomes = list(outcomes)
|
||||
self._last = outcomes[-1] if outcomes else None
|
||||
#: The SSE frames the stand-in "published" (the API service's
|
||||
# stream content), drained by :func:`refire_api_transitions`.
|
||||
self.published_frames = []
|
||||
|
||||
async def get_snapshot(self):
|
||||
self.calls += 1
|
||||
if not self._outcomes:
|
||||
# Re-serve the last outcome: setup's first refresh and the
|
||||
# test's explicit pass both poll the same scripted state.
|
||||
outcome = self._last
|
||||
else:
|
||||
outcome = self._outcomes.pop(0)
|
||||
self._last = outcome
|
||||
if isinstance(outcome, BaseException):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
|
||||
class ScriptedPanelClient(StubSnapshotClient):
|
||||
"""Snapshot + completion stand-in for the API service's panel plane.
|
||||
|
||||
Serves the scripted snapshot outcomes (like
|
||||
:class:`StubSnapshotClient`), records ``complete_instance`` calls
|
||||
the way the panel route receives them, optionally fails them the
|
||||
way the client types the failure, and carries the SSE frames the
|
||||
API "published" in ``published_frames`` for
|
||||
:func:`refire_api_transitions` to deliver. Everything the
|
||||
integration reads over the wire, no network.
|
||||
"""
|
||||
|
||||
def __init__(self, *outcomes) -> None:
|
||||
super().__init__(*outcomes)
|
||||
self.complete_calls: list[tuple[int, int]] = []
|
||||
self.published_frames: list[tuple[str, dict]] = []
|
||||
#: When set, the next ``complete_instance`` raises this instead
|
||||
# of succeeding (a typed NestQuestApiError in production).
|
||||
self.fail_complete_with: BaseException | None = None
|
||||
|
||||
async def complete_instance(
|
||||
self, instance_id: int, actor_child_id: int
|
||||
) -> dict:
|
||||
self.complete_calls.append((instance_id, actor_child_id))
|
||||
if self.fail_complete_with is not None:
|
||||
raise self.fail_complete_with
|
||||
return {"status": "done"}
|
||||
|
||||
|
||||
def set_coordinator_client(entry, client):
|
||||
"""Register a scripted client for ``entry``'s coordinator factory."""
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
|
||||
coordinator_module._COORDINATOR_CLIENT_OVERRIDES[entry.entry_id] = client
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def coordinator_client_from_test_override():
|
||||
"""Resolve every entry's coordinator client through the test seam.
|
||||
|
||||
The integration's setup builds its API client through
|
||||
``coordinator_client_from_entry``; this fixture swaps that factory
|
||||
for a test one handing out the client registered with
|
||||
:func:`set_coordinator_client`, or — for an entry with no scripted
|
||||
client — a stub serving an EMPTY but valid household snapshot (the
|
||||
shape the API's panel route returns before any child exists; the
|
||||
real client would need aiohttp, which this mock-only harness does
|
||||
not provide).
|
||||
"""
|
||||
import datetime
|
||||
import unittest.mock as mock
|
||||
|
||||
import custom_components.nestquest as nq_mod
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
|
||||
_COORDINATOR_CLIENT_OVERRIDES = (
|
||||
coordinator_module._COORDINATOR_CLIENT_OVERRIDES
|
||||
)
|
||||
|
||||
def _factory(hass, entry, **kwargs):
|
||||
override = _COORDINATOR_CLIENT_OVERRIDES.get(entry.entry_id)
|
||||
if override is not None:
|
||||
return override
|
||||
return StubSnapshotClient(
|
||||
{
|
||||
"today_iso": datetime.date.today().isoformat(),
|
||||
"cycle_day": 0,
|
||||
"children": [],
|
||||
}
|
||||
)
|
||||
|
||||
_COORDINATOR_CLIENT_OVERRIDES.clear()
|
||||
patcher = mock.patch.object(
|
||||
nq_mod, "coordinator_client_from_entry", _factory
|
||||
)
|
||||
patcher.start()
|
||||
yield
|
||||
patcher.stop()
|
||||
_COORDINATOR_CLIENT_OVERRIDES.clear()
|
||||
|
||||
|
||||
async def refire_api_transitions(hass, entry, client):
|
||||
"""Deliver the SSE frames the stand-in client has published so far
|
||||
through the integration's SSE subscription — the production event
|
||||
path.
|
||||
|
||||
Since Feature 18 the ``complete_quest`` service fires no local
|
||||
bus event: the API service builds and publishes the transition
|
||||
frames on its SSE stream, and the integration's subscription
|
||||
(:mod:`custom_components.nestquest.sse`) re-fires each frame on
|
||||
the bus. This helper streams the frames the stand-in client
|
||||
published (``client.published_frames``) through a real
|
||||
:class:`~custom_components.nestquest.sse.NestQuestEventStream`
|
||||
and waits (bounded) until the bus has re-fired every one of them.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from custom_components.nestquest.sse import NestQuestEventStream
|
||||
|
||||
frames, client.published_frames = client.published_frames, []
|
||||
|
||||
class _ScriptedStream:
|
||||
"""A healthy stream: the frames, then it stays open."""
|
||||
|
||||
def __init__(self, frames) -> None:
|
||||
self._frames = frames
|
||||
|
||||
def stream_events(self):
|
||||
async def _gen():
|
||||
for frame in self._frames:
|
||||
yield frame
|
||||
await asyncio.Event().wait()
|
||||
|
||||
return _gen()
|
||||
|
||||
before = {etype: len(hass.bus.fired(etype)) for etype, _ in frames}
|
||||
expected = len(frames)
|
||||
manager = NestQuestEventStream(hass, _ScriptedStream(frames))
|
||||
manager.start()
|
||||
try:
|
||||
async with asyncio.timeout(2):
|
||||
while True:
|
||||
delivered = sum(
|
||||
len(hass.bus.fired(etype)) - before[etype]
|
||||
for etype, _ in frames
|
||||
)
|
||||
if delivered >= expected:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
finally:
|
||||
await manager.stop()
|
||||
return frames
|
||||
|
||||
|
||||
class _HassNamespace(SimpleNamespace):
|
||||
"""SimpleNamespace subclass so the hass stub can hold weak references."""
|
||||
|
||||
@@ -1132,6 +923,42 @@ def make_config_flow_entry():
|
||||
return _factory
|
||||
|
||||
|
||||
def freeze_nestquest_clock(
|
||||
monkeypatch, instant: datetime.datetime
|
||||
) -> tuple[datetime.datetime, list]:
|
||||
"""Freeze the integration's ``datetime`` clock to a fixed aware instant.
|
||||
|
||||
Only ``custom_components.nestquest``'s ``datetime`` binding is patched,
|
||||
so the generation path's ``datetime.datetime.now`` reads this instant
|
||||
(localized to whatever time zone it asks for) while every other module
|
||||
and the test harness keep the real clock. Returns ``(frozen, requested)``
|
||||
where ``frozen`` is the instant normalized to UTC and ``requested`` is
|
||||
the list of ``tzinfo`` objects the code under test passed to ``now`` —
|
||||
so a caller can prove the generation path actually requested the
|
||||
configured HA time zone rather than silently reading UTC.
|
||||
"""
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
frozen = instant.astimezone(datetime.timezone.utc)
|
||||
requested: list = []
|
||||
|
||||
class _FrozenDatetime(datetime.datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
requested.append(tz)
|
||||
if tz is None:
|
||||
return frozen.replace(tzinfo=None)
|
||||
return frozen.astimezone(tz)
|
||||
|
||||
fake_module = SimpleNamespace(
|
||||
datetime=_FrozenDatetime,
|
||||
timedelta=datetime.timedelta,
|
||||
date=datetime.date,
|
||||
)
|
||||
monkeypatch.setattr(nestquest, "datetime", fake_module)
|
||||
return frozen, requested
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin-plane API fixtures (shared by the admin-plane test modules).
|
||||
#
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
"""Tests for the Feature 03 admin allowlist business layer (core.admin_allowlist)."""
|
||||
"""Tests for admin_allowlist.py: the Feature 03 allowlist business layer."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.admin_allowlist import (
|
||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||
from custom_components.nestquest.admin_allowlist import (
|
||||
list_admin_ids,
|
||||
seed_setup_admin,
|
||||
set_admin_ids,
|
||||
)
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.dao_children import AdminUsersDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -190,6 +193,165 @@ def test_seed_rejects_malformed_candidates(tmp_path) -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setup wiring: async_setup_entry seeds automatically
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _setup_entry(hass, entry, registry):
|
||||
from tests.test_lifecycle import _wire
|
||||
|
||||
entry = _wire(entry, registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
return entry
|
||||
|
||||
|
||||
async def test_setup_seeds_allowlist_from_entry_context(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry = await _setup_entry(
|
||||
hass,
|
||||
make_entry(context={"user_id": "ha-owner"}),
|
||||
hass.registry,
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
try:
|
||||
assert await list_admin_ids(database) == ["ha-owner"]
|
||||
finally:
|
||||
await database.close()
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
|
||||
|
||||
async def test_setup_seeds_allowlist_from_stored_entry_data(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A lost database re-seeded from the entry's stored admin copy."""
|
||||
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
|
||||
|
||||
entry = await _setup_entry(
|
||||
hass,
|
||||
make_entry(data={CONF_ADMIN_USER_IDS: ["user-a", "user-b"]}),
|
||||
hass.registry,
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
try:
|
||||
assert await list_admin_ids(database) == ["user-a", "user-b"]
|
||||
finally:
|
||||
await database.close()
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
|
||||
|
||||
async def test_setup_without_context_leaves_allowlist_empty(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Fail closed: no context user and no stored copy means an empty
|
||||
allowlist — nobody is admin until the owner configures it."""
|
||||
entry = await _setup_entry(hass, make_entry(), hass.registry)
|
||||
database = entry.runtime_data.database
|
||||
try:
|
||||
assert await list_admin_ids(database) == []
|
||||
finally:
|
||||
await database.close()
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
|
||||
|
||||
async def test_setup_does_not_reseed_narrowed_allowlist(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
from custom_components.nestquest import async_unload_entry
|
||||
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
|
||||
|
||||
entry = await _setup_entry(
|
||||
hass, make_entry(context={"user_id": "ha-owner"}), hass.registry
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
await set_admin_ids(database, ["user-parent"])
|
||||
await database.close()
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
|
||||
# Reload with the SAME context: the narrowed list survives.
|
||||
entry_reloaded = await _setup_entry(
|
||||
hass, make_entry(context={"user_id": "ha-owner"}), hass.registry
|
||||
)
|
||||
database_b = entry_reloaded.runtime_data.database
|
||||
try:
|
||||
assert await list_admin_ids(database_b) == ["user-parent"]
|
||||
finally:
|
||||
await database.close()
|
||||
assert await async_unload_entry(hass, entry_reloaded) is True
|
||||
|
||||
|
||||
async def test_setup_seed_prefers_options_over_entry_data(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Seed precedence on a fresh database: the options flow's saved
|
||||
list (the current one) beats the config flow's original copy."""
|
||||
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
|
||||
|
||||
entry = await _setup_entry(
|
||||
hass,
|
||||
make_entry(
|
||||
options={CONF_ADMIN_USER_IDS: ["user-current"]},
|
||||
data={CONF_ADMIN_USER_IDS: ["ha-owner"]},
|
||||
context={"user_id": "ha-owner"},
|
||||
),
|
||||
hass.registry,
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
try:
|
||||
assert await list_admin_ids(database) == ["user-current"]
|
||||
finally:
|
||||
await database.close()
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
|
||||
|
||||
async def test_setup_owner_fallback_seeds_owner_accounts(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Last-resort seed: with no persisted copy and no flow-context
|
||||
user, HA owner accounts are seeded so first setup is never
|
||||
ownerless (the real flow may lose the context; owners can already
|
||||
do everything in HA)."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
hass.auth.async_get_users = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(id="owner-1", is_owner=True),
|
||||
SimpleNamespace(id="member-1", is_owner=False),
|
||||
SimpleNamespace(id="owner-2", is_owner=True),
|
||||
]
|
||||
)
|
||||
entry = await _setup_entry(hass, make_entry(), hass.registry)
|
||||
database = entry.runtime_data.database
|
||||
try:
|
||||
assert sorted(await list_admin_ids(database)) == ["owner-1", "owner-2"]
|
||||
finally:
|
||||
await database.close()
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
|
||||
|
||||
async def test_setup_owner_fallback_not_used_when_context_present(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The context user (flow caller) wins over the owner fallback:
|
||||
a non-owner parent configuring the integration becomes the admin."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
hass.auth.async_get_users = AsyncMock(
|
||||
return_value=[SimpleNamespace(id="owner-1", is_owner=True)]
|
||||
)
|
||||
entry = await _setup_entry(
|
||||
hass,
|
||||
make_entry(context={"user_id": "parent-configured"}),
|
||||
hass.registry,
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
try:
|
||||
assert await list_admin_ids(database) == ["parent-configured"]
|
||||
finally:
|
||||
await database.close()
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
|
||||
|
||||
def test_seed_owner_ids_argument(tmp_path) -> None:
|
||||
async def _body(database):
|
||||
# Malformed owner lists are rejected like any other candidate —
|
||||
@@ -215,13 +377,54 @@ def test_seed_owner_ids_argument(tmp_path) -> None:
|
||||
_with_db(tmp_path, "seed-owner-arg.db")(_body)
|
||||
|
||||
|
||||
async def test_reopened_setup_failure_closes_database(
|
||||
hass, make_entry, monkeypatch
|
||||
) -> None:
|
||||
"""A seeding failure on the reopened-record path closes the just-
|
||||
opened connection before re-raising (no leaked handles across HA's
|
||||
setup retries)."""
|
||||
import pytest as pytest_module
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import custom_components.nestquest as nestquest_module
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
|
||||
# First: a successful setup to create the runtime record and file.
|
||||
entry = await _setup_entry(hass, make_entry(), hass.registry)
|
||||
await async_unload_entry(hass, entry)
|
||||
|
||||
# Now a re-setup whose seeding fails after the reopen.
|
||||
async def _failing_seed(database, **kwargs):
|
||||
raise RuntimeError("seed boom")
|
||||
|
||||
monkeypatch.setattr(
|
||||
nestquest_module, "seed_setup_admin", _failing_seed, raising=True
|
||||
)
|
||||
|
||||
close_calls: list[bool] = []
|
||||
original_close = NestQuestDatabase.close
|
||||
|
||||
async def _spy_close(self):
|
||||
close_calls.append(True)
|
||||
return await original_close(self)
|
||||
|
||||
monkeypatch.setattr(NestQuestDatabase, "close", _spy_close)
|
||||
|
||||
with pytest_module.raises(RuntimeError, match="seed boom"):
|
||||
await _setup_entry(hass, entry, hass.registry)
|
||||
# The reopen path opened AND closed a connection; the failure is
|
||||
# the last thing observed.
|
||||
assert close_calls[-1] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_admin: the fail-closed resolver (Feature 09's verdict function)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_admin_verdict_matrix(tmp_path) -> None:
|
||||
async def _body(database):
|
||||
from custom_components.nestquest.core.admin_allowlist import is_admin
|
||||
from custom_components.nestquest.admin_allowlist import is_admin
|
||||
|
||||
await set_admin_ids(database, ["user-owner"])
|
||||
assert await is_admin(database, "user-owner") is True
|
||||
@@ -244,7 +447,7 @@ def test_is_admin_verdict_matrix(tmp_path) -> None:
|
||||
def test_is_admin_empty_allowlist_fails_closed(tmp_path) -> None:
|
||||
"""An empty allowlist returns False for EVERYONE, not True."""
|
||||
async def _body(database):
|
||||
from custom_components.nestquest.core.admin_allowlist import is_admin
|
||||
from custom_components.nestquest.admin_allowlist import is_admin
|
||||
|
||||
for candidate in ("user-owner", "", None, 1):
|
||||
assert await is_admin(database, candidate) is False
|
||||
@@ -257,7 +460,7 @@ def test_is_admin_false_after_last_admin_removal_attempt(tmp_path) -> None:
|
||||
"""The refusal keeps the verdict sound: a refused removal leaves
|
||||
the admin in place, so is_admin still resolves True."""
|
||||
async def _body(database):
|
||||
from custom_components.nestquest.core.admin_allowlist import is_admin
|
||||
from custom_components.nestquest.admin_allowlist import is_admin
|
||||
|
||||
await set_admin_ids(database, ["user-owner"])
|
||||
with pytest.raises(ValueError, match="at least one admin"):
|
||||
|
||||
@@ -1,547 +0,0 @@
|
||||
"""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
|
||||
@@ -1,398 +0,0 @@
|
||||
"""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
|
||||
@@ -1,450 +0,0 @@
|
||||
"""Tests for the panel-plane API client (Feature 18).
|
||||
|
||||
The client is exercised against a STUB transport only — no network, no
|
||||
aiohttp, no homeassistant: the injected session is a fake that speaks
|
||||
the same ``request(...)`` surface aiohttp's ``ClientSession`` exposes
|
||||
and records every call, so the tests assert exactly what the client
|
||||
puts on the wire (method, URL, Bearer header, JSON body) and how every
|
||||
failure mode (non-2xx, connection failure, timeout, malformed JSON)
|
||||
surfaces as the typed :class:`NestQuestApiError`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import make_config_entry
|
||||
|
||||
from custom_components.nestquest import api_client
|
||||
from custom_components.nestquest.api_client import (
|
||||
NestQuestApiClient,
|
||||
NestQuestApiError,
|
||||
is_valid_base_url,
|
||||
resolve_api_config,
|
||||
)
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_API_BASE_URL,
|
||||
CONF_PANEL_TOKEN,
|
||||
DEFAULT_API_BASE_URL,
|
||||
DEFAULT_PANEL_TOKEN,
|
||||
)
|
||||
|
||||
BASE_URL = "http://api.test:8000"
|
||||
TOKEN = "test-panel-token"
|
||||
|
||||
#: A realistic panel snapshot payload (the documented shape of the
|
||||
#: Feature 16 snapshot route).
|
||||
SNAPSHOT_PAYLOAD = {
|
||||
"today_iso": "2026-09-23",
|
||||
"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": None,
|
||||
"window": "morning",
|
||||
"due_time": "10:00",
|
||||
"state": "open",
|
||||
"overdue": True,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class StubResponse:
|
||||
"""A fake aiohttp response: ``status`` plus ``await text()``."""
|
||||
|
||||
def __init__(self, status: int, body: str) -> None:
|
||||
self.status = status
|
||||
self._body = body
|
||||
|
||||
async def text(self) -> str:
|
||||
return self._body
|
||||
|
||||
|
||||
class _OutcomeContext:
|
||||
"""Async context manager yielding one scripted outcome.
|
||||
|
||||
Mirrors aiohttp's ``session.request(...)`` return value: entering
|
||||
the context performs the request, so a scripted transport error
|
||||
raises THERE — before any response object exists, exactly where
|
||||
aiohttp's connector raises on a refused connection.
|
||||
"""
|
||||
|
||||
def __init__(self, response, error) -> None:
|
||||
self._response = response
|
||||
self._error = error
|
||||
|
||||
async def __aenter__(self):
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._response
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class StubTransport:
|
||||
"""A no-network stand-in for aiohttp's ``ClientSession``.
|
||||
|
||||
Records every ``request(...)`` call as a dict (method, url, headers,
|
||||
json body) and answers from a scripted queue of outcomes — a
|
||||
:class:`StubResponse` or an exception per call, consumed in order —
|
||||
so each test drives exactly one exchange.
|
||||
"""
|
||||
|
||||
def __init__(self, *outcomes) -> None:
|
||||
self.calls: list[dict] = []
|
||||
self._outcomes = list(outcomes)
|
||||
|
||||
def request(self, method: str, url: str, **kwargs):
|
||||
self.calls.append(
|
||||
{
|
||||
"method": method,
|
||||
"url": url,
|
||||
"headers": kwargs.get("headers"),
|
||||
"json": kwargs.get("json"),
|
||||
}
|
||||
)
|
||||
if not self._outcomes:
|
||||
raise AssertionError("StubTransport ran out of scripted outcomes")
|
||||
outcome = self._outcomes.pop(0)
|
||||
if isinstance(outcome, BaseException):
|
||||
return _OutcomeContext(None, outcome)
|
||||
return _OutcomeContext(outcome, None)
|
||||
|
||||
|
||||
class HangingTransport:
|
||||
"""A transport whose request never answers (for the timeout test)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def request(self, method: str, url: str, **kwargs):
|
||||
self.calls.append({"method": method, "url": url})
|
||||
return _HangingContext()
|
||||
|
||||
|
||||
class _HangingContext:
|
||||
async def __aenter__(self):
|
||||
await asyncio.sleep(5)
|
||||
raise AssertionError("hanging transport was never cancelled")
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def make_client(transport, *, timeout_seconds: float = 5.0) -> NestQuestApiClient:
|
||||
"""Build a client over the stub transport with the test credentials."""
|
||||
return NestQuestApiClient(
|
||||
BASE_URL, TOKEN, transport, timeout_seconds=timeout_seconds
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Success paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_get_snapshot_parses_payload_and_sends_bearer() -> None:
|
||||
transport = StubTransport(
|
||||
StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD))
|
||||
)
|
||||
client = make_client(transport)
|
||||
|
||||
snapshot = await client.get_snapshot()
|
||||
|
||||
assert snapshot == SNAPSHOT_PAYLOAD
|
||||
call = transport.calls[0]
|
||||
assert call["method"] == "GET"
|
||||
assert call["url"] == f"{BASE_URL}/api/v1/panel/snapshot"
|
||||
assert call["headers"] == {"Authorization": f"Bearer {TOKEN}"}
|
||||
assert call["json"] is None
|
||||
|
||||
|
||||
async def test_complete_instance_posts_body_and_headers() -> None:
|
||||
transport = StubTransport(StubResponse(200, '{"status": "done"}'))
|
||||
client = make_client(transport)
|
||||
|
||||
result = await client.complete_instance(42, 3)
|
||||
|
||||
assert result == {"status": "done"}
|
||||
call = transport.calls[0]
|
||||
assert call["method"] == "POST"
|
||||
assert call["url"] == f"{BASE_URL}/api/v1/panel/instances/42/complete"
|
||||
assert call["json"] == {"actor_child_id": 3}
|
||||
assert call["headers"]["Authorization"] == f"Bearer {TOKEN}"
|
||||
|
||||
|
||||
async def test_base_url_trailing_slash_is_normalized() -> None:
|
||||
"""A base URL with a trailing slash must not double the path slash."""
|
||||
transport = StubTransport(StubResponse(200, "{}"))
|
||||
client = NestQuestApiClient(
|
||||
f"{BASE_URL}/", TOKEN, transport, timeout_seconds=5.0
|
||||
)
|
||||
await client.get_snapshot()
|
||||
assert transport.calls[0]["url"] == f"{BASE_URL}/api/v1/panel/snapshot"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP failures: the typed error carries the status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_snapshot_401_raises_typed_error() -> None:
|
||||
"""A wrong/missing token answers 401 and surfaces with that status."""
|
||||
transport = StubTransport(
|
||||
StubResponse(401, '{"detail": "Missing or invalid panel service token"}')
|
||||
)
|
||||
client = make_client(transport)
|
||||
|
||||
with pytest.raises(NestQuestApiError) as excinfo:
|
||||
await client.get_snapshot()
|
||||
|
||||
error = excinfo.value
|
||||
assert error.status == 401
|
||||
assert not error.is_transport_error
|
||||
assert error.__cause__ is None
|
||||
# The token is a secret: it never reaches the raised message.
|
||||
assert TOKEN not in str(error)
|
||||
|
||||
|
||||
async def test_complete_unknown_instance_404_raises_typed_error() -> None:
|
||||
transport = StubTransport(
|
||||
StubResponse(404, '{"detail": "Quest instance not found"}')
|
||||
)
|
||||
client = make_client(transport)
|
||||
|
||||
with pytest.raises(NestQuestApiError) as excinfo:
|
||||
await client.complete_instance(999999, 1)
|
||||
|
||||
assert excinfo.value.status == 404
|
||||
assert not excinfo.value.is_transport_error
|
||||
|
||||
|
||||
async def test_snapshot_500_raises_typed_error() -> None:
|
||||
"""Any non-2xx response is typed, whatever the status."""
|
||||
transport = StubTransport(StubResponse(500, "{}"))
|
||||
client = make_client(transport)
|
||||
|
||||
with pytest.raises(NestQuestApiError) as excinfo:
|
||||
await client.get_snapshot()
|
||||
|
||||
assert excinfo.value.status == 500
|
||||
|
||||
|
||||
async def test_malformed_2xx_body_raises_typed_with_status() -> None:
|
||||
"""A 2xx body that is not JSON is a protocol failure, carrying its status."""
|
||||
transport = StubTransport(StubResponse(200, "<html>not json</html>"))
|
||||
client = make_client(transport)
|
||||
|
||||
with pytest.raises(NestQuestApiError) as excinfo:
|
||||
await client.get_snapshot()
|
||||
|
||||
assert excinfo.value.status == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transport failures: the typed error carries no status and chains the cause
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_connection_failure_raises_transport_error() -> None:
|
||||
transport = StubTransport(OSError("connection refused"))
|
||||
client = make_client(transport)
|
||||
|
||||
with pytest.raises(NestQuestApiError) as excinfo:
|
||||
await client.get_snapshot()
|
||||
|
||||
error = excinfo.value
|
||||
assert error.status is None
|
||||
assert error.is_transport_error
|
||||
assert isinstance(error.__cause__, OSError)
|
||||
|
||||
|
||||
async def test_connection_failure_on_complete_is_typed() -> None:
|
||||
transport = StubTransport(OSError("connection refused"))
|
||||
client = make_client(transport)
|
||||
|
||||
with pytest.raises(NestQuestApiError) as excinfo:
|
||||
await client.complete_instance(7, 1)
|
||||
|
||||
assert excinfo.value.status is None
|
||||
assert excinfo.value.is_transport_error
|
||||
|
||||
|
||||
async def test_request_timeout_raises_transport_error() -> None:
|
||||
"""A hung API call surfaces as a typed transport error, once, with
|
||||
no retry: the one request is cancelled by the client's timeout."""
|
||||
transport = HangingTransport()
|
||||
client = make_client(transport, timeout_seconds=0.05)
|
||||
|
||||
with pytest.raises(NestQuestApiError) as excinfo:
|
||||
await client.get_snapshot()
|
||||
|
||||
assert excinfo.value.status is None
|
||||
assert excinfo.value.is_transport_error
|
||||
assert len(transport.calls) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Construction and config resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_constructor_requires_full_base_url() -> None:
|
||||
for bad_url in (None, "", " ", "api.lan:8000", "http://", "ftp://x"):
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestApiClient(bad_url, TOKEN, StubTransport())
|
||||
|
||||
|
||||
def test_constructor_requires_non_empty_token() -> None:
|
||||
for bad_token in (None, "", " "):
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestApiClient(BASE_URL, bad_token, StubTransport())
|
||||
|
||||
|
||||
def test_constructor_requires_positive_timeout() -> None:
|
||||
for bad_timeout in (0, -1, "5", True, None):
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestApiClient(
|
||||
BASE_URL, TOKEN, StubTransport(), timeout_seconds=bad_timeout
|
||||
)
|
||||
|
||||
|
||||
def test_is_valid_base_url_accepts_full_http_urls() -> None:
|
||||
assert is_valid_base_url("http://127.0.0.1:8000")
|
||||
assert is_valid_base_url("https://nestquest.lan")
|
||||
assert is_valid_base_url(" http://127.0.0.1:8000 ")
|
||||
assert not is_valid_base_url("api.lan:8000")
|
||||
assert not is_valid_base_url("http://")
|
||||
assert not is_valid_base_url("")
|
||||
assert not is_valid_base_url(None)
|
||||
assert not is_valid_base_url(42)
|
||||
|
||||
|
||||
def test_resolve_api_config_prefers_options_then_data_then_defaults() -> None:
|
||||
options_only = make_config_entry(
|
||||
options={CONF_API_BASE_URL: "http://from-options:1"}
|
||||
)
|
||||
assert resolve_api_config(options_only) == (
|
||||
"http://from-options:1",
|
||||
DEFAULT_PANEL_TOKEN,
|
||||
)
|
||||
|
||||
data_only = make_config_entry(
|
||||
data={
|
||||
CONF_API_BASE_URL: "http://from-data:2",
|
||||
CONF_PANEL_TOKEN: "from-data",
|
||||
}
|
||||
)
|
||||
assert resolve_api_config(data_only) == ("http://from-data:2", "from-data")
|
||||
|
||||
both = make_config_entry(
|
||||
options={CONF_API_BASE_URL: "http://from-options:3"},
|
||||
data={
|
||||
CONF_API_BASE_URL: "http://from-data:4",
|
||||
CONF_PANEL_TOKEN: "from-data",
|
||||
},
|
||||
)
|
||||
assert resolve_api_config(both) == (
|
||||
"http://from-options:3",
|
||||
"from-data",
|
||||
)
|
||||
|
||||
bare = make_config_entry()
|
||||
assert resolve_api_config(bare) == (DEFAULT_API_BASE_URL, DEFAULT_PANEL_TOKEN)
|
||||
|
||||
|
||||
def test_client_from_entry_reads_config_and_shared_session(monkeypatch) -> None:
|
||||
"""The production factory resolves the entry config and hands in HA's
|
||||
shared aiohttp session (stubbed module — no real HA, no network)."""
|
||||
fake_module = types.ModuleType("homeassistant.helpers.aiohttp_client")
|
||||
shared_session = object()
|
||||
fake_module.async_get_clientsession = lambda hass: shared_session
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "homeassistant.helpers.aiohttp_client", fake_module
|
||||
)
|
||||
entry = make_config_entry(
|
||||
options={
|
||||
CONF_API_BASE_URL: "http://panel.lan:8443",
|
||||
CONF_PANEL_TOKEN: "entry-token",
|
||||
}
|
||||
)
|
||||
|
||||
client = api_client.client_from_entry(object(), entry)
|
||||
|
||||
assert client._session is shared_session
|
||||
assert client._base_url == "http://panel.lan:8443"
|
||||
assert client._panel_token == "entry-token"
|
||||
|
||||
|
||||
def test_client_from_entry_unconfigured_token_raises_at_construction(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""An entry with NO configured token (the shipped default) raises
|
||||
ValueError AT CONSTRUCTION — the client's fail-fast guard — rather
|
||||
than silently building a client that can only 401 (the documented
|
||||
contract: the options flow's non-empty validation is the user-facing
|
||||
guard; the coordinator must ensure a token is configured first or
|
||||
handle the construction error as "API not configured")."""
|
||||
fake_module = types.ModuleType("homeassistant.helpers.aiohttp_client")
|
||||
fake_module.async_get_clientsession = lambda hass: object()
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "homeassistant.helpers.aiohttp_client", fake_module
|
||||
)
|
||||
entry = make_config_entry(
|
||||
options={CONF_API_BASE_URL: DEFAULT_API_BASE_URL},
|
||||
)
|
||||
assert resolve_api_config(entry)[1] == DEFAULT_PANEL_TOKEN == ""
|
||||
|
||||
with pytest.raises(ValueError, match="panel_token must be a non-empty"):
|
||||
api_client.client_from_entry(object(), entry)
|
||||
|
||||
# Same for a whitespace-only stored token: whitespace is not a token.
|
||||
entry_ws = make_config_entry(options={CONF_PANEL_TOKEN: " "})
|
||||
with pytest.raises(ValueError, match="panel_token must be a non-empty"):
|
||||
api_client.client_from_entry(object(), entry_ws)
|
||||
|
||||
|
||||
async def test_client_logs_nothing() -> None:
|
||||
"""The client emits no log records at all (the token must never
|
||||
reach a log line), success or failure."""
|
||||
import logging
|
||||
|
||||
records: list[logging.LogRecord] = []
|
||||
|
||||
class _Capture(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
records.append(record)
|
||||
|
||||
handler = _Capture()
|
||||
logging.getLogger().addHandler(handler)
|
||||
try:
|
||||
transport = StubTransport(StubResponse(401, "{}"))
|
||||
client = make_client(transport)
|
||||
with pytest.raises(NestQuestApiError):
|
||||
await client.get_snapshot()
|
||||
finally:
|
||||
logging.getLogger().removeHandler(handler)
|
||||
assert records == []
|
||||
@@ -1,671 +0,0 @@
|
||||
"""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()
|
||||
+123
-134
@@ -1,85 +1,50 @@
|
||||
"""Tests for the per-child binary sensors (Feature 10, task 4).
|
||||
|
||||
The snapshot comes from the API service's panel route (the presence
|
||||
engine resolves server-side), so these tests script the route's
|
||||
payload through :class:`conftest.StubSnapshotClient` instead of
|
||||
seeding a local database.
|
||||
"""
|
||||
"""Tests for the per-child binary sensors (Feature 10, task 4)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry
|
||||
import datetime
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.children import create_child, list_children
|
||||
from custom_components.nestquest.const import DOMAIN
|
||||
from custom_components.nestquest.dao_presence import 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
|
||||
from custom_components.nestquest.completion import complete_instance
|
||||
|
||||
SEED_HORIZON_DAYS = 3
|
||||
|
||||
|
||||
def _binary(hass, child_id: int, kind: str):
|
||||
return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"]
|
||||
|
||||
|
||||
def _child(
|
||||
child_id: int,
|
||||
name: str,
|
||||
*,
|
||||
present=True,
|
||||
next_present=None,
|
||||
instances=(),
|
||||
):
|
||||
completed = sum(1 for i in instances if i["state"] == "completed")
|
||||
due = len(instances)
|
||||
return {
|
||||
"child_id": child_id,
|
||||
"child_name": name,
|
||||
"present": present,
|
||||
"next_present": next_present,
|
||||
"due_today": due,
|
||||
"completed_today": completed,
|
||||
"remaining_today": due - completed,
|
||||
"completion_pct": 100 if due == 0 else round(completed / due * 100),
|
||||
"instances": list(instances),
|
||||
}
|
||||
|
||||
|
||||
def _instance(instance_id: int, child_id: int, *, state="open") -> dict:
|
||||
return {
|
||||
"id": instance_id,
|
||||
"definition_id": 3,
|
||||
"child_id": child_id,
|
||||
"title": "Brush teeth",
|
||||
"icon": "🦷",
|
||||
"window": "morning",
|
||||
"due_time": "08:00",
|
||||
"state": state,
|
||||
"overdue": False,
|
||||
"completed_at": None if state == "open" else "2026-09-23T07:55:00+00:00",
|
||||
"on_time": None if state == "open" else True,
|
||||
}
|
||||
|
||||
|
||||
def _payload(children) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
ADA = 1
|
||||
BO = 2
|
||||
CORY = 3
|
||||
|
||||
|
||||
async def _setup_entry(hass, make_entry, *payloads):
|
||||
"""Set up the integration with a scripted snapshot client."""
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(
|
||||
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
|
||||
),
|
||||
hass.registry,
|
||||
)
|
||||
set_coordinator_client(entry, StubSnapshotClient(*payloads))
|
||||
async def _setup_entry(hass, make_entry):
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
return entry, entry.runtime_data.coordinator
|
||||
return entry
|
||||
|
||||
|
||||
async def _seed_two_children(database) -> tuple:
|
||||
ada = await create_child(database, "Ada")
|
||||
bo = await create_child(database, "Bo")
|
||||
today = datetime.date.today()
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today.isoformat()}
|
||||
),
|
||||
[ada.id, bo.id],
|
||||
["morning"],
|
||||
)
|
||||
end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat()
|
||||
await materialize(database, today.isoformat(), end, today=today)
|
||||
return ada, bo
|
||||
|
||||
|
||||
async def test_all_done_off_with_open_quests_and_zero_quests(
|
||||
@@ -87,76 +52,88 @@ async def test_all_done_off_with_open_quests_and_zero_quests(
|
||||
) -> None:
|
||||
"""all_done is OFF while quests are owed AND on a zero-quest day;
|
||||
a child with no quests never fires the celebration automation."""
|
||||
_entry, coordinator = await _setup_entry(
|
||||
hass,
|
||||
make_entry,
|
||||
_payload(
|
||||
[
|
||||
_child(ADA, "Ada", instances=[_instance(7, ADA)]),
|
||||
_child(BO, "Bo", instances=[_instance(8, BO)]),
|
||||
_child(CORY, "Cory"),
|
||||
]
|
||||
),
|
||||
)
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await create_child(database, "Cory") # zero quests
|
||||
await coordinator.async_refresh()
|
||||
|
||||
assert _binary(hass, ADA, "all_done").is_on is False
|
||||
assert _binary(hass, BO, "all_done").is_on is False
|
||||
assert _binary(hass, CORY, "all_done").is_on is False
|
||||
assert _binary(hass, ada.id, "all_done").is_on is False
|
||||
cory = [
|
||||
child for child in coordinator.data.children
|
||||
if child.child_name == "Cory"
|
||||
][0]
|
||||
assert _binary(hass, cory.child_id, "all_done").is_on is False
|
||||
|
||||
|
||||
async def test_all_done_on_only_when_day_clear(hass, make_entry) -> None:
|
||||
_entry, coordinator = await _setup_entry(
|
||||
hass,
|
||||
make_entry,
|
||||
_payload([_child(ADA, "Ada", instances=[_instance(7, ADA)])]),
|
||||
_payload(
|
||||
[
|
||||
_child(
|
||||
ADA,
|
||||
"Ada",
|
||||
instances=[_instance(7, ADA, state="completed")],
|
||||
)
|
||||
]
|
||||
),
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await coordinator.async_refresh()
|
||||
snapshot_ada = [
|
||||
child for child in coordinator.data.children
|
||||
if child.child_id == ada.id
|
||||
][0]
|
||||
instance_id = snapshot_ada.instances[0].instance_id
|
||||
await complete_instance(
|
||||
database,
|
||||
instance_id,
|
||||
actor_source="panel",
|
||||
actor_child_id=ada.id,
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
assert _binary(hass, ADA, "all_done").is_on is True
|
||||
assert _binary(hass, ada.id, "all_done").is_on is True
|
||||
|
||||
|
||||
async def test_present_today_on_without_schedule_off_when_absent(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Presence is resolved by the API's presence engine: present=True
|
||||
renders ON, present=False renders OFF."""
|
||||
_entry, _coordinator = await _setup_entry(
|
||||
hass,
|
||||
make_entry,
|
||||
_payload([_child(ADA, "Ada"), _child(CORY, "Cory", present=False)]),
|
||||
"""No schedule means present every day; an all-absent pattern
|
||||
today reports OFF."""
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await create_child(database, "Cory")
|
||||
today = datetime.date.today()
|
||||
await coordinator.async_refresh()
|
||||
cory = [
|
||||
child for child in coordinator.data.children
|
||||
if child.child_name == "Cory"
|
||||
][0]
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
cory.child_id, 1, today.isoformat(), ""
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
assert _binary(hass, ADA, "present_today").is_on is True
|
||||
assert _binary(hass, CORY, "present_today").is_on is False
|
||||
assert _binary(hass, ada.id, "present_today").is_on is True
|
||||
assert _binary(hass, cory.child_id, "present_today").is_on is False
|
||||
|
||||
|
||||
async def test_binary_sensor_identity_and_attributes(hass, make_entry) -> None:
|
||||
_entry, _coordinator = await _setup_entry(
|
||||
hass, make_entry, _payload([_child(ADA, "Ada")])
|
||||
)
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
all_done = _binary(hass, ADA, "all_done")
|
||||
present = _binary(hass, ADA, "present_today")
|
||||
assert all_done.unique_id == f"nestquest_child_{ADA}_all_done"
|
||||
assert present.unique_id == f"nestquest_child_{ADA}_present_today"
|
||||
all_done = _binary(hass, ada.id, "all_done")
|
||||
present = _binary(hass, ada.id, "present_today")
|
||||
assert all_done.unique_id == f"nestquest_child_{ada.id}_all_done"
|
||||
assert present.unique_id == f"nestquest_child_{ada.id}_present_today"
|
||||
assert all_done.name == "NestQuest Ada all done"
|
||||
assert present.name == "NestQuest Ada present today"
|
||||
for entity in (all_done, present):
|
||||
assert entity.device_info.name == "NestQuest Ada"
|
||||
assert all_done.extra_state_attributes == {
|
||||
"child_id": ADA,
|
||||
"child_id": ada.id,
|
||||
"child_name": "Ada",
|
||||
}
|
||||
assert present.extra_state_attributes == {
|
||||
"child_id": ADA,
|
||||
"child_id": ada.id,
|
||||
"child_name": "Ada",
|
||||
"next_present": None,
|
||||
}
|
||||
@@ -168,28 +145,40 @@ async def test_present_today_carries_next_present_when_away(
|
||||
"""A child away today carries the ISO date of their next present
|
||||
day (the panel's away plate renders ``Returns <weekday>, <Mon D>``
|
||||
from it); an always-absent pattern carries ``None``."""
|
||||
_entry, _coordinator = await _setup_entry(
|
||||
hass,
|
||||
make_entry,
|
||||
_payload(
|
||||
[
|
||||
_child(
|
||||
ADA,
|
||||
"Ada",
|
||||
present=False,
|
||||
next_present="2026-09-24",
|
||||
),
|
||||
_child(CORY, "Cory", present=False, next_present=None),
|
||||
]
|
||||
),
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await create_child(database, "Cory")
|
||||
today = datetime.date.today()
|
||||
pattern = ",".join(
|
||||
str(day) for day in range(7) if day != today.weekday()
|
||||
)
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
ada.id, 1, today.isoformat(), pattern
|
||||
)
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
await _cory_id(database), 1, today.isoformat(), ""
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
assert _binary(hass, ADA, "present_today").is_on is False
|
||||
assert _binary(hass, ADA, "present_today").extra_state_attributes[
|
||||
assert _binary(hass, ada.id, "present_today").is_on is False
|
||||
assert _binary(hass, ada.id, "present_today").extra_state_attributes[
|
||||
"next_present"
|
||||
] == "2026-09-24"
|
||||
] == (today + datetime.timedelta(days=1)).isoformat()
|
||||
|
||||
assert _binary(hass, CORY, "present_today").is_on is False
|
||||
assert _binary(hass, CORY, "present_today").extra_state_attributes[
|
||||
cory = [
|
||||
child for child in coordinator.data.children
|
||||
if child.child_name == "Cory"
|
||||
][0]
|
||||
assert _binary(hass, cory.child_id, "present_today").is_on is False
|
||||
assert _binary(hass, cory.child_id, "present_today").extra_state_attributes[
|
||||
"next_present"
|
||||
] is None
|
||||
|
||||
|
||||
async def _cory_id(database) -> int:
|
||||
for child in await list_children(database):
|
||||
if child.display_name == "Cory":
|
||||
return child.id
|
||||
raise AssertionError("Cory was not created")
|
||||
|
||||
@@ -8,16 +8,16 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.children import (
|
||||
from custom_components.nestquest.children import (
|
||||
create_child,
|
||||
edit_child,
|
||||
list_children,
|
||||
reorder_children,
|
||||
set_child_active,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_children import ChildRecord, ChildrenDao
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.dao_children import ChildRecord, ChildrenDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -739,12 +739,12 @@ def test_deactivating_child_preserves_definitions_instances_events(
|
||||
async def _body(database):
|
||||
from datetime import date, timedelta
|
||||
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_rules import (
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
QuestDefinitionsDao,
|
||||
ScheduleRulesDao,
|
||||
)
|
||||
@@ -831,7 +831,7 @@ def test_no_code_path_deletes_a_child_row() -> None:
|
||||
|
||||
import inspect
|
||||
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
|
||||
methods = {
|
||||
name
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
"""The ``nestquest.complete_quest`` proxy to the API client (Feature 18).
|
||||
|
||||
The service is a thin proxy: a panel tap must reach the API's panel
|
||||
complete route through the entry's ONE shared API client (the same
|
||||
instance the coordinator polls and the SSE subscription reads), with
|
||||
exactly the payload contract the panel cards send (``instance_id``,
|
||||
``actor=panel``, ``actor_child_id``). The service performs NO local
|
||||
completion and fires NO local bus event — the API service emits the
|
||||
transition on its SSE stream and the integration's subscription
|
||||
re-fires it (firing it locally too would double-fire) — and it
|
||||
refreshes the coordinator after a successful call so the sensors
|
||||
reflect the completion immediately. Every failure (a typed
|
||||
:class:`NestQuestApiError`, an unconfigured client, a non-panel actor)
|
||||
surfaces as a :class:`HomeAssistantError`, token-free.
|
||||
|
||||
The scripted clients here stand in for the API service: recording and
|
||||
failing ``complete_instance`` implementations, no network, no aiohttp.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from conftest import ScriptedPanelClient, set_coordinator_client, wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.api_client import NestQuestApiError
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_PANEL_TOKEN,
|
||||
DOMAIN,
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
EVENT_QUEST_COMPLETED,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
)
|
||||
|
||||
|
||||
class _RecordingApiClient(ScriptedPanelClient):
|
||||
"""Snapshot-only stand-in that RECORDS completion calls.
|
||||
|
||||
``complete_instance`` deliberately performs NO local write: the
|
||||
API service is remote, and the assertions below must be able to
|
||||
prove the service handler itself completed nothing locally.
|
||||
"""
|
||||
|
||||
|
||||
class _FailingApiClient(_RecordingApiClient):
|
||||
"""A stand-in whose completion fails the way the client types it."""
|
||||
|
||||
def __init__(self, error: NestQuestApiError, *outcomes) -> None:
|
||||
super().__init__(*outcomes)
|
||||
self.fail_complete_with = error
|
||||
|
||||
|
||||
def _empty_snapshot() -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"children": [],
|
||||
}
|
||||
|
||||
|
||||
async def _setup_entry(hass, make_entry, client):
|
||||
"""Wire, script, and set up an entry against the scripted client."""
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(data={CONF_PANEL_TOKEN: "secret-panel-token"}),
|
||||
hass.registry,
|
||||
)
|
||||
set_coordinator_client(entry, client)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
return entry
|
||||
|
||||
|
||||
def _record_refreshes(entry) -> list[bool]:
|
||||
"""Replace the coordinator's refresh with a recording stand-in."""
|
||||
refreshes: list[bool] = []
|
||||
|
||||
async def _fake_refresh():
|
||||
refreshes.append(True)
|
||||
|
||||
entry.runtime_data.coordinator.async_refresh = _fake_refresh
|
||||
return refreshes
|
||||
|
||||
|
||||
async def test_complete_quest_reaches_the_shared_api_client(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A panel tap calls ``complete_instance`` on the entry's shared
|
||||
client with the tapped child, fires NO local event, and refreshes
|
||||
the coordinator immediately."""
|
||||
client = _RecordingApiClient(_empty_snapshot())
|
||||
entry = await _setup_entry(hass, make_entry, client)
|
||||
assert entry.runtime_data.coordinator.api_client is client, (
|
||||
"the service must proxy through the entry's ONE shared client"
|
||||
)
|
||||
refreshes = _record_refreshes(entry)
|
||||
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": 7,
|
||||
"actor": "panel",
|
||||
"actor_child_id": 1,
|
||||
},
|
||||
)
|
||||
|
||||
assert client.complete_calls == [(7, 1)]
|
||||
# No local firing: the completed (and day-complete) events arrive
|
||||
# over the SSE stream, re-fired by the subscription — never from
|
||||
# this handler.
|
||||
assert hass.bus.fired(EVENT_QUEST_COMPLETED) == []
|
||||
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
||||
# The old immediate-update behaviour: the sensors' coordinator
|
||||
# refresh runs before the service call returns.
|
||||
assert refreshes == [True]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "fragment"),
|
||||
[
|
||||
(
|
||||
NestQuestApiError(
|
||||
"NestQuest API POST /api/v1/panel/instances/7/complete "
|
||||
"failed with HTTP 401",
|
||||
status=401,
|
||||
),
|
||||
"failed with HTTP 401",
|
||||
),
|
||||
(
|
||||
NestQuestApiError(
|
||||
"NestQuest API POST /api/v1/panel/instances/7/complete "
|
||||
"transport failure: connection refused",
|
||||
status=None,
|
||||
),
|
||||
"transport failure",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_api_failure_surfaces_as_a_service_error(
|
||||
hass, make_entry, error, fragment
|
||||
) -> None:
|
||||
"""A typed API failure (non-2xx or transport) raises
|
||||
``HomeAssistantError`` carrying the typed message — which never
|
||||
includes the panel token — with nothing fired locally, and no
|
||||
refresh for a mutation that never happened."""
|
||||
client = _FailingApiClient(error, _empty_snapshot())
|
||||
entry = await _setup_entry(hass, make_entry, client)
|
||||
refreshes = _record_refreshes(entry)
|
||||
|
||||
with pytest.raises(HomeAssistantError, match=fragment) as excinfo:
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": 7,
|
||||
"actor": "panel",
|
||||
"actor_child_id": 1,
|
||||
},
|
||||
)
|
||||
# The typed error is chained, and the service error carries the
|
||||
# typed message only — never the panel token.
|
||||
assert excinfo.value.__cause__ is error
|
||||
assert "secret-panel-token" not in str(excinfo.value)
|
||||
assert client.complete_calls == [(7, 1)]
|
||||
assert hass.bus.fired(EVENT_QUEST_COMPLETED) == []
|
||||
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
||||
assert refreshes == []
|
||||
|
||||
|
||||
async def test_unconfigured_api_client_surfaces_as_a_service_error(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""An entry with no completion surface (the unconfigured-token
|
||||
stub the coordinator swaps in) fails the service clearly instead
|
||||
of reaching an attribute error."""
|
||||
from custom_components.nestquest.coordinator import _UnconfiguredApiClient
|
||||
|
||||
await _setup_entry(hass, make_entry, _UnconfiguredApiClient())
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError, match="NestQuest API is not configured"
|
||||
):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": 7,
|
||||
"actor": "panel",
|
||||
"actor_child_id": 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def test_non_panel_actor_is_rejected_before_the_client(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The API's panel route completes as the tapped child only: a
|
||||
user-actor call fails fast with a clear error and never reaches
|
||||
the client."""
|
||||
client = _RecordingApiClient(_empty_snapshot())
|
||||
await _setup_entry(hass, make_entry, client)
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="actor must be 'panel'"):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": 7,
|
||||
"actor": "user",
|
||||
"actor_user_id": "admin-1",
|
||||
},
|
||||
context={"user_id": "admin-1"},
|
||||
)
|
||||
assert client.complete_calls == []
|
||||
@@ -8,27 +8,27 @@ import re
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.completion import (
|
||||
from custom_components.nestquest.completion import (
|
||||
append_event,
|
||||
complete_instance,
|
||||
instance_state,
|
||||
list_missed_for_child,
|
||||
uncomplete_instance,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
EVENT_COMPLETED,
|
||||
EVENT_UNCOMPLETED,
|
||||
CompletionEventRecord,
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_rules import (
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
QuestDefinitionsDao,
|
||||
ScheduleRulesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -311,7 +311,7 @@ def test_now_pins_occurred_at_utc(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_module_never_exposes_update_or_delete() -> None:
|
||||
import custom_components.nestquest.core.completion as completion
|
||||
import custom_components.nestquest.completion as completion
|
||||
|
||||
public = {
|
||||
name
|
||||
|
||||
@@ -156,13 +156,23 @@ def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None:
|
||||
f"Found hard-coded database filename in {py_file.name}:{node.lineno}"
|
||||
)
|
||||
|
||||
async def test_flow_persists_flow_user_as_initial_admin(hass, make_flow) -> None:
|
||||
"""The HA user completing the flow lands in entry data as the
|
||||
initial admin copy: entry.context is not persisted by real HA, so
|
||||
the durable record must carry the owner."""
|
||||
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
|
||||
|
||||
flow = make_flow(hass)
|
||||
flow.context = {"user_id": "ha-owner"}
|
||||
result = await flow.async_step_user({})
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"] == {CONF_ADMIN_USER_IDS: ["ha-owner"]}
|
||||
|
||||
|
||||
async def test_flow_without_context_user_keeps_data_untouched(
|
||||
hass, make_flow
|
||||
) -> None:
|
||||
"""A submit with user input stores it as-is; no admin copy is
|
||||
invented (the allowlist lives in the API service's own store since
|
||||
the DB removal)."""
|
||||
flow = make_flow(hass)
|
||||
result = await flow.async_step_user({"update_interval": 60})
|
||||
result = await flow.async_step_user({"horizon_days": 7})
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"] == {"update_interval": 60}
|
||||
assert result["data"] == {"horizon_days": 7}
|
||||
|
||||
+250
-702
File diff suppressed because it is too large
Load Diff
@@ -14,9 +14,9 @@ Covers the contracts from task 4dd8f870 plus the review fixes:
|
||||
test feeds the scanner each bypass shape and asserts it flags them.
|
||||
- A settings object constructed with NON-default ``horizon_days`` and
|
||||
``day_rollover_time`` drives a materialization whose resulting window
|
||||
matches the non-default horizon — via the core ``materialize`` path
|
||||
(the integration path it used to share is gone with the integration's
|
||||
database).
|
||||
matches the non-default horizon — via BOTH the core ``materialize``
|
||||
path and the INTEGRATION path (``_run_horizon_materialization`` +
|
||||
``_register_day_rollover_listener``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -29,6 +29,7 @@ from typing import Any
|
||||
import pytest
|
||||
|
||||
import custom_components.nestquest.core as _core_mod
|
||||
from conftest import freeze_nestquest_clock
|
||||
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
@@ -740,7 +741,7 @@ def test_settings_module_has_no_ha_imports() -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Materialization over a non-default horizon (core path).
|
||||
# Materialization over a non-default horizon: core path + integration path.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -827,3 +828,85 @@ async def test_materialize_over_non_default_horizon(hass, tmp_path) -> None:
|
||||
) == []
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
async def test_integration_path_threads_settings(
|
||||
hass, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""The integration path threads ``settings`` through horizon_window + rollover.
|
||||
|
||||
``_run_horizon_materialization`` calls
|
||||
``settings.horizon_window(today)`` (so the helper is real and shared,
|
||||
not recomputed inline), and ``_register_day_rollover_listener``
|
||||
registers at ``settings.day_rollover_hour_minute``. This runs the
|
||||
INTEGRATION path with ``horizon_days=3`` and
|
||||
``day_rollover_time='03:30'`` and asserts the resulting instance
|
||||
window matches [today, today+3] AND the registered hour/minute are 3/30.
|
||||
"""
|
||||
import custom_components.nestquest as nestquest
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
RuleType,
|
||||
ScheduleRule,
|
||||
)
|
||||
|
||||
# Freeze the integration clock so "today" is deterministic (the
|
||||
# hass fixture's config.time_zone is UTC, so 2026-03-01 12:00 UTC
|
||||
# localizes to 2026-03-01).
|
||||
freeze_nestquest_clock(
|
||||
monkeypatch, datetime.datetime(2026, 3, 1, 12, 0, tzinfo=datetime.timezone.utc)
|
||||
)
|
||||
|
||||
database = await _prepare(tmp_path / "nq.db", hass.async_add_executor_job)
|
||||
try:
|
||||
settings = NestQuestSettings(horizon_days=3, day_rollover_time="03:30")
|
||||
today = datetime.date(2026, 3, 1)
|
||||
|
||||
NOW = "2026-03-01T12:00:00+00:00"
|
||||
child = await ChildrenDao(database).create("Ada", NOW)
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Daily chore",
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.DAILY, start_date=today.isoformat()
|
||||
),
|
||||
[child.id],
|
||||
["morning"],
|
||||
)
|
||||
|
||||
# Integration path: the window comes from settings.horizon_window.
|
||||
await nestquest._run_horizon_materialization(hass, database, settings)
|
||||
|
||||
end = today + datetime.timedelta(days=3)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, today.isoformat(), end.isoformat()
|
||||
)
|
||||
dates = sorted({r.due_date for r in records})
|
||||
assert dates == [
|
||||
(today + datetime.timedelta(days=i)).isoformat()
|
||||
for i in range(4)
|
||||
]
|
||||
beyond = (end + datetime.timedelta(days=1)).isoformat()
|
||||
far = (end + datetime.timedelta(days=10)).isoformat()
|
||||
assert await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, beyond, far
|
||||
) == []
|
||||
|
||||
# The rollover listener registers at the configured hour/minute.
|
||||
remove = nestquest._register_day_rollover_listener(
|
||||
hass, database, settings
|
||||
)
|
||||
try:
|
||||
reg = hass.time_change.registrations[-1]
|
||||
assert reg["hour"] == 3
|
||||
assert reg["minute"] == 30
|
||||
finally:
|
||||
remove()
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
+25
-65
@@ -10,22 +10,17 @@ 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.
|
||||
|
||||
The database is a standalone core one (the integration owns no
|
||||
database since Feature 18's DB removal); the closing round-trip pins
|
||||
that the integration's coordinator reconstructs the SAME dataclasses
|
||||
from the route's payload.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from conftest import set_coordinator_client, wire_entry_to_registry
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.core.children import create_child
|
||||
from custom_components.nestquest.core.completion import complete_instance
|
||||
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,
|
||||
@@ -33,17 +28,14 @@ from custom_components.nestquest.core.snapshot import (
|
||||
build_snapshot,
|
||||
instance_payload,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
PresenceSchedulesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.materialize import materialize
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.quest_definitions import create_quest_definition
|
||||
from custom_components.nestquest.core.recurrence import ScheduleRule
|
||||
from custom_components.nestquest.core.settings import NestQuestSettings
|
||||
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:
|
||||
@@ -113,7 +105,7 @@ async def test_instance_payload_shape_and_missed_omission() -> None:
|
||||
)
|
||||
|
||||
|
||||
async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> None:
|
||||
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,
|
||||
@@ -125,18 +117,15 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
|
||||
scheduled active child is Ada, whose 2-week cycle anchored one day
|
||||
before today puts ``cycle_day`` at 2.
|
||||
"""
|
||||
# A standalone core database (the integration owns none): the
|
||||
# builder and its seed run against it through the executor.
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "snapshot.db")
|
||||
await apply_migrations(database)
|
||||
settings = NestQuestSettings()
|
||||
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);
|
||||
# the coordinator no longer owns a clock read (the API serves the
|
||||
# snapshot), so the test resolves the HA-local zone directly.
|
||||
# Single clock read for the seed/snapshot anchor (no midnight skew).
|
||||
today = coordinator._local_now().date()
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
# 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(
|
||||
@@ -305,42 +294,13 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
|
||||
== "missed"
|
||||
)
|
||||
|
||||
# REQUIRED wrapper tie-in: since Feature 18 the coordinator's
|
||||
# refresh polls the snapshot through its API client, so pinning the
|
||||
# client to serve the payload of the SAME snapshot built with
|
||||
# pinned_now must reproduce the builder's snapshot byte-for-byte.
|
||||
# The dataclasses are frozen, so == is a full deep compare — a
|
||||
# reconstruction that dropped or reshaped a field would diverge
|
||||
# here even though the literal assertions above still pass.
|
||||
class _PinnedPayloadClient:
|
||||
async def get_snapshot(self):
|
||||
return {
|
||||
"today_iso": built.today_iso,
|
||||
"cycle_day": built.cycle_day,
|
||||
"children": [
|
||||
{
|
||||
"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": instance_payload(
|
||||
child.instances, include_missed=False
|
||||
),
|
||||
}
|
||||
for child in built.children
|
||||
],
|
||||
}
|
||||
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}),
|
||||
hass.registry,
|
||||
)
|
||||
set_coordinator_client(entry, _PinnedPayloadClient())
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
# 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
|
||||
await database.close()
|
||||
|
||||
@@ -6,15 +6,15 @@ import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.dao_children import (
|
||||
from custom_components.nestquest.dao_children import (
|
||||
AdminUserRecord,
|
||||
AdminUsersDao,
|
||||
ChildRecord,
|
||||
ChildrenDao,
|
||||
)
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.schema import SCHEMA_V1_STATEMENTS
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.schema import SCHEMA_V1_STATEMENTS
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -504,6 +504,9 @@ def test_children_and_admin_sql_lives_only_in_dao_module() -> None:
|
||||
"tests/test_dao_rules.py", # rules/definitions guard, own scope
|
||||
"tests/test_dao_presence.py", # presence guard, own scope
|
||||
"tests/test_dao_instances.py", # instances guard, own scope
|
||||
"tests/test_startup_db.py", # row-count probe of the children
|
||||
# table only (proving a fresh setup loads); guard below covers
|
||||
# everything else
|
||||
}
|
||||
sql_pattern = re.compile(
|
||||
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+"
|
||||
@@ -555,6 +558,32 @@ def _strip_function_spans(text: str, names: set[str]) -> str:
|
||||
for number, line in enumerate(lines, start=1)
|
||||
if number not in excluded
|
||||
)
|
||||
|
||||
|
||||
def test_startup_db_test_file_uses_dao_elsewhere() -> None:
|
||||
"""Compensating self-scan for the startup-test exemption: outside
|
||||
the single sanctioned COUNT(*) probe, test_startup_db.py must go
|
||||
through the DAO for children/admin_users data access.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
startup_test = Path(__file__).parent / "test_startup_db.py"
|
||||
stripped = _strip_function_spans(
|
||||
startup_test.read_text(),
|
||||
{"test_setup_with_valid_file_preserves_rows"},
|
||||
)
|
||||
sql_pattern = re.compile(
|
||||
r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|"
|
||||
r"FROM|JOIN)\s+[`'\"]*(\[)?(children|admin_users)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
assert sql_pattern.search(stripped) is None, (
|
||||
"test_startup_db.py must go through the DAO for these tables "
|
||||
"outside its sanctioned row-count probe"
|
||||
)
|
||||
|
||||
|
||||
def test_dao_matrix_test_file_raw_probes_are_only_constraint_probes() -> None:
|
||||
"""Compensating self-scan for the matrix-file exemption, structurally
|
||||
exact: parse the two sanctioned functions' ASTs and require that
|
||||
|
||||
@@ -7,16 +7,16 @@ import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
CompletionEventRecord,
|
||||
CompletionEventsDao,
|
||||
QuestInstanceRecord,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_rules import ScheduleRulesDao, QuestDefinitionsDao
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.dao_rules import ScheduleRulesDao, QuestDefinitionsDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
|
||||
|
||||
def _run(coro):
|
||||
|
||||
@@ -21,24 +21,24 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.dao_children import (
|
||||
from custom_components.nestquest.dao_children import (
|
||||
AdminUsersDao,
|
||||
ChildrenDao,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
PresenceSchedulesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_rules import (
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
ScheduleRulesDao,
|
||||
QuestDefinitionsDao,
|
||||
)
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
|
||||
|
||||
def _run(coro):
|
||||
|
||||
@@ -6,15 +6,15 @@ import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverrideRecord,
|
||||
PresenceOverridesDao,
|
||||
PresenceScheduleRecord,
|
||||
PresenceSchedulesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -741,12 +741,12 @@ def test_override_create_racing_conflicts_serialize(tmp_path) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
async def _main():
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
|
||||
armed = {"active": False, "gated": False}
|
||||
gate_open = asyncio_module.Event()
|
||||
|
||||
@@ -6,8 +6,8 @@ import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_rules import (
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
ScheduleRuleRecord,
|
||||
ScheduleRuleStorage,
|
||||
ScheduleRulesDao,
|
||||
@@ -17,9 +17,9 @@ from custom_components.nestquest.core.dao_rules import (
|
||||
schedule_rule_from_storage,
|
||||
schedule_rule_to_storage,
|
||||
)
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
@@ -341,7 +341,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
|
||||
# delete task has been scheduled, so the create holds
|
||||
# the connection lock while the delete queues behind
|
||||
# it — then the create proceeds and wins.
|
||||
import custom_components.nestquest.core.dao_rules as dao_rules
|
||||
import custom_components.nestquest.dao_rules as dao_rules
|
||||
|
||||
original_validate = (
|
||||
dao_rules.QuestDefinitionsDao._validate_rule_exists
|
||||
|
||||
+66
-1
@@ -14,7 +14,7 @@ import pytest
|
||||
from conftest import executor_for, make_hass
|
||||
|
||||
import custom_components.nestquest.core.db as db_module
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -933,3 +933,68 @@ def test_execute_many_exposes_rowcount(tmp_path) -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle wiring: opened once per entry, closed on unload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_setup_entry_opens_database_in_runtime_data(hass, make_entry) -> None:
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
runtime = entry.runtime_data
|
||||
assert runtime.database is not None
|
||||
assert runtime.database.connected is True
|
||||
await async_unload_entry(hass, entry)
|
||||
|
||||
|
||||
async def test_unload_entry_closes_database(hass, make_entry) -> None:
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
database = entry.runtime_data.database
|
||||
assert database.connected is True
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert database.connected is False
|
||||
with pytest.raises(RuntimeError, match="closed"):
|
||||
await database.fetch_one("SELECT 1")
|
||||
|
||||
|
||||
async def test_second_setup_does_not_open_second_connection(hass, make_entry) -> None:
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
first = entry.runtime_data.database
|
||||
|
||||
await async_setup_entry(hass, entry)
|
||||
second = entry.runtime_data.database
|
||||
|
||||
assert first is second
|
||||
assert first.connected is True
|
||||
await async_unload_entry(hass, entry)
|
||||
|
||||
|
||||
async def test_setup_unload_cycles_reopen_fresh_connection(hass, make_entry) -> None:
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
seen: list[NestQuestDatabase] = []
|
||||
for _ in range(3):
|
||||
await async_setup_entry(hass, entry)
|
||||
runtime = entry.runtime_data
|
||||
assert runtime.database.connected is True
|
||||
seen.append(runtime.database)
|
||||
await async_unload_entry(hass, entry)
|
||||
assert runtime.database.connected is False
|
||||
assert len(set(seen)) == 3
|
||||
assert hass.data == {}
|
||||
+107
-122
@@ -1,16 +1,24 @@
|
||||
"""Entity lifecycle across child add/deactivate/reactivate (Feature 10, task 8).
|
||||
|
||||
The household state the entities mirror comes from the API service's
|
||||
panel route, so a deactivate/reactivate/add is scripted as a payload
|
||||
change served by :class:`conftest.StubSnapshotClient`; the reload
|
||||
(unload + setup) re-polls it, exactly as production does.
|
||||
"""
|
||||
"""Entity lifecycle across child add/deactivate/reactivate (Feature 10, task 8)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry
|
||||
import datetime
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||
from custom_components.nestquest.children import (
|
||||
create_child,
|
||||
list_children,
|
||||
set_child_active,
|
||||
)
|
||||
from custom_components.nestquest.const import DOMAIN
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import ScheduleRule
|
||||
|
||||
SEED_HORIZON_DAYS = 3
|
||||
PER_CHILD_KINDS = (
|
||||
"quests_due_today",
|
||||
"quests_completed_today",
|
||||
@@ -26,78 +34,6 @@ HOUSEHOLD_UNIQUE_IDS = (
|
||||
"nestquest_cycle_day",
|
||||
)
|
||||
|
||||
ADA = 1
|
||||
BO = 2
|
||||
CORY_ID = 4
|
||||
|
||||
|
||||
def _child(child_id: int, name: str, *, present=True, instances=()):
|
||||
completed = sum(1 for i in instances if i["state"] == "completed")
|
||||
due = len(instances)
|
||||
return {
|
||||
"child_id": child_id,
|
||||
"child_name": name,
|
||||
"present": present,
|
||||
"next_present": None,
|
||||
"due_today": due,
|
||||
"completed_today": completed,
|
||||
"remaining_today": due - completed,
|
||||
"completion_pct": 100 if due == 0 else round(completed / due * 100),
|
||||
"instances": list(instances),
|
||||
}
|
||||
|
||||
|
||||
def _instance(instance_id: int, child_id: int, *, state="open") -> dict:
|
||||
return {
|
||||
"id": instance_id,
|
||||
"definition_id": 3,
|
||||
"child_id": child_id,
|
||||
"title": "Brush teeth",
|
||||
"icon": "🦷",
|
||||
"window": "morning",
|
||||
"due_time": "08:00",
|
||||
"state": state,
|
||||
"overdue": False,
|
||||
"completed_at": None if state == "open" else "2026-09-23T07:55:00+00:00",
|
||||
"on_time": None if state == "open" else True,
|
||||
}
|
||||
|
||||
|
||||
def _payload(children) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
#: The full household (Ada + Bo, one daily quest each) and the states
|
||||
#: the lifecycle walks through: Bo deactivated, reactivated, completed.
|
||||
_BOTH = _payload(
|
||||
[
|
||||
_child(ADA, "Ada", instances=[_instance(7, ADA)]),
|
||||
_child(BO, "Bo", instances=[_instance(8, BO)]),
|
||||
]
|
||||
)
|
||||
_BO_GONE = _payload([_child(ADA, "Ada", instances=[_instance(7, ADA)])])
|
||||
_BO_DONE = _payload(
|
||||
[
|
||||
_child(ADA, "Ada", instances=[_instance(7, ADA)]),
|
||||
_child(BO, "Bo", instances=[_instance(8, BO, state="completed")]),
|
||||
]
|
||||
)
|
||||
_ADA_ONLY = _payload([_child(ADA, "Ada", instances=[_instance(7, ADA)])])
|
||||
_ADA_AND_CORY = _payload(
|
||||
[
|
||||
_child(ADA, "Ada", instances=[_instance(7, ADA)]),
|
||||
_child(CORY_ID, "Cory"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _child_unique_ids(child_id: int) -> set[str]:
|
||||
return {f"nestquest_child_{child_id}_{kind}" for kind in PER_CHILD_KINDS}
|
||||
|
||||
|
||||
async def _reload(hass, entry) -> None:
|
||||
"""Unload then set up: the harness's restart/reload equivalent."""
|
||||
@@ -105,36 +41,46 @@ async def _reload(hass, entry) -> None:
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
|
||||
|
||||
async def _setup_with_household(hass, make_entry, *payloads):
|
||||
"""Set up the entry with a scripted, ordered snapshot history."""
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(
|
||||
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
|
||||
),
|
||||
hass.registry,
|
||||
)
|
||||
client = set_coordinator_client(entry, StubSnapshotClient(*payloads))
|
||||
def _child_unique_ids(child_id: int) -> set[str]:
|
||||
return {f"nestquest_child_{child_id}_{kind}" for kind in PER_CHILD_KINDS}
|
||||
|
||||
|
||||
async def _setup_and_seed(hass, make_entry):
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
return entry, entry.runtime_data.coordinator, client
|
||||
database = entry.runtime_data.database
|
||||
ada = await create_child(database, "Ada")
|
||||
bo = await create_child(database, "Bo")
|
||||
today = datetime.date.today()
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today.isoformat()}
|
||||
),
|
||||
[ada.id, bo.id],
|
||||
["morning"],
|
||||
)
|
||||
end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat()
|
||||
await materialize(database, today.isoformat(), end, today=today)
|
||||
await entry.runtime_data.coordinator.async_refresh()
|
||||
return entry, ada, bo
|
||||
|
||||
|
||||
async def test_deactivated_child_entities_removed_on_reload(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A child absent from the API's snapshot loses its entities on
|
||||
reload; the surviving child's entities are untouched."""
|
||||
entry, _coordinator, _client = await _setup_with_household(
|
||||
hass, make_entry, _BOTH, _BO_GONE
|
||||
)
|
||||
assert _child_unique_ids(ADA) <= set(hass.entities)
|
||||
assert _child_unique_ids(BO) <= set(hass.entities)
|
||||
entry, ada, bo = await _setup_and_seed(hass, make_entry)
|
||||
assert _child_unique_ids(ada.id) <= set(hass.entities)
|
||||
assert _child_unique_ids(bo.id) <= set(hass.entities)
|
||||
|
||||
await set_child_active(entry.runtime_data.database, bo.id, False)
|
||||
await _reload(hass, entry)
|
||||
|
||||
assert _child_unique_ids(ADA) <= set(hass.entities), (
|
||||
assert _child_unique_ids(ada.id) <= set(hass.entities), (
|
||||
"the surviving child's entities must be untouched"
|
||||
)
|
||||
assert not (_child_unique_ids(BO) & set(hass.entities)), (
|
||||
assert not (_child_unique_ids(bo.id) & set(hass.entities)), (
|
||||
"the deactivated child's entities must be gone after reload"
|
||||
)
|
||||
|
||||
@@ -142,61 +88,100 @@ async def test_deactivated_child_entities_removed_on_reload(
|
||||
async def test_reactivation_restores_entities_with_original_unique_ids(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
original = _child_unique_ids(BO)
|
||||
entry, _coordinator, _client = await _setup_with_household(
|
||||
hass, make_entry, _BOTH, _BO_GONE, _BOTH
|
||||
)
|
||||
entry, ada, bo = await _setup_and_seed(hass, make_entry)
|
||||
original = _child_unique_ids(bo.id)
|
||||
await set_child_active(entry.runtime_data.database, bo.id, False)
|
||||
await _reload(hass, entry)
|
||||
assert not (original & set(hass.entities))
|
||||
|
||||
await set_child_active(entry.runtime_data.database, bo.id, True)
|
||||
await _reload(hass, entry)
|
||||
|
||||
assert original <= set(hass.entities), (
|
||||
"reactivation must restore the exact original unique_ids"
|
||||
)
|
||||
bo_due = hass.entities[f"nestquest_child_{BO}_quests_due_today"]
|
||||
bo_due = hass.entities[f"nestquest_child_{bo.id}_quests_due_today"]
|
||||
assert bo_due.native_value == 1, "restored entities carry live state"
|
||||
|
||||
|
||||
async def test_new_child_gets_entities_without_disturbing_existing(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A child ADDED to the API's snapshot gains its entities on
|
||||
reload; the existing children's entities are untouched."""
|
||||
entry, _coordinator, _client = await _setup_with_household(
|
||||
hass, make_entry, _ADA_ONLY, _ADA_AND_CORY
|
||||
)
|
||||
before_ada = set(hass.entities) & _child_unique_ids(ADA)
|
||||
entry, ada, bo = await _setup_and_seed(hass, make_entry)
|
||||
before_ada = set(hass.entities) & _child_unique_ids(ada.id)
|
||||
|
||||
cory = await create_child(entry.runtime_data.database, "Cory")
|
||||
await _reload(hass, entry)
|
||||
|
||||
assert before_ada <= set(hass.entities), "existing entities untouched"
|
||||
assert _child_unique_ids(CORY_ID) <= set(hass.entities), (
|
||||
assert _child_unique_ids(cory.id) <= set(hass.entities), (
|
||||
"the new child's entities appear after reload"
|
||||
)
|
||||
assert hass.entities[
|
||||
f"nestquest_child_{CORY_ID}_quests_due_today"
|
||||
].native_value == 0
|
||||
assert hass.entities[f"nestquest_child_{cory.id}_quests_due_today"].native_value == 0
|
||||
|
||||
|
||||
async def test_restart_equivalent_restores_all_entity_states(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Unload + setup (the restart equivalent) restores every active
|
||||
child's entities with the API's current states and the household
|
||||
rollups."""
|
||||
entry, _coordinator, _client = await _setup_with_household(
|
||||
hass, make_entry, _BOTH, _BO_DONE
|
||||
child's entities with correct states and the household rollups."""
|
||||
from custom_components.nestquest.completion import complete_instance
|
||||
|
||||
entry, ada, bo = await _setup_and_seed(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
snapshot_ada = next(
|
||||
child
|
||||
for child in coordinator.data.children
|
||||
if child.child_id == ada.id
|
||||
)
|
||||
await complete_instance(
|
||||
entry.runtime_data.database,
|
||||
snapshot_ada.instances[0].instance_id,
|
||||
actor_source="panel",
|
||||
actor_child_id=ada.id,
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
assert hass.entities[
|
||||
f"nestquest_child_{ada.id}_quests_completed_today"
|
||||
].native_value == 1
|
||||
|
||||
await _reload(hass, entry)
|
||||
|
||||
assert hass.entities[
|
||||
f"nestquest_child_{ADA}_quests_completed_today"
|
||||
].native_value == 0
|
||||
f"nestquest_child_{ada.id}_quests_completed_today"
|
||||
].native_value == 1, "completed count survives the restart"
|
||||
assert hass.entities[
|
||||
f"nestquest_child_{BO}_quests_completed_today"
|
||||
].native_value == 1, "the API's recorded completion survives the restart"
|
||||
f"nestquest_child_{bo.id}_quests_completed_today"
|
||||
].native_value == 0
|
||||
assert set(HOUSEHOLD_UNIQUE_IDS) <= set(hass.entities)
|
||||
assert hass.entities[
|
||||
"nestquest_household_quests_completed_today"
|
||||
].native_value == 1
|
||||
|
||||
|
||||
async def test_unload_shuts_coordinator_before_closing_database(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The coordinator's listeners are cleared while the database is
|
||||
still connected: a scheduled refresh must never race the close."""
|
||||
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
|
||||
seen_connected = []
|
||||
|
||||
async def _probe_shutdown():
|
||||
seen_connected.append(database.connected)
|
||||
|
||||
original_close = database.close
|
||||
|
||||
async def _recording_close():
|
||||
seen_connected.append("closing")
|
||||
await original_close()
|
||||
|
||||
coordinator.async_shutdown = _probe_shutdown
|
||||
database.close = _recording_close
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert seen_connected == [True, "closing"], (
|
||||
"coordinator shutdown must precede the database close"
|
||||
)
|
||||
|
||||
+128
-217
@@ -2,156 +2,72 @@
|
||||
|
||||
One fixture drives every assertion the done-condition names: correct
|
||||
values for every sensor and binary sensor (including the zero-quest
|
||||
child and the 255-character state limit), the completed/uncompleted/
|
||||
day-complete events re-fired exactly once from the API's SSE frames,
|
||||
the panel payload omitting missed instances while admin_instances
|
||||
equals it, and no entities for a child the API does not report. The
|
||||
per-task test files cover each behavior in depth; this file is the
|
||||
single pass that proves them all against ONE fixture, so a regression
|
||||
in any contract fails here too.
|
||||
|
||||
Since the DB removal the household state and every transition come
|
||||
from the API service, so the fixture scripts the panel route's
|
||||
payloads and completion surface (:class:`conftest.ScriptedPanelClient`)
|
||||
and delivers the transition frames through the SSE subscription — the
|
||||
production event path.
|
||||
child and the 255-character state limit), the three events firing
|
||||
exactly once with schema-valid payloads, the instances payload
|
||||
omitting missed instances while admin_instances includes them, and no
|
||||
entities for an inactive child. The per-task test files cover each
|
||||
behavior in depth; this file is the single pass that proves them all
|
||||
against ONE fixture, so a regression in any contract fails here too.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
|
||||
from conftest import ScriptedPanelClient, set_coordinator_client, wire_entry_to_registry
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.children import (
|
||||
create_child,
|
||||
list_children,
|
||||
set_child_active,
|
||||
)
|
||||
from custom_components.nestquest.completion import derive_state
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
DOMAIN,
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
EVENT_QUEST_COMPLETED,
|
||||
EVENT_QUEST_UNCOMPLETED,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
)
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import ScheduleRule
|
||||
|
||||
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
|
||||
|
||||
ADA = 1
|
||||
BO = 2
|
||||
CORY = 3
|
||||
|
||||
_OCCURRED = "2026-09-23T07:55:00+00:00"
|
||||
|
||||
|
||||
def _instance(instance_id: int, child_id: int, *, window="morning", state="open"):
|
||||
return {
|
||||
"id": instance_id,
|
||||
"definition_id": 3,
|
||||
"child_id": child_id,
|
||||
"title": "Brush teeth",
|
||||
"icon": "🦷",
|
||||
"window": window,
|
||||
"due_time": "08:00" if window == "morning" else "19:00",
|
||||
"state": state,
|
||||
"overdue": False,
|
||||
"completed_at": None if state == "open" else _OCCURRED,
|
||||
"on_time": None if state == "open" else True,
|
||||
}
|
||||
|
||||
|
||||
def _child(child_id: int, name: str, instances=()):
|
||||
completed = sum(1 for i in instances if i["state"] == "completed")
|
||||
due = len(instances)
|
||||
return {
|
||||
"child_id": child_id,
|
||||
"child_name": name,
|
||||
"present": True,
|
||||
"next_present": None,
|
||||
"due_today": due,
|
||||
"completed_today": completed,
|
||||
"remaining_today": due - completed,
|
||||
"completion_pct": 100 if due == 0 else round(completed / due * 100),
|
||||
"instances": list(instances),
|
||||
}
|
||||
|
||||
|
||||
def _payload(children, cycle_day=0) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": cycle_day,
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
def _completed_frame(instance_id: int, child_id: int, child_name: str, window: str):
|
||||
"""The completed-event frame the API service publishes for one
|
||||
panel completion (the day-complete rule is the API's; its payload
|
||||
shape is pinned by tests/test_api_sse.py on the production
|
||||
plane)."""
|
||||
return (
|
||||
EVENT_QUEST_COMPLETED,
|
||||
{
|
||||
"child_id": child_id,
|
||||
"child_name": child_name,
|
||||
"instance_id": instance_id,
|
||||
"quest_title": "Brush teeth",
|
||||
"window": window,
|
||||
"due_date": "2026-09-23",
|
||||
"due_time": "08:00" if window == "morning" else "19:00",
|
||||
"occurred_at": _OCCURRED,
|
||||
"was_on_time": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _day_complete_frame(child_id: int, child_name: str):
|
||||
"""The day-complete frame the API publishes once a child's day
|
||||
clears (after the LAST completion of the day)."""
|
||||
return (
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
{
|
||||
"child_id": child_id,
|
||||
"child_name": child_name,
|
||||
"quests_due": 2,
|
||||
"quests_completed": 2,
|
||||
"occurred_at": _OCCURRED,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _fixture(hass, make_entry):
|
||||
"""Two quest children, one zero-quest child; the inactive child is
|
||||
simply ABSENT from the API's snapshot (the API only reports active
|
||||
children, so the integration can never create its entities)."""
|
||||
"""Two quest children, one zero-quest child, one inactive child."""
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(
|
||||
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
|
||||
),
|
||||
hass.registry,
|
||||
make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry
|
||||
)
|
||||
client = ScriptedPanelClient(
|
||||
_payload(
|
||||
[
|
||||
_child(ADA, "Ada", [_instance(7, ADA), _instance(8, ADA, window="evening")]),
|
||||
_child(BO, "Bo", [_instance(9, BO), _instance(10, BO, window="evening")]),
|
||||
_child(CORY, "Cory"),
|
||||
]
|
||||
),
|
||||
_payload(
|
||||
[
|
||||
_child(
|
||||
ADA,
|
||||
"Ada",
|
||||
[
|
||||
_instance(7, ADA, state="completed"),
|
||||
_instance(8, ADA, window="evening", state="completed"),
|
||||
],
|
||||
),
|
||||
_child(BO, "Bo", [_instance(9, BO), _instance(10, BO, window="evening")]),
|
||||
_child(CORY, "Cory"),
|
||||
]
|
||||
),
|
||||
)
|
||||
set_coordinator_client(entry, client)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
return entry, client
|
||||
database = entry.runtime_data.database
|
||||
ada = await create_child(database, "Ada")
|
||||
bo = await create_child(database, "Bo")
|
||||
cory = await create_child(database, "Cory")
|
||||
ghost = await create_child(database, "Ghost")
|
||||
await set_child_active(database, ghost.id, False)
|
||||
today = datetime.date.today()
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today.isoformat()}
|
||||
),
|
||||
[ada.id, bo.id],
|
||||
["morning", "evening"],
|
||||
)
|
||||
end = (today + datetime.timedelta(days=3)).isoformat()
|
||||
await materialize(database, today.isoformat(), end, today=today)
|
||||
await entry.runtime_data.coordinator.async_refresh()
|
||||
return entry, (ada, bo, cory, ghost)
|
||||
|
||||
|
||||
def _sensor(hass, child_id, kind):
|
||||
@@ -159,11 +75,11 @@ def _sensor(hass, child_id, kind):
|
||||
|
||||
|
||||
async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
||||
entry, client = await _fixture(hass, make_entry)
|
||||
entry, (ada, bo, cory, ghost) = await _fixture(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
|
||||
# --- Every reported child has the full entity set; the child the
|
||||
# API does not report has none.
|
||||
# --- Every active child has the full entity set; the inactive
|
||||
# child has none.
|
||||
kinds = (
|
||||
"quests_due_today",
|
||||
"quests_completed_today",
|
||||
@@ -173,37 +89,36 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
||||
"all_done",
|
||||
"present_today",
|
||||
)
|
||||
ghost_id = 99
|
||||
for child in (ADA, BO, CORY):
|
||||
for child in (ada, bo, cory):
|
||||
for kind in kinds:
|
||||
assert f"{DOMAIN}_child_{child}_{kind}" in hass.entities, kind
|
||||
assert f"{DOMAIN}_child_{child.id}_{kind}" in hass.entities, kind
|
||||
for kind in kinds:
|
||||
assert f"{DOMAIN}_child_{ghost_id}_{kind}" not in hass.entities, (
|
||||
"children absent from the API snapshot must have no entities"
|
||||
assert f"{DOMAIN}_child_{ghost.id}_{kind}" not in hass.entities, (
|
||||
"inactive children must have no entities"
|
||||
)
|
||||
|
||||
# --- Correct values against the fixture: Ada and Bo owe two
|
||||
# quests (morning + evening), Cory owes none and is still 100%.
|
||||
for child in (ADA, BO):
|
||||
assert _sensor(hass, child, "quests_due_today").native_value == 2
|
||||
for child in (ada, bo):
|
||||
assert _sensor(hass, child.id, "quests_due_today").native_value == 2
|
||||
assert (
|
||||
_sensor(hass, child, "quests_completed_today").native_value == 0
|
||||
_sensor(hass, child.id, "quests_completed_today").native_value == 0
|
||||
)
|
||||
assert (
|
||||
_sensor(hass, child, "quests_remaining_today").native_value == 2
|
||||
_sensor(hass, child.id, "quests_remaining_today").native_value == 2
|
||||
)
|
||||
assert (
|
||||
_sensor(hass, child, "completion_pct_today").native_value == 0
|
||||
_sensor(hass, child.id, "completion_pct_today").native_value == 0
|
||||
)
|
||||
assert _sensor(hass, child, "next_quest").native_value == (
|
||||
assert _sensor(hass, child.id, "next_quest").native_value == (
|
||||
"Brush teeth"
|
||||
)
|
||||
assert _sensor(hass, child, "all_done").is_on is False
|
||||
assert _sensor(hass, child, "present_today").is_on is True
|
||||
assert _sensor(hass, CORY, "quests_due_today").native_value == 0
|
||||
assert _sensor(hass, CORY, "completion_pct_today").native_value == 100
|
||||
assert _sensor(hass, CORY, "all_done").is_on is False
|
||||
assert _sensor(hass, CORY, "next_quest").native_value == "none"
|
||||
assert _sensor(hass, child.id, "all_done").is_on is False
|
||||
assert _sensor(hass, child.id, "present_today").is_on is True
|
||||
assert _sensor(hass, cory.id, "quests_due_today").native_value == 0
|
||||
assert _sensor(hass, cory.id, "completion_pct_today").native_value == 100
|
||||
assert _sensor(hass, cory.id, "all_done").is_on is False
|
||||
assert _sensor(hass, cory.id, "next_quest").native_value == "none"
|
||||
|
||||
# --- Household rollups equal the per-child sums.
|
||||
assert hass.entities[
|
||||
@@ -223,31 +138,22 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
||||
), unique_id
|
||||
|
||||
# --- Completing both of Ada's quests fires the events exactly
|
||||
# once each and flips her entities immediately. The transition
|
||||
# events arrive over the API's SSE stream (the subscription
|
||||
# re-fires them), never from the service handler.
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{"instance_id": 7, "actor": "panel", "actor_child_id": ADA},
|
||||
# once each and flips her entities immediately.
|
||||
instances = await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(
|
||||
ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat()
|
||||
)
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{"instance_id": 8, "actor": "panel", "actor_child_id": ADA},
|
||||
)
|
||||
client.published_frames.extend(
|
||||
[
|
||||
_completed_frame(7, ADA, "Ada", "morning"),
|
||||
_completed_frame(8, ADA, "Ada", "evening"),
|
||||
# The API publishes the day-complete frame only when the
|
||||
# day clears — after the LAST completion of the day.
|
||||
_day_complete_frame(ADA, "Ada"),
|
||||
]
|
||||
)
|
||||
from conftest import refire_api_transitions
|
||||
|
||||
await refire_api_transitions(hass, entry, client)
|
||||
for instance in instances:
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": instance.id,
|
||||
"actor": "panel",
|
||||
"actor_child_id": ada.id,
|
||||
},
|
||||
)
|
||||
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 2
|
||||
for payload in hass.bus.fired(EVENT_QUEST_COMPLETED):
|
||||
assert _TIMESTAMP.match(payload["occurred_at"])
|
||||
@@ -256,53 +162,53 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
||||
assert payload["quest_title"] == "Brush teeth"
|
||||
assert isinstance(payload["instance_id"], int)
|
||||
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
|
||||
# The next scripted snapshot reflects both completions.
|
||||
await coordinator.async_refresh()
|
||||
assert _sensor(hass, ADA, "all_done").is_on is True
|
||||
assert _sensor(hass, ADA, "quests_remaining_today").native_value == 0
|
||||
assert _sensor(hass, ada.id, "all_done").is_on is True
|
||||
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 0
|
||||
assert (
|
||||
_sensor(hass, ADA, "completion_pct_today").native_value == 100
|
||||
_sensor(hass, ada.id, "completion_pct_today").native_value == 100
|
||||
)
|
||||
|
||||
# --- Reversing one quest fires the uncompleted event exactly once.
|
||||
# The HA ``uncomplete_quest`` service is gone (the API service's
|
||||
# admin route is the reversal path), so the reversal is the API's:
|
||||
# it publishes the uncompleted frame and the integration re-fires
|
||||
# it; the coordinator's next poll picks the restored counts up.
|
||||
client.published_frames.append(
|
||||
(
|
||||
EVENT_QUEST_UNCOMPLETED,
|
||||
{
|
||||
"child_id": ADA,
|
||||
"child_name": "Ada",
|
||||
"instance_id": 7,
|
||||
"quest_title": "Brush teeth",
|
||||
"window": "morning",
|
||||
"due_date": "2026-09-23",
|
||||
"due_time": "08:00",
|
||||
"occurred_at": _OCCURRED,
|
||||
},
|
||||
)
|
||||
# --- Reversing one quest fires the uncompleted event exactly once
|
||||
# and restores the owed counts.
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
{"instance_id": instances[0].id, "actor": "user"},
|
||||
context={"user_id": "admin-1"},
|
||||
)
|
||||
await refire_api_transitions(hass, entry, client)
|
||||
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
|
||||
assert _sensor(hass, ada.id, "all_done").is_on is False
|
||||
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 1
|
||||
|
||||
# --- The panel payload omits missed instances (D-009) — the API
|
||||
# panel route omits missed rows ENTIRELY, so admin_instances
|
||||
# cannot carry them either (the admin surface for missed quests is
|
||||
# the PWA, reading the API directly). A snapshot built from the
|
||||
# payload can only ever hold open and completed rows: pin that
|
||||
# the payload round-trips verbatim.
|
||||
# --- The instances payload omits missed instances while
|
||||
# admin_instances includes them (D-009): pin the derive clock
|
||||
# forward in the TEST ONLY so today's open instances read missed.
|
||||
original_refresh = coordinator._async_update_data
|
||||
|
||||
async def _future_dated_refresh():
|
||||
import custom_components.nestquest.core.snapshot as snapshot_module
|
||||
|
||||
real_derive = snapshot_module.derive_state
|
||||
|
||||
def _derive(instance, latest, today):
|
||||
return real_derive(instance, latest, today + datetime.timedelta(days=1))
|
||||
|
||||
snapshot_module.derive_state = _derive
|
||||
try:
|
||||
return await original_refresh()
|
||||
finally:
|
||||
snapshot_module.derive_state = real_derive
|
||||
|
||||
coordinator._async_update_data = _future_dated_refresh
|
||||
await coordinator.async_refresh()
|
||||
attributes = _sensor(hass, BO, "quests_due_today").extra_state_attributes
|
||||
assert attributes["instances"] == [
|
||||
dict(_instance(9, BO)),
|
||||
dict(_instance(10, BO, window="evening")),
|
||||
], "the panel payload round-trips the API's open rows verbatim"
|
||||
assert attributes["admin_instances"] == attributes["instances"], (
|
||||
"the API payload omits missed rows, so the admin payload "
|
||||
"carries none either (the PWA is the admin surface now)"
|
||||
attributes = _sensor(hass, bo.id, "quests_due_today").extra_state_attributes
|
||||
assert attributes["instances"] == [], "panel payload omits missed"
|
||||
assert len(attributes["admin_instances"]) == 2
|
||||
assert all(
|
||||
entry["state"] == "missed" for entry in attributes["admin_instances"]
|
||||
)
|
||||
coordinator._async_update_data = original_refresh
|
||||
await coordinator.async_refresh()
|
||||
|
||||
|
||||
async def test_payload_schema_matches_the_documented_contract(
|
||||
@@ -310,16 +216,21 @@ async def test_payload_schema_matches_the_documented_contract(
|
||||
) -> None:
|
||||
"""Every fired payload carries the documented fields with the
|
||||
documented types."""
|
||||
entry, client = await _fixture(hass, make_entry)
|
||||
entry, (ada, _bo, _cory, _ghost) = await _fixture(hass, make_entry)
|
||||
instances = await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(
|
||||
ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat()
|
||||
)
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{"instance_id": 7, "actor": "panel", "actor_child_id": ADA},
|
||||
{
|
||||
"instance_id": instances[0].id,
|
||||
"actor": "panel",
|
||||
"actor_child_id": ada.id,
|
||||
},
|
||||
)
|
||||
client.published_frames.append(_completed_frame(7, ADA, "Ada", "morning"))
|
||||
from conftest import refire_api_transitions
|
||||
|
||||
await refire_api_transitions(hass, entry, client)
|
||||
payload = hass.bus.fired(EVENT_QUEST_COMPLETED)[0]
|
||||
for key, check in (
|
||||
("child_id", lambda v: isinstance(v, int)),
|
||||
|
||||
+658
-236
File diff suppressed because it is too large
Load Diff
+521
-45
@@ -9,10 +9,22 @@ from conftest import make_config_entry, make_hass, wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
SERVICE_REGENERATE,
|
||||
DOMAIN as DOMAIN_CONST,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
)
|
||||
|
||||
ADMIN_ID = "admin-1"
|
||||
ADMIN_CTX = {"user_id": ADMIN_ID}
|
||||
|
||||
|
||||
def _admin_data():
|
||||
return {CONF_ADMIN_USER_IDS: [ADMIN_ID]}
|
||||
|
||||
|
||||
def _wire(entry, registry):
|
||||
return wire_entry_to_registry(entry, registry)
|
||||
@@ -25,25 +37,15 @@ async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None:
|
||||
assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data
|
||||
|
||||
|
||||
async def test_setup_runtime_data_has_entry_id_and_coordinator(hass, make_entry) -> None:
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
async def test_setup_runtime_data_has_entry_id_and_settings(hass, make_entry) -> None:
|
||||
entry = _wire(make_entry(options={"horizon_days": 7}), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
runtime = entry.runtime_data
|
||||
assert runtime.entry_id == entry.entry_id
|
||||
assert runtime.coordinator is not None
|
||||
assert runtime.settings.horizon_days == 7
|
||||
assert callable(runtime.remove_update_listener)
|
||||
|
||||
|
||||
async def test_setup_registers_no_time_change_listener(hass, make_entry) -> None:
|
||||
"""The integration owns no database any more: the daily
|
||||
day-rollover/materialization listener is gone (the API service
|
||||
owns the horizon), so setup registers NOTHING against the
|
||||
time-change listener bookkeeping."""
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
assert hass.time_change.size == 0
|
||||
|
||||
|
||||
async def test_unload_returns_true_and_removes_hass_data(hass, make_entry) -> None:
|
||||
registry = hass.registry
|
||||
entry = _wire(make_entry(), registry)
|
||||
@@ -118,7 +120,7 @@ async def test_options_update_triggers_reload_once(hass, make_entry) -> None:
|
||||
registry = hass.registry
|
||||
entry = _wire(make_entry(), registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
entry.options = {"update_interval": 60}
|
||||
entry.options = {"horizon_days": 30}
|
||||
await registry.async_dispatch_options_update(entry)
|
||||
assert hass.config_entries.async_reload.call_count == 1
|
||||
assert registry.reloaded == [entry.entry_id]
|
||||
@@ -196,16 +198,6 @@ async def test_setup_entry_uses_domain_constant(hass, make_entry) -> None:
|
||||
assert set(hass.data) == {DOMAIN_CONST}
|
||||
|
||||
|
||||
async def test_setup_registers_the_completion_service(hass, make_entry) -> None:
|
||||
"""The one domain-global service registers with setup and deregisters
|
||||
on the last unload."""
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
assert hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST)
|
||||
await async_unload_entry(hass, entry)
|
||||
assert not hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST)
|
||||
|
||||
|
||||
async def test_setup_awaitable_listener_removal_supported() -> None:
|
||||
"""A coroutine-returning remover is awaited during unload."""
|
||||
removed = []
|
||||
@@ -224,9 +216,9 @@ async def test_setup_awaitable_listener_removal_supported() -> None:
|
||||
assert entry.runtime_data is None
|
||||
|
||||
|
||||
async def test_setup_unwinds_when_listener_registration_fails() -> None:
|
||||
"""A listener-registration failure must not leak partial setup state."""
|
||||
from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST
|
||||
async def test_setup_closes_database_when_listener_registration_fails() -> None:
|
||||
"""A listener-registration failure must not leak the opened database."""
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
|
||||
class _Boom(Exception):
|
||||
pass
|
||||
@@ -236,16 +228,13 @@ async def test_setup_unwinds_when_listener_registration_fails() -> None:
|
||||
hass, _registry = make_hass()
|
||||
with pytest.raises(_Boom):
|
||||
await async_setup_entry(hass, entry)
|
||||
assert hass.data.get(DOMAIN_CONST, {}) == {}
|
||||
assert hass.data.get(DOMAIN, {}) == {}
|
||||
assert not hasattr(entry, "runtime_data")
|
||||
assert not hass.services.has_service(DOMAIN_CONST, SERVICE_COMPLETE_QUEST)
|
||||
|
||||
|
||||
async def test_unload_shuts_coordinator_even_when_listener_removal_raises() -> None:
|
||||
"""A raising update-listener remover must not skip the coordinator
|
||||
shutdown: the retained error is re-raised only after the teardown
|
||||
ran and the runtime record was cleared."""
|
||||
from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST
|
||||
async def test_unload_closes_database_even_when_listener_removal_raises() -> None:
|
||||
"""Unload removes the listener, closes the DB in a finally, then raises."""
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
|
||||
class _RemoveBoom(Exception):
|
||||
pass
|
||||
@@ -257,17 +246,504 @@ async def test_unload_shuts_coordinator_even_when_listener_removal_raises() -> N
|
||||
entry.add_update_listener = lambda listener: _remover
|
||||
hass, _registry = make_hass()
|
||||
await async_setup_entry(hass, entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
seen = []
|
||||
|
||||
async def _shutdown():
|
||||
seen.append("shutdown")
|
||||
|
||||
coordinator.async_shutdown = _shutdown
|
||||
|
||||
database = entry.runtime_data.database
|
||||
with pytest.raises(_RemoveBoom):
|
||||
await async_unload_entry(hass, entry)
|
||||
assert seen == ["shutdown"], "coordinator shutdown must have run"
|
||||
assert database.connected is False, "database leaked despite remover error"
|
||||
assert entry.runtime_data is None
|
||||
assert DOMAIN_CONST not in hass.data
|
||||
assert DOMAIN not in hass.data
|
||||
assert hass.time_change.size == 0, "time-change listener leaked"
|
||||
|
||||
|
||||
async def test_unload_cancels_time_listener_even_when_update_remover_raises() -> None:
|
||||
"""A raising update-listener remover must not skip the time-change cancel.
|
||||
|
||||
Both removers are independent: the update-listener remover raising must
|
||||
still cancel the daily time-change callback, whose action would otherwise
|
||||
fire after unload against a closed database.
|
||||
"""
|
||||
|
||||
class _RemoveBoom(Exception):
|
||||
pass
|
||||
|
||||
def _remover():
|
||||
raise _RemoveBoom()
|
||||
|
||||
entry = make_config_entry(entry_id="update-boom-time-cancel")
|
||||
entry.add_update_listener = lambda listener: _remover
|
||||
hass, _registry = make_hass()
|
||||
await async_setup_entry(hass, entry)
|
||||
assert hass.time_change.size == 1
|
||||
with pytest.raises(_RemoveBoom):
|
||||
await async_unload_entry(hass, entry)
|
||||
assert hass.time_change.size == 0, "time-change listener not cancelled"
|
||||
assert entry.runtime_data is None
|
||||
assert DOMAIN not in hass.data
|
||||
|
||||
|
||||
async def test_setup_registers_day_rollover_listener(hass, make_entry) -> None:
|
||||
"""Setup registers a daily rollover listener and stores its cancel fn."""
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert hass.time_change.size == 1
|
||||
registration = hass.time_change.registrations[0]
|
||||
assert registration["hour"] == 0
|
||||
assert registration["minute"] == 0
|
||||
assert callable(entry.runtime_data.remove_time_change_listener)
|
||||
|
||||
|
||||
async def test_setup_registers_day_rollover_at_configured_time(hass, make_entry) -> None:
|
||||
"""The listener fires at the entry option's rollover time, not the default."""
|
||||
entry = _wire(make_entry(options={CONF_DAY_ROLLOVER_TIME: "06:30"}), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
registration = hass.time_change.registrations[0]
|
||||
assert registration["hour"] == 6
|
||||
assert registration["minute"] == 30
|
||||
|
||||
|
||||
async def test_unload_cancels_day_rollover_listener(hass, make_entry) -> None:
|
||||
"""Unload removes the time-change listener alongside the update listener."""
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
assert hass.time_change.size == 1
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert hass.time_change.size == 0
|
||||
|
||||
|
||||
async def test_rollover_time_change_reregisters_listener_at_new_time(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A rollover-time options change re-registers the listener at the new time."""
|
||||
entry = _wire(
|
||||
make_entry(options={CONF_DAY_ROLLOVER_TIME: "00:00"}), hass.registry
|
||||
)
|
||||
await async_setup_entry(hass, entry)
|
||||
assert (hass.time_change.registrations[0]["hour"], hass.time_change.registrations[0]["minute"]) == (0, 0)
|
||||
|
||||
# The options flow writes the new rollover time, then HA reloads the
|
||||
# entry: unload tears down the old listener, setup re-registers it.
|
||||
entry.options = {CONF_DAY_ROLLOVER_TIME: "06:30"}
|
||||
await async_unload_entry(hass, entry)
|
||||
assert hass.time_change.size == 0
|
||||
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert hass.time_change.size == 1
|
||||
registration = hass.time_change.registrations[0]
|
||||
assert (registration["hour"], registration["minute"]) == (6, 30)
|
||||
|
||||
|
||||
async def test_day_rollover_listener_materializes_ha_local_horizon(
|
||||
hass, make_entry, monkeypatch
|
||||
) -> None:
|
||||
"""Firing the listener materializes today..today+horizon in HA local time."""
|
||||
import datetime
|
||||
from unittest.mock import AsyncMock
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
database = entry.runtime_data.database
|
||||
|
||||
fake = AsyncMock()
|
||||
monkeypatch.setattr(nestquest, "_materialize_run", fake)
|
||||
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
expected_start = today.isoformat()
|
||||
expected_end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
|
||||
await hass.time_change.fire()
|
||||
|
||||
fake.assert_awaited_once_with(
|
||||
database, expected_start, expected_end, today=today
|
||||
)
|
||||
|
||||
|
||||
async def test_day_rollover_uses_configured_horizon_days(hass, make_entry) -> None:
|
||||
"""The daily run materializes only the configured horizon window."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
entry = _wire(make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
database = entry.runtime_data.database
|
||||
|
||||
# A definition created AFTER setup has no instances yet (create does not
|
||||
# materialize); firing the daily listener must generate only the horizon.
|
||||
child_id = await _seed_daily_child_and_definition(
|
||||
database, _local_today(hass)
|
||||
)
|
||||
await hass.time_change.fire()
|
||||
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=5)).isoformat()
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, today.isoformat(), end
|
||||
)
|
||||
assert len(records) == 6
|
||||
|
||||
beyond = (today + datetime.timedelta(days=6)).isoformat()
|
||||
beyond_end = (
|
||||
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||
).isoformat()
|
||||
assert await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, beyond, beyond_end
|
||||
) == []
|
||||
|
||||
|
||||
def test_day_rollover_time_change_tracker_rejects_local_kwarg() -> None:
|
||||
"""The fake async_track_time_change mirrors HA 2024.6 (no ``local`` kwarg).
|
||||
|
||||
Setup drives the local-time wrapper directly, so passing ``local=`` must
|
||||
raise TypeError — otherwise a regression in the production call would be
|
||||
silently masked by the fake.
|
||||
"""
|
||||
import inspect as _inspect
|
||||
|
||||
from homeassistant.helpers.event import async_track_time_change
|
||||
|
||||
params = _inspect.signature(async_track_time_change).parameters
|
||||
assert list(params) == ["hass", "action", "hour", "minute", "second"]
|
||||
with pytest.raises(TypeError):
|
||||
async_track_time_change(None, lambda _now: None, hour=0, minute=0, local=True)
|
||||
|
||||
|
||||
async def test_setup_applies_all_v1_tables(hass, make_entry) -> None:
|
||||
"""Setup applies the full v1 DDL: all four v1 tables exist afterwards."""
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
database = entry.runtime_data.database
|
||||
rows = await database.fetch_all(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' "
|
||||
"AND name IN ('children', 'admin_users', 'schedule_rules', "
|
||||
"'quest_definitions') ORDER BY name"
|
||||
)
|
||||
assert [row[0] for row in rows] == [
|
||||
"admin_users",
|
||||
"children",
|
||||
"quest_definitions",
|
||||
"schedule_rules",
|
||||
]
|
||||
await async_unload_entry(hass, entry)
|
||||
|
||||
|
||||
async def _seed_daily_child_and_definition(database, today) -> int:
|
||||
"""Create one always-present child + a daily definition starting today.
|
||||
|
||||
Returns the child's id. No presence schedule means the child is present
|
||||
every day, so the daily rule yields exactly one instance per horizon day.
|
||||
"""
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import RuleType, ScheduleRule
|
||||
|
||||
NOW = "2026-09-14T12:00:00+00:00"
|
||||
child = await ChildrenDao(database).create("Ada", NOW)
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Daily chore",
|
||||
ScheduleRule(rule_type=RuleType.DAILY, start_date=today.isoformat()),
|
||||
[child.id],
|
||||
["morning"],
|
||||
)
|
||||
return child.id
|
||||
|
||||
|
||||
def _local_today(hass) -> "datetime.date":
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
return datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
|
||||
|
||||
|
||||
async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None:
|
||||
"""Setup materializes once so a restart backfills any missed days."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.store import async_get_db_path
|
||||
|
||||
# Seed the database BEFORE setup: a restart reopens an existing file that
|
||||
# already holds definitions. The startup backfill must pick them up.
|
||||
db_path = await async_get_db_path(hass)
|
||||
pre = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await pre.open(db_path)
|
||||
await apply_migrations(pre)
|
||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
||||
await pre.close()
|
||||
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
records = await QuestInstancesDao(entry.runtime_data.database).list_by_date_range(
|
||||
child_id, today.isoformat(), end
|
||||
)
|
||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
|
||||
async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None:
|
||||
"""Setup backfills only the configured (non-default) horizon window."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.store import async_get_db_path
|
||||
|
||||
db_path = await async_get_db_path(hass)
|
||||
pre = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await pre.open(db_path)
|
||||
await apply_migrations(pre)
|
||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
||||
await pre.close()
|
||||
|
||||
entry = _wire(
|
||||
make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=5)).isoformat()
|
||||
records = await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(child_id, today.isoformat(), end)
|
||||
assert len(records) == 6
|
||||
|
||||
beyond = (today + datetime.timedelta(days=6)).isoformat()
|
||||
beyond_end = (
|
||||
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||
).isoformat()
|
||||
assert await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(child_id, beyond, beyond_end) == []
|
||||
|
||||
|
||||
async def test_setup_succeeds_with_fully_malformed_options(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A fully malformed options dict falls back per field and setup succeeds.
|
||||
|
||||
``horizon_days=True``, ``day_rollover_time='99:99'`` and
|
||||
``morning_summary_time='25:00'`` are all invalid; the integration's
|
||||
``from_options_resilient`` falls each back to its default, so setup
|
||||
SUCCEEDS, the day-rollover listener registers at the default hour 0
|
||||
minute 0, and the startup backfill materializes the default horizon
|
||||
window.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.store import async_get_db_path
|
||||
|
||||
db_path = await async_get_db_path(hass)
|
||||
pre = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await pre.open(db_path)
|
||||
await apply_migrations(pre)
|
||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
||||
await pre.close()
|
||||
|
||||
entry = _wire(
|
||||
make_entry(
|
||||
options={
|
||||
CONF_HORIZON_DAYS: True,
|
||||
CONF_DAY_ROLLOVER_TIME: "99:99",
|
||||
CONF_MORNING_SUMMARY_TIME: "25:00",
|
||||
}
|
||||
),
|
||||
hass.registry,
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
|
||||
# The day-rollover listener fell back to the default 00:00.
|
||||
registration = hass.time_change.registrations[0]
|
||||
assert registration["hour"] == 0
|
||||
assert registration["minute"] == 0
|
||||
|
||||
# The startup backfill used the default horizon window.
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
records = await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(child_id, today.isoformat(), end)
|
||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
|
||||
async def test_setup_keeps_horizon_when_sibling_option_is_bad(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A malformed sibling option does not reset the configured horizon.
|
||||
|
||||
``horizon_days=5`` is valid while ``morning_summary_time='25:00'`` is
|
||||
invalid; ``from_options_resilient`` falls back ONLY the bad sibling,
|
||||
so the entry's resolved settings keep ``horizon_days == 5``.
|
||||
"""
|
||||
entry = _wire(
|
||||
make_entry(
|
||||
options={
|
||||
CONF_HORIZON_DAYS: 5,
|
||||
CONF_MORNING_SUMMARY_TIME: "25:00",
|
||||
}
|
||||
),
|
||||
hass.registry,
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert entry.runtime_data.settings.horizon_days == 5
|
||||
|
||||
|
||||
async def test_regenerate_service_registered_and_materializes(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""``nestquest.regenerate`` is registered and calling it regenerates."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
entry = _wire(make_entry(data=_admin_data()), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
# A definition created AFTER setup has no instances yet (create does not
|
||||
# materialize); the service must generate them on demand.
|
||||
database = entry.runtime_data.database
|
||||
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
||||
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, today.isoformat(), end
|
||||
)
|
||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
|
||||
async def test_regenerate_service_uses_configured_horizon_days(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""``nestquest.regenerate`` regenerates only the configured horizon."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
entry = _wire(
|
||||
make_entry(options={CONF_HORIZON_DAYS: 5}, data=_admin_data()),
|
||||
hass.registry,
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
|
||||
database = entry.runtime_data.database
|
||||
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
||||
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=5)).isoformat()
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, today.isoformat(), end
|
||||
)
|
||||
assert len(records) == 6
|
||||
|
||||
beyond = (today + datetime.timedelta(days=6)).isoformat()
|
||||
beyond_end = (
|
||||
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||
).isoformat()
|
||||
assert await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, beyond, beyond_end
|
||||
) == []
|
||||
|
||||
|
||||
async def test_unload_deregisters_regenerate_service(hass, make_entry) -> None:
|
||||
"""Unload removes the ``regenerate`` service so no stale entry leaks."""
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
|
||||
async def test_regenerate_service_is_domain_scoped_across_entries(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The ``regenerate`` service is domain-global, not per-entry.
|
||||
|
||||
With two loaded entries the service must be registered and functional;
|
||||
unloading one entry must keep it registered and functional for the
|
||||
remaining entry; unloading the final entry must remove it.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
entry_a = _wire(
|
||||
make_entry(entry_id="entry_a", data=_admin_data()), hass.registry
|
||||
)
|
||||
entry_b = _wire(
|
||||
make_entry(entry_id="entry_b", data=_admin_data()), hass.registry
|
||||
)
|
||||
|
||||
assert await async_setup_entry(hass, entry_a) is True
|
||||
assert await async_setup_entry(hass, entry_b) is True
|
||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
# The domain-global handler resolves a live database from any entry.
|
||||
database_b = entry_b.runtime_data.database
|
||||
child_id = await _seed_daily_child_and_definition(
|
||||
database_b, _local_today(hass)
|
||||
)
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
records = await QuestInstancesDao(database_b).list_by_date_range(
|
||||
child_id, today.isoformat(), end
|
||||
)
|
||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
# Unloading one entry must not tear down the shared service.
|
||||
assert await async_unload_entry(hass, entry_a) is True
|
||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
# It still works for the remaining entry.
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||
|
||||
# Unloading the final entry removes the domain-global service.
|
||||
assert await async_unload_entry(hass, entry_b) is True
|
||||
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
|
||||
async def test_setup_cleans_listeners_when_materialization_raises(
|
||||
hass, make_entry, monkeypatch
|
||||
) -> None:
|
||||
"""A startup-materialization failure rolls back every registered listener.
|
||||
|
||||
The daily and update listeners are installed before the startup
|
||||
materialization runs; when that walk raises, the database is closed but
|
||||
the remover callables must also be invoked so no listener survives
|
||||
firing against a closed database.
|
||||
"""
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
async def _boom(*_args, **_kwargs):
|
||||
raise RuntimeError("materialization failed")
|
||||
|
||||
monkeypatch.setattr(nestquest, "_materialize_run", _boom)
|
||||
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
with pytest.raises(RuntimeError, match="materialization failed"):
|
||||
await async_setup_entry(hass, entry)
|
||||
|
||||
assert hass.time_change.size == 0
|
||||
assert hass.registry.size == 0
|
||||
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
assert not hasattr(entry, "runtime_data")
|
||||
+16
-16
@@ -6,29 +6,29 @@ import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_presence import PresenceSchedulesDao
|
||||
from custom_components.nestquest.core.dao_rules import (
|
||||
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
QuestDefinitionsDao,
|
||||
schedule_rule_to_storage,
|
||||
)
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.materialize import (
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.materialize import (
|
||||
materialize,
|
||||
regenerate_for_child,
|
||||
regenerate_for_definition,
|
||||
)
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.quest_definitions import (
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
create_quest_definition,
|
||||
edit_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
ScheduleRule,
|
||||
occurs_on,
|
||||
@@ -505,7 +505,7 @@ def test_materialize_skips_deactivated_child_mid_walk(tmp_path) -> None:
|
||||
|
||||
def test_materialize_uses_single_presence_snapshot_mid_walk(tmp_path) -> None:
|
||||
async def _body(database):
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
|
||||
@@ -628,7 +628,7 @@ def test_materialize_does_not_overwrite_regenerated_due_time(tmp_path) -> None:
|
||||
|
||||
def test_materialize_honours_overrides(tmp_path) -> None:
|
||||
async def _body(database):
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
|
||||
@@ -758,7 +758,7 @@ def test_materialize_clamps_past_start_to_today(tmp_path) -> None:
|
||||
|
||||
def test_materialize_skips_inactive_definitions(tmp_path) -> None:
|
||||
async def _body(database):
|
||||
from custom_components.nestquest.core.quest_definitions import (
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
set_quest_definition_active,
|
||||
)
|
||||
|
||||
@@ -906,7 +906,7 @@ def test_regenerate_for_child_rebuilds_horizon_preserves_history(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
async def _body(database):
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
|
||||
@@ -1091,7 +1091,7 @@ def test_regenerate_for_child_uses_single_anchor_across_midnight(
|
||||
def test_regenerate_for_child_is_child_scoped(tmp_path) -> None:
|
||||
"""Regenerating one child must never touch another child's rows."""
|
||||
async def _body(database):
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
|
||||
@@ -1493,10 +1493,10 @@ def test_materialize_end_to_end_six_rule_types(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(materialize_module, "_now_stamp", _distinct_stamp)
|
||||
|
||||
async def _body(database):
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.presence import PresenceSchedule
|
||||
from custom_components.nestquest.presence import PresenceSchedule
|
||||
|
||||
children = ChildrenDao(database)
|
||||
schedules = PresenceSchedulesDao(database)
|
||||
|
||||
+134
-4
@@ -9,16 +9,16 @@ from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.schema import SCHEMA_V7_META_STATE_DDL
|
||||
from custom_components.nestquest.core.migrations import (
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.schema import SCHEMA_V7_META_STATE_DDL
|
||||
from custom_components.nestquest.migrations import (
|
||||
MIGRATIONS,
|
||||
VERSION_TABLE,
|
||||
VERSION_TABLE_DDL,
|
||||
apply_migrations,
|
||||
read_schema_version,
|
||||
)
|
||||
from custom_components.nestquest.core.schema import (
|
||||
from custom_components.nestquest.schema import (
|
||||
SCHEMA_V1_STATEMENTS,
|
||||
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
||||
)
|
||||
@@ -1064,3 +1064,133 @@ def test_partial_pending_migrations_resume_from_current_version(
|
||||
assert "step_b" in _tables(database)
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Startup wiring: setup runs the migration runner, not raw DDL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _setup_entry(hass, entry, registry) -> object:
|
||||
from tests.test_lifecycle import _wire # reuse the established wiring
|
||||
|
||||
entry = _wire(entry, registry)
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
|
||||
await async_setup_entry(hass, entry)
|
||||
return entry
|
||||
|
||||
|
||||
async def test_setup_entry_migrates_fresh_database(hass, make_entry) -> None:
|
||||
import sqlite3 as sqlite3_mod
|
||||
|
||||
from custom_components.nestquest.migrations import read_schema_version
|
||||
from custom_components.nestquest.store import async_get_db_path
|
||||
|
||||
entry = await _setup_entry(hass, make_entry(), hass.registry)
|
||||
database = entry.runtime_data.database
|
||||
assert await read_schema_version(database) == len(MIGRATIONS)
|
||||
# The v1 tables exist because the runner applied migration 1.
|
||||
names = {
|
||||
row[0]
|
||||
for row in await database.fetch_all(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' "
|
||||
"AND name NOT LIKE 'sqlite_%'"
|
||||
)
|
||||
}
|
||||
assert "children" in names and "completion_events" in names
|
||||
# The version table exists with its singleton row stamped.
|
||||
columns = await database.fetch_all(f"PRAGMA table_info({VERSION_TABLE})")
|
||||
assert [(row[1], row[3]) for row in columns] == [("id", 1), ("version", 1)]
|
||||
db_path = await async_get_db_path(hass)
|
||||
conn = sqlite3_mod.connect(db_path)
|
||||
try:
|
||||
conn.execute(f"INSERT INTO {VERSION_TABLE} (id, version) VALUES (2, 1)")
|
||||
violated = False
|
||||
except sqlite3_mod.IntegrityError:
|
||||
violated = True
|
||||
finally:
|
||||
conn.close()
|
||||
assert violated, "singleton-row CHECK must be live in the setup-created DB"
|
||||
await database.close()
|
||||
|
||||
|
||||
async def test_options_reload_reopens_and_rechecks_version(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A listener-triggered reload re-runs setup against the same file.
|
||||
|
||||
Unload then setup is the reload lifecycle: the old connection is
|
||||
closed, apply_migrations runs again on the persisted file, keeps
|
||||
version and rows intact, and the reopened connection is live.
|
||||
"""
|
||||
from custom_components.nestquest import async_unload_entry
|
||||
from custom_components.nestquest.migrations import read_schema_version
|
||||
|
||||
entry = await _setup_entry(hass, make_entry(), hass.registry)
|
||||
database_a = entry.runtime_data.database
|
||||
await database_a.execute(
|
||||
"INSERT INTO children (display_name, created_at) VALUES (?, ?)",
|
||||
("Ada", "2026-09-13T00:00:00+00:00"),
|
||||
)
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert not database_a.connected
|
||||
|
||||
entry_reloaded = await _setup_entry(hass, make_entry(), hass.registry)
|
||||
database_b = entry_reloaded.runtime_data.database
|
||||
assert database_b is not database_a
|
||||
try:
|
||||
assert await read_schema_version(database_b) == len(MIGRATIONS)
|
||||
row = await database_b.fetch_one("SELECT COUNT(*) FROM children")
|
||||
assert row == (1,)
|
||||
finally:
|
||||
await database_b.close()
|
||||
|
||||
|
||||
async def test_setup_entry_failure_closes_database(
|
||||
hass, make_entry, monkeypatch
|
||||
) -> None:
|
||||
"""A failing migration closes the opened connection before re-raising.
|
||||
|
||||
A migration failure surfaces as ConfigEntryNotReady (HA retries
|
||||
setup) with the original error preserved as __cause__. Spies on
|
||||
the real wrapper instance: records whether close() ran, rather
|
||||
than inferring from hass.data (a failed setup never stores a
|
||||
runtime record).
|
||||
"""
|
||||
import pytest as pytest_module
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
import custom_components.nestquest as nestquest_module
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import (
|
||||
MIGRATIONS as _MIGRATIONS,
|
||||
)
|
||||
|
||||
async def _failing(database, migrations=_MIGRATIONS):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(
|
||||
nestquest_module, "apply_migrations", _failing, raising=True
|
||||
)
|
||||
|
||||
close_calls: list[bool] = []
|
||||
original_close = NestQuestDatabase.close
|
||||
|
||||
async def _spy_close(self):
|
||||
close_calls.append(True)
|
||||
return await original_close(self)
|
||||
|
||||
monkeypatch.setattr(NestQuestDatabase, "close", _spy_close)
|
||||
|
||||
entry = make_entry()
|
||||
with pytest_module.raises(ConfigEntryNotReady) as excinfo:
|
||||
await _setup_entry(hass, entry, hass.registry)
|
||||
assert isinstance(excinfo.value.__cause__, RuntimeError)
|
||||
assert "boom" in str(excinfo.value.__cause__)
|
||||
|
||||
assert close_calls == [True], (
|
||||
"failed setup must close the connection it opened"
|
||||
)
|
||||
record = hass.data["nestquest"].get(entry.entry_id)
|
||||
assert record is None, "failed setup must not store runtime data"
|
||||
+452
-212
@@ -14,16 +14,27 @@ from conftest import make_config_entry, make_hass, options_flow_base as _options
|
||||
from custom_components.nestquest import config_flow
|
||||
from custom_components.nestquest import options_flow
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_API_BASE_URL,
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
CONF_PANEL_TOKEN,
|
||||
CONF_SNAPSHOT_STALENESS,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_API_BASE_URL,
|
||||
DEFAULT_PANEL_IDLE_TIMEOUT,
|
||||
DEFAULT_PANEL_TOKEN,
|
||||
DEFAULT_SNAPSHOT_STALENESS,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_NOTIFY_TARGET,
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_PANEL_IDLE_TIMEOUT,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
@@ -32,19 +43,25 @@ STRINGS_PATH = PKG_DIR / "strings.json"
|
||||
TRANSLATIONS_EN_PATH = PKG_DIR / "translations" / "en.json"
|
||||
|
||||
VALID_INPUT = {
|
||||
CONF_HORIZON_DAYS: 7,
|
||||
CONF_DAY_ROLLOVER_TIME: "03:30",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 60,
|
||||
}
|
||||
|
||||
#: What a schema-validated submit produces from a bare VALID_INPUT on a
|
||||
#: fresh entry: the Feature 10 refresh interval, the Feature 18
|
||||
#: staleness threshold, and the Feature 18 API connection fill from
|
||||
#: their defaults.
|
||||
#: fresh entry: the Feature 10 refresh interval and the Feature 11
|
||||
#: notification section fill from their defaults.
|
||||
FULL_INPUT = {
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
||||
CONF_SNAPSHOT_STALENESS: DEFAULT_SNAPSHOT_STALENESS,
|
||||
CONF_API_BASE_URL: DEFAULT_API_BASE_URL,
|
||||
CONF_PANEL_TOKEN: DEFAULT_PANEL_TOKEN,
|
||||
CONF_NOTIFY_TARGET: "",
|
||||
CONF_MORNING_SUMMARY_TIME: DEFAULT_MORNING_SUMMARY_TIME,
|
||||
CONF_AFTERNOON_REMINDER_TIME: DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_TIME: DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
CONF_MORNING_SUMMARY_ENABLED: DEFAULT_AUTOMATION_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED: DEFAULT_AUTOMATION_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED: DEFAULT_AUTOMATION_ENABLED,
|
||||
CONF_CELEBRATION_ENABLED: DEFAULT_AUTOMATION_ENABLED,
|
||||
}
|
||||
|
||||
|
||||
@@ -60,13 +77,48 @@ def _make_entry(entry_id: str = "test_entry", options: dict | None = None, data:
|
||||
return make_config_entry(entry_id=entry_id, options=options, data=data)
|
||||
|
||||
|
||||
def _make_flow(entry) -> options_flow.NestQuestOptionsFlow:
|
||||
"""Create an options flow instance bound to a stubbed config entry."""
|
||||
def _make_flow(entry, *, users=None, database=None) -> options_flow.NestQuestOptionsFlow:
|
||||
"""Create an options flow instance bound to a stubbed config entry.
|
||||
|
||||
``users`` populates a stubbed hass.auth for the admin picker;
|
||||
``database`` wires the runtime record so the flow can read and
|
||||
write the allowlist like real HA's options flow does.
|
||||
"""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
|
||||
flow.hass = SimpleNamespace()
|
||||
flow.hass.auth = SimpleNamespace(
|
||||
async_get_users=AsyncMock(return_value=users or [])
|
||||
)
|
||||
if database is not None:
|
||||
from custom_components.nestquest.const import DOMAIN
|
||||
|
||||
flow.hass.data = {
|
||||
DOMAIN: {entry.entry_id: SimpleNamespace(database=database)}
|
||||
}
|
||||
return flow
|
||||
|
||||
|
||||
def _user(user_id: str, name: str):
|
||||
return SimpleNamespace(id=user_id, name=name)
|
||||
|
||||
|
||||
def _open_allowlist_db(tmp_path, name):
|
||||
"""A real migrated temp database for the flow to read and write."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
|
||||
hass = MagicMock()
|
||||
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
_run(database.open(tmp_path / name))
|
||||
_run(apply_migrations(database))
|
||||
return database
|
||||
|
||||
|
||||
def _schema_values(schema) -> dict:
|
||||
"""Extract the per-field default values from a real voluptuous schema."""
|
||||
return {
|
||||
@@ -93,11 +145,11 @@ def test_options_flow_get_options_flow_is_ha_callback() -> None:
|
||||
|
||||
def test_options_flow_constructs_through_2024_6_surface() -> None:
|
||||
"""The handler constructs against the mocked 2024.6 base and inherits its contract."""
|
||||
entry = _make_entry(options={CONF_UPDATE_INTERVAL: 90})
|
||||
entry = _make_entry(options={CONF_HORIZON_DAYS: 21})
|
||||
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
|
||||
assert isinstance(flow, _options_flow_base())
|
||||
assert flow.config_entry is entry
|
||||
assert flow.options == {CONF_UPDATE_INTERVAL: 90}
|
||||
assert flow.options == {CONF_HORIZON_DAYS: 21}
|
||||
|
||||
|
||||
def test_options_flow_submits_through_2024_6_surface() -> None:
|
||||
@@ -132,66 +184,82 @@ def test_options_flow_prefills_from_defaults() -> None:
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(flow.async_step_init(None))
|
||||
values = _schema_values(result["data_schema"])
|
||||
assert values[CONF_HORIZON_DAYS] == DEFAULT_HORIZON_DAYS
|
||||
assert values[CONF_DAY_ROLLOVER_TIME] == DEFAULT_DAY_ROLLOVER_TIME
|
||||
assert values[CONF_PANEL_IDLE_TIMEOUT] == DEFAULT_PANEL_IDLE_TIMEOUT
|
||||
assert values[CONF_UPDATE_INTERVAL] == DEFAULT_UPDATE_INTERVAL
|
||||
assert values[CONF_SNAPSHOT_STALENESS] == DEFAULT_SNAPSHOT_STALENESS
|
||||
|
||||
|
||||
def test_options_flow_prefills_from_entry_options() -> None:
|
||||
"""Stored entry options win over entry data and defaults."""
|
||||
entry = _make_entry(
|
||||
options={
|
||||
CONF_HORIZON_DAYS: 21,
|
||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||
CONF_UPDATE_INTERVAL: 90,
|
||||
CONF_SNAPSHOT_STALENESS: 450,
|
||||
},
|
||||
data={
|
||||
CONF_HORIZON_DAYS: 99,
|
||||
CONF_DAY_ROLLOVER_TIME: "23:59",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 999,
|
||||
CONF_UPDATE_INTERVAL: 999,
|
||||
CONF_SNAPSHOT_STALENESS: 999,
|
||||
},
|
||||
)
|
||||
result = _run(_make_flow(entry).async_step_init(None))
|
||||
values = _schema_values(result["data_schema"])
|
||||
assert values[CONF_HORIZON_DAYS] == 21
|
||||
assert values[CONF_DAY_ROLLOVER_TIME] == "05:45"
|
||||
assert values[CONF_PANEL_IDLE_TIMEOUT] == 120
|
||||
assert values[CONF_UPDATE_INTERVAL] == 90
|
||||
assert values[CONF_SNAPSHOT_STALENESS] == 450
|
||||
|
||||
|
||||
def test_options_flow_falls_back_to_entry_data() -> None:
|
||||
"""Pre-upgrade entries without options fall back to entry.data."""
|
||||
entry = _make_entry(
|
||||
data={
|
||||
CONF_HORIZON_DAYS: 10,
|
||||
CONF_DAY_ROLLOVER_TIME: "01:15",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 90,
|
||||
CONF_UPDATE_INTERVAL: 60,
|
||||
CONF_SNAPSHOT_STALENESS: 240,
|
||||
}
|
||||
)
|
||||
result = _run(_make_flow(entry).async_step_init(None))
|
||||
values = _schema_values(result["data_schema"])
|
||||
assert values[CONF_HORIZON_DAYS] == 10
|
||||
assert values[CONF_DAY_ROLLOVER_TIME] == "01:15"
|
||||
assert values[CONF_PANEL_IDLE_TIMEOUT] == 90
|
||||
assert values[CONF_UPDATE_INTERVAL] == 60
|
||||
assert values[CONF_SNAPSHOT_STALENESS] == 240
|
||||
|
||||
|
||||
def test_options_flow_schema_applies_stored_defaults() -> None:
|
||||
"""A real voluptuous validate({}) fills every field from the stored defaults."""
|
||||
"""A real voluptuous validate({}) fills every field from the stored defaults.
|
||||
|
||||
Without a runtime database (and no auth in the bare harness), the
|
||||
admin picker's default is the empty selection and its choice set is
|
||||
empty — the three legacy settings are unaffected.
|
||||
"""
|
||||
entry = _make_entry(
|
||||
options={
|
||||
CONF_HORIZON_DAYS: 21,
|
||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||
CONF_UPDATE_INTERVAL: 90,
|
||||
CONF_SNAPSHOT_STALENESS: 450,
|
||||
CONF_API_BASE_URL: "http://panel.lan:8443",
|
||||
CONF_PANEL_TOKEN: "stored-token",
|
||||
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
|
||||
CONF_MORNING_SUMMARY_TIME: "07:00",
|
||||
CONF_AFTERNOON_REMINDER_TIME: "14:00",
|
||||
CONF_END_OF_DAY_REPORT_TIME: "21:30",
|
||||
}
|
||||
)
|
||||
result = _run(_make_flow(entry).async_step_init(None))
|
||||
assert result["data_schema"]({}) == {
|
||||
CONF_HORIZON_DAYS: 21,
|
||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||
CONF_UPDATE_INTERVAL: 90,
|
||||
CONF_SNAPSHOT_STALENESS: 450,
|
||||
CONF_API_BASE_URL: "http://panel.lan:8443",
|
||||
CONF_PANEL_TOKEN: "stored-token",
|
||||
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
|
||||
CONF_MORNING_SUMMARY_TIME: "07:00",
|
||||
CONF_AFTERNOON_REMINDER_TIME: "14:00",
|
||||
CONF_END_OF_DAY_REPORT_TIME: "21:30",
|
||||
CONF_MORNING_SUMMARY_ENABLED: True,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED: True,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED: True,
|
||||
CONF_CELEBRATION_ENABLED: True,
|
||||
CONF_ADMIN_USER_IDS: [],
|
||||
}
|
||||
|
||||
|
||||
@@ -201,12 +269,50 @@ def test_options_flow_schema_coerces_field_types() -> None:
|
||||
result = _run(flow.async_step_init(None))
|
||||
schema = result["data_schema"]
|
||||
validators = {marker.schema: validator for marker, validator in schema.schema.items()}
|
||||
assert validators[CONF_HORIZON_DAYS] is int
|
||||
assert validators[CONF_DAY_ROLLOVER_TIME] is str
|
||||
assert validators[CONF_PANEL_IDLE_TIMEOUT] is int
|
||||
assert validators[CONF_UPDATE_INTERVAL] is int
|
||||
assert validators[CONF_SNAPSHOT_STALENESS] is int
|
||||
# The Feature 18 API connection fields are free-text strings.
|
||||
assert validators[CONF_API_BASE_URL] is str
|
||||
assert validators[CONF_PANEL_TOKEN] is str
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_horizon", [0, -5])
|
||||
def test_options_flow_rejects_non_positive_horizon(bad_horizon: int) -> None:
|
||||
"""Zero or negative horizon values yield a field-level error, no exception."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_HORIZON_DAYS: bad_horizon})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_HORIZON_DAYS: "invalid"}
|
||||
|
||||
|
||||
def test_options_flow_rejects_malformed_time_not_a_time() -> None:
|
||||
"""Non-HH:MM strings are rejected with invalid_time."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "noon"})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"}
|
||||
|
||||
|
||||
def test_options_flow_rejects_malformed_time_out_of_range() -> None:
|
||||
"""Out-of-range HH:MM values are rejected with invalid_time."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "25:99"})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"}
|
||||
|
||||
|
||||
def test_options_flow_rejects_pasted_unicode_superscript_time() -> None:
|
||||
"""Pasted Unicode digits like '²²:²²' yield a field-level error, no exception."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "²²:²²"})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_timeout", ["abc", 0, -10, 10])
|
||||
@@ -220,21 +326,8 @@ def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None:
|
||||
assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_interval", ["abc", True, 0, -10, 29])
|
||||
def test_options_flow_rejects_invalid_update_interval(bad_interval) -> None:
|
||||
"""Non-numeric, boolean, and below-floor update intervals reject."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_UPDATE_INTERVAL: bad_interval})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_UPDATE_INTERVAL: "invalid"}
|
||||
|
||||
|
||||
def test_options_flow_creates_entry_with_validated_keys() -> None:
|
||||
"""A valid submit creates an entry with exactly the validated keys
|
||||
(the client-facing settings, filled from defaults where the raw
|
||||
submit omitted them)."""
|
||||
def test_options_flow_creates_entry_with_four_keys() -> None:
|
||||
"""A valid submit creates an entry with exactly the four validated keys."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
@@ -249,7 +342,7 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once(
|
||||
entry = _make_entry()
|
||||
entry.add_update_listener = lambda listener: registry.add(entry.entry_id, listener)
|
||||
|
||||
# Simulate setup registering the lifecycle update listener.
|
||||
# Simulate setup registering the merged lifecycle update listener.
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
|
||||
_run(async_setup_entry(hass, entry))
|
||||
@@ -268,13 +361,14 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once(
|
||||
|
||||
def test_options_flow_validation_never_raises() -> None:
|
||||
"""Validation returns errors instead of raising for arbitrary junk input."""
|
||||
junk = {CONF_PANEL_IDLE_TIMEOUT: None, CONF_UPDATE_INTERVAL: "abc"}
|
||||
junk = {CONF_HORIZON_DAYS: None, CONF_DAY_ROLLOVER_TIME: None, CONF_PANEL_IDLE_TIMEOUT: None}
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(flow.async_step_init(junk))
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {
|
||||
CONF_HORIZON_DAYS: "invalid",
|
||||
CONF_DAY_ROLLOVER_TIME: "invalid_time",
|
||||
CONF_PANEL_IDLE_TIMEOUT: "invalid",
|
||||
CONF_UPDATE_INTERVAL: "invalid",
|
||||
}
|
||||
|
||||
|
||||
@@ -304,10 +398,10 @@ def test_options_strings_present_and_matching(strings: dict, translations_en: di
|
||||
assert init["title"]
|
||||
assert init["description"]
|
||||
data = init["data"]
|
||||
for key in (CONF_PANEL_IDLE_TIMEOUT,):
|
||||
for key in (CONF_HORIZON_DAYS, CONF_DAY_ROLLOVER_TIME, CONF_PANEL_IDLE_TIMEOUT):
|
||||
assert data.get(key), f"Missing field name for '{key}'"
|
||||
errors = options["errors"]
|
||||
for key in ("invalid",):
|
||||
for key in ("invalid", "invalid_time"):
|
||||
assert errors.get(key), f"Missing error string for '{key}'"
|
||||
|
||||
|
||||
@@ -363,199 +457,345 @@ def test_options_flow_no_hardcoded_domain_or_db_filename() -> None:
|
||||
f"Hard-coded database filename in {py_file.name}:{node.lineno}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel-plane API connection section (Feature 18)
|
||||
# Admin allowlist picker (Feature 03)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_options_flow_api_section_defaults_saved() -> None:
|
||||
"""A bare submit saves the API connection defaults: the const base
|
||||
URL and an EMPTY panel token (no token is ever invented)."""
|
||||
def test_options_flow_picker_lists_existing_users(tmp_path) -> None:
|
||||
"""The picker offers EXISTING HA users (id -> name), never free text."""
|
||||
import voluptuous as vol
|
||||
|
||||
database = _open_allowlist_db(tmp_path, "picker-users.db")
|
||||
try:
|
||||
from custom_components.nestquest.admin_allowlist import set_admin_ids
|
||||
|
||||
_run(set_admin_ids(database, ["u1"]))
|
||||
entry = _make_entry()
|
||||
flow = _make_flow(
|
||||
entry,
|
||||
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
|
||||
database=database,
|
||||
)
|
||||
result = _run(flow.async_step_init(None))
|
||||
schema = result["data_schema"]
|
||||
marker = next(
|
||||
m for m in schema.schema
|
||||
if getattr(m, "schema", None) == CONF_ADMIN_USER_IDS
|
||||
)
|
||||
# Default is the CURRENT database allowlist.
|
||||
values = _schema_values(schema)
|
||||
assert values[CONF_ADMIN_USER_IDS] == ["u1"]
|
||||
# Both users are selectable; an unknown id is rejected by the
|
||||
# schema itself (this is what real HA submits go through).
|
||||
validated = schema(
|
||||
{
|
||||
CONF_HORIZON_DAYS: 7,
|
||||
CONF_DAY_ROLLOVER_TIME: "03:30",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 60,
|
||||
CONF_ADMIN_USER_IDS: ["u1", "u2"],
|
||||
}
|
||||
)
|
||||
assert validated[CONF_ADMIN_USER_IDS] == ["u1", "u2"]
|
||||
with pytest.raises(vol.Invalid):
|
||||
schema(
|
||||
{
|
||||
CONF_HORIZON_DAYS: 7,
|
||||
CONF_DAY_ROLLOVER_TIME: "03:30",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 60,
|
||||
CONF_ADMIN_USER_IDS: ["impostor"],
|
||||
}
|
||||
)
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_options_flow_submit_applies_selection_to_database(tmp_path) -> None:
|
||||
database = _open_allowlist_db(tmp_path, "picker-apply.db")
|
||||
try:
|
||||
from custom_components.nestquest.admin_allowlist import (
|
||||
list_admin_ids,
|
||||
set_admin_ids,
|
||||
)
|
||||
|
||||
_run(set_admin_ids(database, ["u1"]))
|
||||
entry = _make_entry()
|
||||
flow = _make_flow(
|
||||
entry,
|
||||
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
|
||||
database=database,
|
||||
)
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_ADMIN_USER_IDS: ["u2"]})
|
||||
)
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_ADMIN_USER_IDS] == ["u2"]
|
||||
# The allowlist (and the entry's recovery copy) carry the pick.
|
||||
assert await_gather(list_admin_ids(database)) == ["u2"]
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def await_gather(coro):
|
||||
return _run(coro)
|
||||
|
||||
|
||||
def test_options_flow_empty_selection_refused(tmp_path) -> None:
|
||||
"""Saving zero admins is refused: an empty allowlist fails closed."""
|
||||
database = _open_allowlist_db(tmp_path, "picker-empty.db")
|
||||
try:
|
||||
from custom_components.nestquest.admin_allowlist import (
|
||||
list_admin_ids,
|
||||
set_admin_ids,
|
||||
)
|
||||
|
||||
_run(set_admin_ids(database, ["u1"]))
|
||||
entry = _make_entry()
|
||||
flow = _make_flow(
|
||||
entry,
|
||||
users=[_user("u1", "Joshua")],
|
||||
database=database,
|
||||
)
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_ADMIN_USER_IDS: []})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_ADMIN_USER_IDS: "no_admins"}
|
||||
assert await_gather(list_admin_ids(database)) == ["u1"]
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_options_flow_unknown_selection_refused(tmp_path) -> None:
|
||||
database = _open_allowlist_db(tmp_path, "picker-unknown.db")
|
||||
try:
|
||||
from custom_components.nestquest.admin_allowlist import (
|
||||
list_admin_ids,
|
||||
set_admin_ids,
|
||||
)
|
||||
|
||||
_run(set_admin_ids(database, ["u1"]))
|
||||
entry = _make_entry()
|
||||
flow = _make_flow(
|
||||
entry,
|
||||
users=[_user("u1", "Joshua")],
|
||||
database=database,
|
||||
)
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{**VALID_INPUT, CONF_ADMIN_USER_IDS: ["impostor"]}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_ADMIN_USER_IDS: "invalid_admin"}
|
||||
assert await_gather(list_admin_ids(database)) == ["u1"]
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_options_flow_submit_without_admin_key_keeps_allowlist(tmp_path) -> None:
|
||||
"""Raw callers omitting the key (pre-picker payloads) do not
|
||||
touch the allowlist; the CURRENT allowlist is carried forward on
|
||||
the options copy so no removed admin can be resurrected later."""
|
||||
database = _open_allowlist_db(tmp_path, "picker-legacy.db")
|
||||
try:
|
||||
from custom_components.nestquest.admin_allowlist import (
|
||||
list_admin_ids,
|
||||
set_admin_ids,
|
||||
)
|
||||
|
||||
_run(set_admin_ids(database, ["u1"]))
|
||||
entry = _make_entry()
|
||||
flow = _make_flow(
|
||||
entry,
|
||||
users=[_user("u1", "Joshua")],
|
||||
database=database,
|
||||
)
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_ADMIN_USER_IDS] == ["u1"]
|
||||
assert await_gather(list_admin_ids(database)) == ["u1"]
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_options_flow_duplicate_selection_refused(tmp_path) -> None:
|
||||
"""Duplicate ids surface as a field error, not an unhandled
|
||||
ValueError from the business layer."""
|
||||
database = _open_allowlist_db(tmp_path, "picker-dupe.db")
|
||||
try:
|
||||
from custom_components.nestquest.admin_allowlist import (
|
||||
list_admin_ids,
|
||||
set_admin_ids,
|
||||
)
|
||||
|
||||
_run(set_admin_ids(database, ["u1"]))
|
||||
entry = _make_entry()
|
||||
flow = _make_flow(
|
||||
entry,
|
||||
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
|
||||
database=database,
|
||||
)
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{**VALID_INPUT, CONF_ADMIN_USER_IDS: ["u2", "u2"]}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_ADMIN_USER_IDS: "invalid_admin"}
|
||||
assert await_gather(list_admin_ids(database)) == ["u1"]
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_options_flow_omitted_picker_carries_allowlist_forward(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""An omitted picker field must not drop the allowlist copy from
|
||||
the returned options: HA replaces entry.options wholesale, and a
|
||||
later database loss must not resurrect removed admins from the
|
||||
stale config-flow copy in entry.data."""
|
||||
database = _open_allowlist_db(tmp_path, "picker-carry.db")
|
||||
try:
|
||||
from custom_components.nestquest.admin_allowlist import (
|
||||
set_admin_ids,
|
||||
)
|
||||
|
||||
_run(set_admin_ids(database, ["u2"]))
|
||||
entry = _make_entry()
|
||||
flow = _make_flow(
|
||||
entry,
|
||||
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
|
||||
database=database,
|
||||
)
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
# The current allowlist rides along on the legacy three-key path.
|
||||
assert result["data"][CONF_ADMIN_USER_IDS] == ["u2"]
|
||||
assert result["data"][CONF_HORIZON_DAYS] == VALID_INPUT[CONF_HORIZON_DAYS]
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_options_flow_notification_section_defaults() -> None:
|
||||
"""A bare submit fills the notification section from const defaults:
|
||||
08:00 / 15:00 / 20:00 — the report deliberately BEFORE the midnight
|
||||
rollover sweep — an empty notify target, and all toggles enabled."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_API_BASE_URL] == DEFAULT_API_BASE_URL
|
||||
assert result["data"][CONF_PANEL_TOKEN] == ""
|
||||
assert result["data"][CONF_MORNING_SUMMARY_TIME] == "08:00"
|
||||
assert result["data"][CONF_AFTERNOON_REMINDER_TIME] == "15:00"
|
||||
assert result["data"][CONF_END_OF_DAY_REPORT_TIME] == "20:00"
|
||||
assert result["data"][CONF_NOTIFY_TARGET] == ""
|
||||
for toggle in (
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
):
|
||||
assert result["data"][toggle] is True
|
||||
|
||||
|
||||
def test_options_flow_api_base_url_must_be_full_http_url() -> None:
|
||||
"""Scheme-less, host-less, empty, and non-string base URLs reject
|
||||
with a field error — the same rule the client's constructor
|
||||
enforces, so a malformed URL fails at the form, not at the first
|
||||
request."""
|
||||
def test_options_flow_notification_times_must_be_strict_hh_mm() -> None:
|
||||
flow = _make_flow(_make_entry())
|
||||
for bad_url in ("api.lan:8000", "http://", "", " ", 42):
|
||||
for bad_time in ("9:00", "25:00", "08:0", "08-00", 800):
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{**VALID_INPUT, CONF_API_BASE_URL: bad_url}
|
||||
{
|
||||
**VALID_INPUT,
|
||||
CONF_MORNING_SUMMARY_TIME: bad_time,
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_API_BASE_URL: "invalid_url"}
|
||||
assert result["errors"] == {CONF_MORNING_SUMMARY_TIME: "invalid_time"}
|
||||
|
||||
|
||||
def test_options_flow_panel_token_validation() -> None:
|
||||
"""The EMPTY token is valid (the API connection is simply not
|
||||
configured yet); padded and non-string values reject — a padded
|
||||
paste would fail the token check at request time instead."""
|
||||
def test_options_flow_notify_target_validation() -> None:
|
||||
"""The empty target is VALID (it is the shipped default and must be
|
||||
savable); only malformed non-empty values reject."""
|
||||
flow = _make_flow(_make_entry())
|
||||
# The empty default saves cleanly.
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_PANEL_TOKEN: ""})
|
||||
flow.async_step_init(
|
||||
{**VALID_INPUT, CONF_NOTIFY_TARGET: ""}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_PANEL_TOKEN] == ""
|
||||
assert result["data"][CONF_NOTIFY_TARGET] == ""
|
||||
|
||||
for bad_token in (" secret ", " ", 42):
|
||||
# Malformed non-empty values reject.
|
||||
for bad_target in (" notify.x ", " ", 42):
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{**VALID_INPUT, CONF_PANEL_TOKEN: bad_token}
|
||||
{
|
||||
**VALID_INPUT,
|
||||
CONF_NOTIFY_TARGET: bad_target,
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_PANEL_TOKEN: "invalid"}
|
||||
assert result["errors"] == {CONF_NOTIFY_TARGET: "invalid"}
|
||||
|
||||
|
||||
def test_options_flow_api_section_stored_values_round_trip() -> None:
|
||||
"""Stored API settings are the form's defaults and a raw legacy
|
||||
def test_options_flow_toggles_must_be_real_booleans() -> None:
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{
|
||||
**VALID_INPUT,
|
||||
CONF_CELEBRATION_ENABLED: "on",
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_CELEBRATION_ENABLED: "invalid"}
|
||||
|
||||
|
||||
def test_options_flow_notification_stored_values_round_trip() -> None:
|
||||
"""Stored notification settings are the form's defaults and a raw
|
||||
submit without them keeps the stored values."""
|
||||
entry = _make_entry(
|
||||
options={
|
||||
**VALID_INPUT,
|
||||
CONF_API_BASE_URL: "http://panel.lan:8443",
|
||||
CONF_PANEL_TOKEN: "stored-token",
|
||||
CONF_UPDATE_INTERVAL: 90,
|
||||
CONF_NOTIFY_TARGET: "notify.mobile_app_dad",
|
||||
CONF_MORNING_SUMMARY_TIME: "07:15",
|
||||
CONF_AFTERNOON_REMINDER_TIME: "16:45",
|
||||
CONF_END_OF_DAY_REPORT_TIME: "21:00",
|
||||
CONF_MORNING_SUMMARY_ENABLED: False,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED: True,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED: False,
|
||||
CONF_CELEBRATION_ENABLED: True,
|
||||
}
|
||||
)
|
||||
flow = _make_flow(entry)
|
||||
form = _run(flow.async_step_init(None))
|
||||
schema_defaults = form["data_schema"]({})
|
||||
assert schema_defaults[CONF_API_BASE_URL] == "http://panel.lan:8443"
|
||||
assert schema_defaults[CONF_PANEL_TOKEN] == "stored-token"
|
||||
assert schema_defaults[CONF_NOTIFY_TARGET] == "notify.mobile_app_dad"
|
||||
assert schema_defaults[CONF_MORNING_SUMMARY_TIME] == "07:15"
|
||||
assert schema_defaults[CONF_END_OF_DAY_REPORT_ENABLED] is False
|
||||
|
||||
# A raw submit carrying only the panel-idle key keeps the stored
|
||||
# API connection settings.
|
||||
# A raw submit carrying only the legacy keys keeps the stored
|
||||
# notification settings.
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_API_BASE_URL] == "http://panel.lan:8443"
|
||||
assert result["data"][CONF_PANEL_TOKEN] == "stored-token"
|
||||
assert result["data"][CONF_NOTIFY_TARGET] == "notify.mobile_app_dad"
|
||||
assert result["data"][CONF_MORNING_SUMMARY_TIME] == "07:15"
|
||||
assert result["data"][CONF_MORNING_SUMMARY_ENABLED] is False
|
||||
assert result["data"][CONF_CELEBRATION_ENABLED] is True
|
||||
|
||||
|
||||
def test_options_flow_strings_cover_api_section(
|
||||
strings: dict, translations_en: dict
|
||||
) -> None:
|
||||
"""Both string files name the API fields and the URL error."""
|
||||
data = strings["options"]["step"]["init"]["data"]
|
||||
for key in (CONF_API_BASE_URL, CONF_PANEL_TOKEN):
|
||||
assert data.get(key), f"Missing field name for '{key}'"
|
||||
assert strings["options"]["errors"]["invalid_url"]
|
||||
assert translations_en["options"] == strings["options"]
|
||||
def test_options_flow_no_hard_coded_personal_target_in_source() -> None:
|
||||
"""The notify target's DEFAULT is the empty string: the household
|
||||
fills their own service name, and no personal target ships as a
|
||||
value anywhere in the const/flow defaults."""
|
||||
from custom_components.nestquest import const
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Last-good snapshot staleness section (Feature 18)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_options_flow_staleness_below_update_interval_refused() -> None:
|
||||
"""A staleness threshold shorter than the submit's update interval
|
||||
is refused with the dedicated error: such a threshold could never
|
||||
serve even one cached snapshot."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: 300,
|
||||
CONF_SNAPSHOT_STALENESS: 60,
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid_staleness"}
|
||||
|
||||
|
||||
def test_options_flow_staleness_below_stored_interval_refused() -> None:
|
||||
"""A raw submit setting the staleness but omitting the update
|
||||
interval is checked against the STORED interval, not the default."""
|
||||
entry = _make_entry(options={CONF_UPDATE_INTERVAL: 300})
|
||||
flow = _make_flow(entry)
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_SNAPSHOT_STALENESS: 60})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid_staleness"}
|
||||
|
||||
|
||||
def test_options_flow_staleness_must_be_positive_int() -> None:
|
||||
"""Non-int, boolean, and non-positive staleness values reject with
|
||||
the plain invalid error."""
|
||||
flow = _make_flow(_make_entry())
|
||||
for bad_staleness in ("900", True, 0, -10):
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{**VALID_INPUT, CONF_SNAPSHOT_STALENESS: bad_staleness}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid"}
|
||||
|
||||
|
||||
def test_options_flow_staleness_equal_to_interval_saves() -> None:
|
||||
"""A threshold exactly as long as the update interval is valid (the
|
||||
boundary is inclusive)."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: 300,
|
||||
CONF_SNAPSHOT_STALENESS: 300,
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_SNAPSHOT_STALENESS] == 300
|
||||
|
||||
|
||||
def test_options_flow_staleness_defaults_saved() -> None:
|
||||
"""A bare submit saves the staleness default (three update
|
||||
intervals) alongside the other defaults."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_SNAPSHOT_STALENESS] == DEFAULT_SNAPSHOT_STALENESS
|
||||
|
||||
|
||||
def test_options_flow_staleness_stored_values_round_trip() -> None:
|
||||
"""A stored staleness is the form's default and a raw legacy submit
|
||||
without it keeps the stored value."""
|
||||
entry = _make_entry(
|
||||
options={
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: 300,
|
||||
CONF_SNAPSHOT_STALENESS: 1200,
|
||||
}
|
||||
)
|
||||
flow = _make_flow(entry)
|
||||
form = _run(flow.async_step_init(None))
|
||||
schema_defaults = form["data_schema"]({})
|
||||
assert schema_defaults[CONF_SNAPSHOT_STALENESS] == 1200
|
||||
|
||||
# A raw submit carrying only the panel-idle key keeps the stored
|
||||
# staleness (and never trips the interval cross-check).
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_SNAPSHOT_STALENESS] == 1200
|
||||
|
||||
|
||||
def test_options_flow_strings_cover_staleness_section(
|
||||
strings: dict, translations_en: dict
|
||||
) -> None:
|
||||
"""Both string files name the staleness field and its error."""
|
||||
data = strings["options"]["step"]["init"]["data"]
|
||||
assert data.get(CONF_SNAPSHOT_STALENESS), (
|
||||
f"Missing field name for '{CONF_SNAPSHOT_STALENESS}'"
|
||||
)
|
||||
assert strings["options"]["errors"]["invalid_staleness"]
|
||||
assert translations_en["options"] == strings["options"]
|
||||
assert getattr(const, "CONF_NOTIFY_TARGET", None) == "notify_target"
|
||||
# No const default carries a concrete notify service.
|
||||
assert const.__dict__.get("DEFAULT_NOTIFY_TARGET", "") == ""
|
||||
form = _run(_make_flow(_make_entry()).async_step_init(None))
|
||||
assert form["data_schema"]({})[CONF_NOTIFY_TARGET] == ""
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Feature 09 permission test matrix across every registered service."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
from homeassistant.exceptions import Unauthorized
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DOMAIN,
|
||||
DOMAIN_SERVICES,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
)
|
||||
from custom_components.nestquest.service_policy import (
|
||||
ADMIN_ONLY,
|
||||
OPEN,
|
||||
SERVICE_POLICY,
|
||||
)
|
||||
|
||||
ADMIN_ID = "admin-1"
|
||||
ADMIN_CTX = {"user_id": ADMIN_ID}
|
||||
NON_ADMIN_ID = "non-admin"
|
||||
NON_ADMIN_CTX = {"user_id": NON_ADMIN_ID}
|
||||
KIOSK_ID = "kiosk-panel"
|
||||
KIOSK_CTX = {"user_id": KIOSK_ID}
|
||||
|
||||
|
||||
def test_every_registered_service_is_in_the_operation_registry() -> None:
|
||||
"""The registry is total: a service outside it could never be gated."""
|
||||
missing = [
|
||||
service for service in DOMAIN_SERVICES if service not in SERVICE_POLICY
|
||||
]
|
||||
extra = [
|
||||
service for service in SERVICE_POLICY if service not in DOMAIN_SERVICES
|
||||
]
|
||||
assert not missing, f"registered but ungated: {missing}"
|
||||
assert not extra, f"gated but never registered: {extra}"
|
||||
assert set(SERVICE_POLICY.values()) == {OPEN, ADMIN_ONLY}
|
||||
|
||||
|
||||
async def _setup(hass, make_entry, *, admin=True):
|
||||
data = {CONF_ADMIN_USER_IDS: [ADMIN_ID]} if admin else {}
|
||||
entry = wire_entry_to_registry(make_entry(data=data), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
return entry
|
||||
|
||||
|
||||
async def _seed(hass, entry) -> dict:
|
||||
"""Seed one child, one definition, and materialized instances."""
|
||||
from custom_components.nestquest.children import list_children
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
database = entry.runtime_data.database
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
{
|
||||
"title": "Brush teeth",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [(await list_children(database))[0].id],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||
child = (await list_children(database))[0]
|
||||
today = datetime.date.today().isoformat()
|
||||
end = (
|
||||
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||
).isoformat()
|
||||
instances = await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, today, end
|
||||
)
|
||||
assert instances
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
|
||||
return {
|
||||
"child_id": child.id,
|
||||
"instance_id": instances[0].id,
|
||||
"definition_id": instances[0].definition_id,
|
||||
"override": await PresenceOverridesDao(database).create(
|
||||
child.id, "2027-01-01", "2027-01-02", False
|
||||
),
|
||||
"today": today,
|
||||
"database": database,
|
||||
}
|
||||
|
||||
|
||||
def _admin_success_payload(service: str, ctx: dict) -> dict | None:
|
||||
"""A schema-valid, end-to-end-successful payload per admin service."""
|
||||
if service == SERVICE_UNCOMPLETE_QUEST:
|
||||
return {"instance_id": ctx["instance_id"], "actor": "user"}
|
||||
if service == SERVICE_CREATE_QUEST_DEFINITION:
|
||||
return {
|
||||
"title": "Second quest",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [ctx["child_id"]],
|
||||
"windows": ["afternoon"],
|
||||
}
|
||||
if service == SERVICE_UPDATE_QUEST_DEFINITION:
|
||||
return {"definition_id": ctx["definition_id"], "title": "Brush teeth 2"}
|
||||
if service == SERVICE_SET_QUEST_DEFINITION_ACTIVE:
|
||||
return {"definition_id": ctx["definition_id"], "is_active": False}
|
||||
if service == SERVICE_SET_PRESENCE_PATTERN:
|
||||
return {
|
||||
"child_id": ctx["child_id"],
|
||||
"cycle_length_weeks": 1,
|
||||
"anchor_date": ctx["today"],
|
||||
"pattern": {0: [0, 1, 2, 3, 4, 5, 6]},
|
||||
}
|
||||
if service == SERVICE_CREATE_PRESENCE_OVERRIDE:
|
||||
return {
|
||||
"child_id": ctx["child_id"],
|
||||
"start_date": "2027-03-01",
|
||||
"end_date": "2027-03-02",
|
||||
"is_present": False,
|
||||
}
|
||||
if service == SERVICE_DELETE_PRESENCE_OVERRIDE:
|
||||
return {"override_id": ctx["override"].id}
|
||||
if service == SERVICE_EXPORT_HISTORY_CSV:
|
||||
return None # raises HomeAssistantError (Feature 13) — not Unauthorized
|
||||
if service == SERVICE_MANAGE_CHILD:
|
||||
return {"action": "create", "display_name": "Bo"}
|
||||
if service == SERVICE_REGENERATE:
|
||||
return {}
|
||||
raise AssertionError(f"unexpected service {service}")
|
||||
|
||||
|
||||
_ADMIN_ONLY = [
|
||||
service for service, policy in SERVICE_POLICY.items() if policy == ADMIN_ONLY
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("service", _ADMIN_ONLY)
|
||||
async def test_admin_succeeds_on_every_admin_only_service(
|
||||
hass, make_entry, service
|
||||
) -> None:
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
entry = await _setup(hass, make_entry)
|
||||
ctx = await _seed(hass, entry)
|
||||
if service == SERVICE_EXPORT_HISTORY_CSV:
|
||||
# The gate passes; the Feature 13 stub raises its "not
|
||||
# implemented" HomeAssistantError — NOT Unauthorized.
|
||||
with pytest.raises(HomeAssistantError, match="Feature 13"):
|
||||
await hass.services.call(
|
||||
DOMAIN, service, None, context=ADMIN_CTX
|
||||
)
|
||||
return
|
||||
await hass.services.call(
|
||||
DOMAIN, service, _admin_success_payload(service, ctx), context=ADMIN_CTX
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("service", _ADMIN_ONLY)
|
||||
async def test_non_admin_refused_on_every_admin_only_service(
|
||||
hass, make_entry, service
|
||||
) -> None:
|
||||
entry = await _setup(hass, make_entry)
|
||||
ctx = await _seed(hass, entry)
|
||||
if service != SERVICE_EXPORT_HISTORY_CSV:
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
service,
|
||||
_admin_success_payload(service, ctx),
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
service,
|
||||
_admin_success_payload(service, ctx),
|
||||
context=NON_ADMIN_CTX,
|
||||
)
|
||||
|
||||
|
||||
async def test_non_admin_allowed_on_complete_quest(hass, make_entry) -> None:
|
||||
from custom_components.nestquest.completion import instance_state
|
||||
|
||||
entry = await _setup(hass, make_entry)
|
||||
ctx = await _seed(hass, entry)
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": ctx["instance_id"],
|
||||
"actor": "panel",
|
||||
"actor_child_id": ctx["child_id"],
|
||||
},
|
||||
context=NON_ADMIN_CTX,
|
||||
)
|
||||
assert (
|
||||
await instance_state(ctx["database"], ctx["instance_id"]) == "done"
|
||||
)
|
||||
|
||||
|
||||
async def test_kiosk_refused_on_uncomplete_and_regenerate(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry = await _setup(hass, make_entry)
|
||||
ctx = await _seed(hass, entry)
|
||||
from custom_components.nestquest.admin_allowlist import list_admin_ids
|
||||
|
||||
assert KIOSK_ID not in await list_admin_ids(ctx["database"])
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
{"instance_id": ctx["instance_id"], "actor": "user"},
|
||||
context=KIOSK_CTX,
|
||||
)
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN, SERVICE_REGENERATE, context=KIOSK_CTX
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Tests for the Feature 09 permission gate's audit logging."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from homeassistant.exceptions import Unauthorized
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
)
|
||||
|
||||
ADMIN_ID = "admin-1"
|
||||
ADMIN_CTX = {"user_id": ADMIN_ID}
|
||||
_TIMESTAMP = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
|
||||
|
||||
|
||||
async def _set_up(hass, make_entry):
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]}), hass.registry
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
return entry
|
||||
|
||||
|
||||
async def test_denied_call_logs_warning_with_fields(
|
||||
hass, make_entry, caplog
|
||||
) -> None:
|
||||
entry = await _set_up(hass, make_entry)
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context={"user_id": "kiosk-panel"},
|
||||
)
|
||||
denials = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if record.levelno == logging.WARNING
|
||||
and "denied" in record.getMessage()
|
||||
]
|
||||
assert len(denials) == 1
|
||||
message = denials[0].getMessage()
|
||||
assert SERVICE_MANAGE_CHILD in message
|
||||
assert "kiosk-panel" in message
|
||||
assert _TIMESTAMP.search(message), message
|
||||
|
||||
|
||||
async def test_denied_no_context_logs_user_none(
|
||||
hass, make_entry, caplog
|
||||
) -> None:
|
||||
entry = await _set_up(hass, make_entry)
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
)
|
||||
denials = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if record.levelno == logging.WARNING
|
||||
and "denied" in record.getMessage()
|
||||
]
|
||||
assert len(denials) == 1
|
||||
message = denials[0].getMessage()
|
||||
assert "none" in message
|
||||
assert _TIMESTAMP.search(message), message
|
||||
|
||||
|
||||
async def test_successful_admin_call_logs_debug_with_fields(
|
||||
hass, make_entry, caplog
|
||||
) -> None:
|
||||
caplog.set_level(logging.DEBUG, logger="custom_components.nestquest")
|
||||
entry = await _set_up(hass, make_entry)
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
allowed = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if record.levelno == logging.DEBUG
|
||||
and "allowed" in record.getMessage()
|
||||
]
|
||||
assert len(allowed) == 1
|
||||
message = allowed[0].getMessage()
|
||||
assert SERVICE_MANAGE_CHILD in message
|
||||
assert ADMIN_ID in message
|
||||
assert _TIMESTAMP.search(message), message
|
||||
|
||||
|
||||
async def test_denied_log_carries_no_personal_data_beyond_user_id(
|
||||
hass, make_entry, caplog
|
||||
) -> None:
|
||||
"""The log line names the caller's user id only: the payload's
|
||||
display name must not leak into the audit trail."""
|
||||
entry = await _set_up(hass, make_entry)
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context={"user_id": "kiosk-panel"},
|
||||
)
|
||||
denials = [
|
||||
record.getMessage()
|
||||
for record in caplog.records
|
||||
if record.levelno == logging.WARNING and "denied" in record.getMessage()
|
||||
]
|
||||
assert denials, "expected a denial log line"
|
||||
assert "Ada" not in denials[0]
|
||||
assert "display_name" not in denials[0]
|
||||
@@ -5,7 +5,7 @@ import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.presence import (
|
||||
from custom_components.nestquest.presence import (
|
||||
MAX_CYCLE_LENGTH_WEEKS,
|
||||
PresenceEngine,
|
||||
PresenceOverride,
|
||||
|
||||
@@ -7,20 +7,20 @@ import re
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_rules import (
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
QuestDefinitionsDao,
|
||||
ScheduleRulesDao,
|
||||
schedule_rule_to_storage,
|
||||
)
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.quest_definitions import (
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
CreatedQuestDefinition,
|
||||
assign_child,
|
||||
create_quest_definition,
|
||||
@@ -31,7 +31,7 @@ from custom_components.nestquest.core.quest_definitions import (
|
||||
set_quest_definition_active,
|
||||
unassign_child,
|
||||
)
|
||||
from custom_components.nestquest.core.recurrence import RuleType, ScheduleRule
|
||||
from custom_components.nestquest.recurrence import RuleType, ScheduleRule
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -1150,7 +1150,7 @@ def test_concurrent_assign_unassign_returns_consistent_roster(
|
||||
)
|
||||
bo = await children.create("Bo", NOW)
|
||||
|
||||
import custom_components.nestquest.core.dao_rules as dao_rules
|
||||
import custom_components.nestquest.dao_rules as dao_rules
|
||||
|
||||
original_list = dao_rules.QuestDefinitionsDao.list_assignees
|
||||
started = asyncio.Event()
|
||||
@@ -1666,7 +1666,7 @@ def test_list_query_snapshot_coherent_under_racing_edit(tmp_path) -> None:
|
||||
start_date="2026-09-14",
|
||||
)
|
||||
|
||||
import custom_components.nestquest.core.dao_rules as dao_rules
|
||||
import custom_components.nestquest.dao_rules as dao_rules
|
||||
|
||||
original_list = dao_rules.QuestDefinitionsDao.list_windows
|
||||
started = asyncio.Event()
|
||||
@@ -1732,7 +1732,7 @@ def test_list_definitions_for_child_snapshot_coherent_under_racing_unassign(
|
||||
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
|
||||
)
|
||||
|
||||
import custom_components.nestquest.core.dao_rules as dao_rules
|
||||
import custom_components.nestquest.dao_rules as dao_rules
|
||||
|
||||
original_list = dao_rules.QuestDefinitionsDao.list_assignees
|
||||
started = asyncio.Event()
|
||||
@@ -1795,7 +1795,7 @@ def test_list_active_definitions_snapshot_coherent_under_racing_edit(
|
||||
start_date="2026-09-14",
|
||||
)
|
||||
|
||||
import custom_components.nestquest.core.dao_rules as dao_rules
|
||||
import custom_components.nestquest.dao_rules as dao_rules
|
||||
|
||||
original_list = dao_rules.QuestDefinitionsDao.list_windows
|
||||
started = asyncio.Event()
|
||||
|
||||
@@ -12,7 +12,7 @@ import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
|
||||
@@ -15,7 +15,7 @@ import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
from custom_components.nestquest.recurrence import (
|
||||
ScheduleRule,
|
||||
RuleType,
|
||||
occurs_on,
|
||||
@@ -395,7 +395,7 @@ def test_recurrence_engine_imports_nothing_forbidden() -> None:
|
||||
|
||||
module_file = Path(
|
||||
importlib.util.find_spec(
|
||||
"custom_components.nestquest.core.recurrence"
|
||||
"custom_components.nestquest.recurrence"
|
||||
).origin
|
||||
)
|
||||
tree = ast.parse(module_file.read_text())
|
||||
|
||||
@@ -5,7 +5,7 @@ import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
@@ -219,7 +219,7 @@ def test_occurs_on_rejects_unknown_shapes_loudly() -> None:
|
||||
than a silent wrong answer. Forged via a private enum extension
|
||||
because ScheduleRule validation rejects unknown names."""
|
||||
import enum as enum_module
|
||||
from custom_components.nestquest.core import recurrence as mod
|
||||
from custom_components.nestquest import recurrence as mod
|
||||
|
||||
ForgedType = enum_module.Enum(
|
||||
"ForgedType", {"UNKNOWN_SHAPE": "unknown_shape"}
|
||||
|
||||
@@ -5,7 +5,7 @@ import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
@@ -344,12 +344,12 @@ def test_purity_guard_logic_catches_probe_statements(tmp_path) -> None:
|
||||
"from homeassistant.core import HomeAssistant",
|
||||
"import sqlite3",
|
||||
"from .db import NestQuestDatabase",
|
||||
"from custom_components.nestquest.core.db import NestQuestDatabase",
|
||||
"from custom_components.nestquest.db import NestQuestDatabase",
|
||||
"from custom_components.nestquest import db",
|
||||
"import custom_components.nestquest.core.dao_rules",
|
||||
"import custom_components.nestquest.dao_rules",
|
||||
"from . import db",
|
||||
"from . import schema",
|
||||
"from custom_components.nestquest.core.migrations import apply_migrations",
|
||||
"from custom_components.nestquest.migrations import apply_migrations",
|
||||
]
|
||||
for probe in probes:
|
||||
offenders = _scan_forbidden_imports(probe)
|
||||
|
||||
@@ -5,7 +5,7 @@ import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
|
||||
@@ -5,7 +5,7 @@ import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
|
||||
@@ -18,7 +18,7 @@ import random
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user