Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80ac786a54 | ||
|
|
3cdedb37c8 | ||
|
|
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.
|
||||
@@ -159,50 +159,9 @@ Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
|
||||
|
||||
Run these steps in order to display the NestQuest panel on a TouchHub
|
||||
wall device. The panel is an HA Lovelace dashboard, so the kiosk browser
|
||||
is simply a Home Assistant client. The path below is zero-config: the
|
||||
integration registers its panel dashboard itself on setup, so no YAML
|
||||
and no hand-built views are involved.
|
||||
is simply a Home Assistant client.
|
||||
|
||||
1. **Install the integration and add the config entry.** Install
|
||||
NestQuest via HACS or manually (see Installation above) and restart
|
||||
Home Assistant. Then, in *Settings → Devices & Services*, click
|
||||
**Add Integration**, search for **NestQuest**, and submit the flow —
|
||||
it has no fields; it just creates the single config entry. Then open
|
||||
the entry, click **Configure**, and fill the panel-plane API
|
||||
connection in the options flow:
|
||||
|
||||
- **NestQuest API base URL** — the full `http://host:port` URL of
|
||||
your NestQuest API service (default `http://127.0.0.1:8000`).
|
||||
- **Panel service token** — the token the panel presents to the API
|
||||
service as its Bearer credential. It may stay empty until you have
|
||||
one; a pasted token must be unpadded.
|
||||
|
||||
The form's remaining fields (panel idle timeout, coordinator update
|
||||
interval, last-good snapshot staleness) have defaults that serve the
|
||||
panel and need no change here.
|
||||
|
||||
2. **The panel dashboard appears automatically.** On setup the
|
||||
integration registers one storage-mode Lovelace dashboard: a sidebar
|
||||
entry titled **NestQuest** at the URL path `/nestquest`, whose stored
|
||||
config is nothing but the **`custom:nestquest-party` strategy**
|
||||
(`strategy: { type: custom:nestquest-party }`). The strategy
|
||||
generates the views at render time from the household roster:
|
||||
|
||||
- **The Party** — the party-board view at `/nestquest/party`; the
|
||||
dashboard root `/nestquest` shows it as the first view.
|
||||
- one quest-log view per child at `/nestquest/<child-slug>`
|
||||
("Ada's Quest Log", …), in roster order.
|
||||
|
||||
Nothing is hand-configured: no YAML, no manual views, no
|
||||
`child_order`, no `quest_log_path`/`board_path` — the board and the
|
||||
quest logs compute their navigation paths from the dashboard's own
|
||||
URL — and every generated view is already in **Panel** layout (see
|
||||
the two constraints below). If the Lovelace surfaces are unavailable
|
||||
(for example when Lovelace runs in YAML mode), setup logs a warning
|
||||
and skips the registration; create the dashboard manually with the
|
||||
same strategy as described in the manual alternative below.
|
||||
|
||||
3. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
|
||||
1. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
|
||||
a new user for the wall device. Make it **non-admin**, and ensure it is
|
||||
not in the NestQuest admin allowlist. The allowlist blocks the NestQuest
|
||||
**admin** operations — `uncomplete_quest`, `create_quest_definition`,
|
||||
@@ -215,101 +174,66 @@ and no hand-built views are involved.
|
||||
Assistant user it may also reach other permitted Home Assistant entities
|
||||
and services.
|
||||
|
||||
4. **Open the dashboard and point TouchHub at it.** Open
|
||||
`http://<home-assistant>/nestquest` in a browser first and check the
|
||||
party board renders with your children's crests. Then, in the
|
||||
TouchHub launcher, add the **Home Assistant Lovelace** app to the
|
||||
dock and paste the dashboard URL from step 2 — the dashboard root,
|
||||
`/nestquest` (TouchHub loads that URL directly as the kiosk landing
|
||||
page). Set TouchHub **Auto-Return** to a value GREATER than the
|
||||
quest log's `idle_return_seconds` (default 40s) — recommend **60s or
|
||||
higher**. If Auto-Return is at or below 40s, TouchHub returns to its
|
||||
launcher before the quest log's idle return can navigate back to the
|
||||
party board.
|
||||
2. **Build a dashboard with only the NestQuest panel cards.** Create a
|
||||
Lovelace dashboard whose **URL slug is `nestquest`** (Settings →
|
||||
Dashboards → Edit → URL slug). A functional panel needs **two** views
|
||||
in that dashboard, each with its own card config from
|
||||
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
|
||||
paths (the path segment after the dashboard slug):
|
||||
|
||||
5. **Log the kiosk session in.** Sign in on the device as the dedicated
|
||||
kiosk user from step 3, and leave that session logged in.
|
||||
- **View 1 path `board`** (URL `/nestquest/board`) — party board
|
||||
(`type: custom:nestquest-party-board-card`): the household quest
|
||||
board. Set `weather_entity` (optional; the dock falls back to
|
||||
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
|
||||
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
|
||||
child's slug in display order.
|
||||
|
||||
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
|
||||
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
|
||||
the party board hops to. Set `board_path: /nestquest/board` (the
|
||||
party-board view to return to), `weather_entity`,
|
||||
`idle_return_seconds` (must be shorter than TouchHub Auto-Return),
|
||||
`confirm_timeout_seconds`, and `complete_screen_seconds`. The quest
|
||||
log reads the selected child's slug from the view's URL path, so the
|
||||
board → log hop is a plain Lovelace navigation.
|
||||
|
||||
Each view's layout MUST be set to **Panel** (Edit Dashboard → pencil on the
|
||||
view → View type: Panel). Home Assistant's default Masonry layout constrains
|
||||
the 1080px-tall NestQuest card to a narrow column; Panel (single-card)
|
||||
layout is what renders the card full-screen at 1920×1080.
|
||||
|
||||
The dashboard contains only the NestQuest panel card for each view —
|
||||
no other cards, no admin controls.
|
||||
|
||||
The `quest_log_path` and `board_path` values must match the dashboard
|
||||
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
|
||||
as the dashboard slug with view paths `board` and `log`, the card
|
||||
configs are already correct. If you pick different names, substitute
|
||||
your actual paths in **both** card configs — e.g. a dashboard slug
|
||||
`chores` with view paths `main` and `history` needs
|
||||
`quest_log_path: /chores/history` and `board_path: /chores/main`.
|
||||
Navigation between the two views fails (the crest tap or back button
|
||||
lands on a 404) if the card config paths do not match the dashboard
|
||||
slug and view paths exactly.
|
||||
|
||||
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
|
||||
|
||||
3. **Point TouchHub at it.** In the TouchHub launcher, add the **Home
|
||||
Assistant Lovelace** app to the dock and paste the dashboard URL from
|
||||
step 2 — the party-board view URL, e.g. `/nestquest/board` (TouchHub
|
||||
loads that URL directly as the kiosk landing page). Set TouchHub
|
||||
**Auto-Return** to a value GREATER than the quest log's
|
||||
`idle_return_seconds` (default 40s) — recommend **60s or higher**. If
|
||||
Auto-Return is at or below 40s, TouchHub returns to its launcher before
|
||||
the quest log's idle return can navigate back to the party board.
|
||||
|
||||
4. **Log the kiosk session in.** Sign in on the device as the dedicated
|
||||
kiosk user from step 1, and leave that session logged in.
|
||||
|
||||
TouchHub loads the dashboard URL directly. The NestQuest cards are normal
|
||||
Lovelace cards, not an iframe, so no Home Assistant framing setting is
|
||||
needed.
|
||||
|
||||
### The two constraints
|
||||
|
||||
- **Panel layout.** Every view must be in **Panel** (single-card)
|
||||
layout: Home Assistant's default Masonry layout constrains the
|
||||
1080px-tall NestQuest card to a narrow column, while Panel renders the
|
||||
card full-screen at 1920×1080. The zero-config path already satisfies
|
||||
the requirement — the strategy generates every view as
|
||||
`type: panel` — while the manual alternative below requires setting
|
||||
the layout by hand on each view (Edit Dashboard → pencil on the view
|
||||
→ View type: Panel).
|
||||
|
||||
- **TouchHub Auto-Return.** TouchHub's **Auto-Return** must be greater
|
||||
than the quest log's `idle_return_seconds` (default 40s) — recommend
|
||||
**60s or higher** — so the launcher does not reclaim the wall device
|
||||
before the quest log's own idle return navigates back to the party
|
||||
board.
|
||||
|
||||
### Manual alternative: build the dashboard by hand
|
||||
|
||||
If you prefer not to use the zero-config dashboard above — or its
|
||||
registration was skipped — configure a Lovelace dashboard whose **URL
|
||||
slug is `nestquest`** (Settings → Dashboards → Edit → URL slug)
|
||||
yourself. Two options, in decreasing order of convenience:
|
||||
|
||||
- **Use the strategy.** Give the dashboard
|
||||
|
||||
```yaml
|
||||
strategy:
|
||||
type: custom:nestquest-party
|
||||
```
|
||||
|
||||
and no views of your own — the strategy generates the party-board and
|
||||
quest-log views (each already in Panel layout) from the household
|
||||
roster at render time. Optional `url_path` and `weather_entity` keys
|
||||
may sit beside `strategy:`.
|
||||
|
||||
- **Build the two views by hand.** A functional panel needs **two**
|
||||
views in that dashboard, each with its own card config from
|
||||
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
|
||||
paths (the path segment after the dashboard slug):
|
||||
|
||||
- **View 1 path `board`** (URL `/nestquest/board`) — party board
|
||||
(`type: custom:nestquest-party-board-card`): the household quest
|
||||
board. Set `weather_entity` (optional; the dock falls back to
|
||||
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
|
||||
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
|
||||
child's slug in display order.
|
||||
|
||||
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
|
||||
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
|
||||
the party board hops to. Set `board_path: /nestquest/board` (the
|
||||
party-board view to return to), `weather_entity`,
|
||||
`idle_return_seconds` (must be shorter than TouchHub Auto-Return —
|
||||
see the two constraints above), `confirm_timeout_seconds`, and
|
||||
`complete_screen_seconds`. The quest log reads the selected child's
|
||||
slug from the view's URL path, so the board → log hop is a plain
|
||||
Lovelace navigation.
|
||||
|
||||
The dashboard contains only the NestQuest panel card for each view —
|
||||
no other cards, no admin controls. Each view's layout MUST be set to
|
||||
**Panel** — see the two constraints above for why; the strategy
|
||||
generates it for you, hand-built views do not get it for free.
|
||||
|
||||
The `quest_log_path` and `board_path` values must match the dashboard
|
||||
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
|
||||
as the dashboard slug with view paths `board` and `log`, the card
|
||||
configs are already correct. If you pick different names, substitute
|
||||
your actual paths in **both** card configs — e.g. a dashboard slug
|
||||
`chores` with view paths `main` and `history` needs
|
||||
`quest_log_path: /chores/history` and `board_path: /chores/main`.
|
||||
Navigation between the two views fails (the crest tap or back button
|
||||
lands on a 404) if the card config paths do not match the dashboard
|
||||
slug and view paths exactly.
|
||||
|
||||
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
|
||||
|
||||
## Developer Setup and Testing
|
||||
|
||||
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
||||
|
||||
+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
|
||||
|
||||
+8
-92
@@ -35,20 +35,16 @@ the real cause is visible in the server log instead of disguised as a
|
||||
bad credential; a short backoff window after a failed fetch fails fast
|
||||
(re-raising the recorded fault) rather than fetching once per request.
|
||||
|
||||
Beyond authentication, :func:`require_admin` is the ONE dependency
|
||||
every admin route uses: it REJECTS the panel service token outright —
|
||||
that credential authenticates the panel plane only, so treating it as
|
||||
a JWT candidate would blur two credential types into one check — and
|
||||
then requires the verified JWT's ``groups`` claim to name the
|
||||
``nestquest-admins`` group (authorization, 403, distinct from the
|
||||
uniform authentication 401 so the two failure classes stay apart).
|
||||
This module validates the TOKEN only — it is authentication, not
|
||||
authorization. Membership in the ``nestquest-admins`` group is the
|
||||
NEXT task, which extends :func:`require_admin_jwt` (the verified
|
||||
claims are already its return value, so no rewrite is needed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import binascii
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated
|
||||
@@ -74,11 +70,6 @@ _REJECTION_DETAIL = "Missing or invalid admin credentials"
|
||||
#: Challenge header returned with a 401, per the Bearer auth scheme.
|
||||
_WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"}
|
||||
|
||||
#: The authorization group a verified admin JWT must name in its
|
||||
#: ``groups`` claim. A module constant (the value Authentik's
|
||||
#: nestquest-admins group maps to) so it is clearly named in one place.
|
||||
ADMIN_GROUP = "nestquest-admins"
|
||||
|
||||
#: Seconds a fetched JWKS document stays trusted before the next
|
||||
#: fetch. Five minutes bounds how long a rotated-away key keeps
|
||||
#: verifying while staying far above a per-request fetch.
|
||||
@@ -275,19 +266,6 @@ class JwksCache:
|
||||
return document
|
||||
|
||||
|
||||
#: 403 detail for the panel service token on an admin route. Deliberately
|
||||
#: non-leaking: it names the credential TYPE, not its value or where it came
|
||||
#: from. 403 (not 401) because this is a REFUSED credential type, not an
|
||||
#: absent/invalid one — challenging re-authentication would suggest the same
|
||||
#: credential could work with a retry, which it never can here.
|
||||
_PANEL_TOKEN_REJECTION_DETAIL = "This credential type is not accepted for admin access"
|
||||
|
||||
#: 403 detail for a valid JWT that is not a member of the admin group.
|
||||
#: Distinct from the uniform 401 on purpose: the token AUTHENTICATED, but
|
||||
#: the caller is not AUTHORIZED, so the response may say so.
|
||||
_FORBIDDEN_DETAIL = "Admin access requires membership in the nestquest-admins group"
|
||||
|
||||
|
||||
def _reject() -> HTTPException:
|
||||
"""Build the single 401 the dependency raises for every failure."""
|
||||
return HTTPException(
|
||||
@@ -413,9 +391,9 @@ async def require_admin_jwt(
|
||||
:func:`verify_admin_token`, returning the verified claims for the
|
||||
route to use (the probe route surfaces the token's ``sub``).
|
||||
|
||||
The nestquest-admins GROUP check lives in :func:`require_admin`,
|
||||
which wraps this dependency; ``require_admin_jwt`` remains the plain
|
||||
JWT-validation contract — the 401 path stays exactly this uniform.
|
||||
The nestquest-admins GROUP check is the NEXT task: it extends this
|
||||
dependency (e.g. by inspecting the returned claims) so every admin
|
||||
route inherits it — the 401 contract stays exactly this uniform.
|
||||
"""
|
||||
if authorization is None:
|
||||
raise _reject()
|
||||
@@ -425,66 +403,4 @@ async def require_admin_jwt(
|
||||
return await verify_admin_token(request, credential.strip())
|
||||
|
||||
|
||||
async def require_admin(
|
||||
request: Request,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> dict[str, object]:
|
||||
"""The ONE admin-plane dependency: service-token refusal + group gate.
|
||||
|
||||
Order matters. FIRST the Bearer credential is compared against the
|
||||
configured panel service token (``app.state.config.panel_token``):
|
||||
a match is refused OUTRIGHT with 403 — BEFORE any JWT parsing — so
|
||||
the panel credential is never even treated as a JWT candidate, let
|
||||
alone a non-admin JWT. (403 rather than 401 because this is a
|
||||
refused credential TYPE, not an invalid one: challenging
|
||||
re-authentication would imply retrying could ever succeed; the
|
||||
detail names the type only, never the value.) A config with an
|
||||
EMPTY panel token never matches anything, so it cannot lock every
|
||||
Bearer request out.
|
||||
|
||||
Otherwise the credential goes through the existing JWT path
|
||||
(:func:`require_admin_jwt` → :func:`verify_admin_token`): a missing
|
||||
or malformed header and any invalid JWT still yield the SAME
|
||||
uniform 401 as before. A VALID JWT is then checked for
|
||||
authorization: its ``groups`` claim must be a list containing
|
||||
:data:`ADMIN_GROUP` — a missing, empty, or non-list ``groups`` or
|
||||
one without the group raises 403 (distinct from the 401 on
|
||||
purpose: the token authenticated, the caller did not authorize).
|
||||
|
||||
Returns the verified claims for the route.
|
||||
"""
|
||||
if authorization is not None:
|
||||
scheme, _, credential = authorization.partition(" ")
|
||||
if scheme.lower() == _BEARER_SCHEME and credential.strip():
|
||||
config = getattr(request.app.state, "config", None)
|
||||
if (
|
||||
config is not None
|
||||
and config.panel_token
|
||||
# compare_digest on bytes (as require_panel_token does)
|
||||
# so the refusal does not short-circuit on the first
|
||||
# mismatching byte — a constant-time comparison keeps a
|
||||
# timing side channel on the credential off the table.
|
||||
and secrets.compare_digest(
|
||||
credential.strip().encode("utf-8"),
|
||||
config.panel_token.encode("utf-8"),
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=_PANEL_TOKEN_REJECTION_DETAIL,
|
||||
)
|
||||
claims = await require_admin_jwt(request, authorization)
|
||||
groups = claims.get("groups")
|
||||
if not isinstance(groups, list) or ADMIN_GROUP not in groups:
|
||||
raise HTTPException(status_code=403, detail=_FORBIDDEN_DETAIL)
|
||||
return claims
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ADMIN_GROUP",
|
||||
"JwksCache",
|
||||
"JwksFetcher",
|
||||
"require_admin",
|
||||
"require_admin_jwt",
|
||||
"verify_admin_token",
|
||||
]
|
||||
__all__ = ["JwksCache", "JwksFetcher", "require_admin_jwt", "verify_admin_token"]
|
||||
|
||||
@@ -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,100 +124,12 @@ 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")
|
||||
|
||||
#: The quest-definition business layer (``core.quest_definitions`` —
|
||||
#: ``create_quest_definition``, ``edit_quest_definition``,
|
||||
#: ``set_quest_definition_active``) the admin plane's quest-definition
|
||||
#: routes call.
|
||||
core_quest_definitions = importlib.import_module(
|
||||
f"{_CORE_MODULE_NAME}.quest_definitions"
|
||||
)
|
||||
|
||||
#: The recurrence model (``core.recurrence.ScheduleRule`` and
|
||||
#: ``RuleValidationError``) the quest-definition routes build request
|
||||
#: rules with — the model validates at construction, the routes never
|
||||
#: re-implement rule validation.
|
||||
core_recurrence = importlib.import_module(f"{_CORE_MODULE_NAME}.recurrence")
|
||||
|
||||
#: The presence model (``core.presence.PresenceEngine``,
|
||||
#: ``PresenceSchedule``, ``PresenceOverride``) the admin plane's
|
||||
#: presence routes serialize and the tests resolve presence with.
|
||||
core_presence = importlib.import_module(f"{_CORE_MODULE_NAME}.presence")
|
||||
|
||||
#: The presence-write business layer
|
||||
#: (``core.presence_management.set_presence_schedule`` et al.) the
|
||||
#: admin plane's presence routes call — the ONE implementation the HA
|
||||
#: services share.
|
||||
core_presence_management = importlib.import_module(
|
||||
f"{_CORE_MODULE_NAME}.presence_management"
|
||||
)
|
||||
|
||||
#: The typed presence DAOs (``core.dao_presence``) the admin-plane tests
|
||||
#: read stored schedules and overrides back through.
|
||||
core_dao_presence = importlib.import_module(
|
||||
f"{_CORE_MODULE_NAME}.dao_presence"
|
||||
)
|
||||
|
||||
#: The materialization walk (``core.materialize.materialize``,
|
||||
#: ``regenerate_for_definition``, ``regenerate_for_child``) the admin
|
||||
#: plane's regenerate route calls — the same walk the integration's
|
||||
#: config/presence changes trigger.
|
||||
core_materialize = importlib.import_module(f"{_CORE_MODULE_NAME}.materialize")
|
||||
|
||||
#: The core constants module (``core.const.DEFAULT_HORIZON_DAYS``) the
|
||||
#: regenerate route sizes the household rolling horizon with.
|
||||
core_const = importlib.import_module(f"{_CORE_MODULE_NAME}.const")
|
||||
|
||||
#: The typed child DAO (``core.dao_children.ChildrenDao.get``) the
|
||||
#: regenerate route reads a child's existence with before delegating.
|
||||
core_dao_children = importlib.import_module(
|
||||
f"{_CORE_MODULE_NAME}.dao_children"
|
||||
)
|
||||
|
||||
#: The typed definition DAO (``core.dao_rules.QuestDefinitionsDao.get``)
|
||||
#: the regenerate route reads a definition's existence with before
|
||||
#: delegating.
|
||||
core_dao_rules = importlib.import_module(f"{_CORE_MODULE_NAME}.dao_rules")
|
||||
|
||||
#: The HA-free missed sweep (``core.sweep.run_missed_sweep`` +
|
||||
#: ``SWEEP_WATERMARK_KEY``) the admin plane's trigger route calls and
|
||||
#: the daily scheduler runs — the ONE sweep policy the integration's
|
||||
#: bus shim shares.
|
||||
core_sweep = importlib.import_module(f"{_CORE_MODULE_NAME}.sweep")
|
||||
|
||||
__all__ = [
|
||||
"core",
|
||||
"core_children",
|
||||
"core_completion",
|
||||
"core_const",
|
||||
"core_dao_children",
|
||||
"core_dao_presence",
|
||||
"core_dao_rules",
|
||||
"core_db",
|
||||
"core_events",
|
||||
"core_history",
|
||||
"core_materialize",
|
||||
"core_migrations",
|
||||
"core_presence",
|
||||
"core_presence_management",
|
||||
"core_quest_definitions",
|
||||
"core_recurrence",
|
||||
"core_snapshot",
|
||||
"core_settings",
|
||||
"core_settings_store",
|
||||
"core_sweep",
|
||||
]
|
||||
|
||||
+23
-1692
File diff suppressed because it is too large
Load Diff
@@ -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,78 +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, registers the zero-config
|
||||
panel dashboard (Feature 20), 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 .dashboard import async_register_dashboard
|
||||
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:
|
||||
@@ -89,14 +386,48 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up NestQuest from a config entry."""
|
||||
await async_register_frontend(hass)
|
||||
# The zero-config panel dashboard (Feature 20): register the
|
||||
# storage-mode strategy dashboard if no dashboard under the stable
|
||||
# url_path exists yet. Unavailability of the Lovelace dashboards
|
||||
# collection (YAML mode, lovelace not loaded) logs a warning and
|
||||
# NEVER fails setup; an existing dashboard — ours or the user's —
|
||||
# is left untouched, so a reload cannot duplicate it.
|
||||
await async_register_dashboard(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
|
||||
@@ -104,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()
|
||||
@@ -199,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
|
||||
|
||||
@@ -227,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,41 +20,44 @@ 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"
|
||||
|
||||
# Zero-config panel dashboard (Feature 20): ONE storage-mode Lovelace
|
||||
# dashboard registered on setup whose config carries the bundle's
|
||||
# ``custom:nestquest-party`` strategy (frontend/src/strategy.ts) — the
|
||||
# views are generated at render time from the household roster. The
|
||||
# url_path IS the domain: the stable key idempotency is keyed on (a
|
||||
# reload or a second entry never duplicates it, and a dashboard already
|
||||
# registered under it — ours or the user's — is left untouched).
|
||||
DASHBOARD_URL_PATH = DOMAIN
|
||||
DASHBOARD_TITLE = "NestQuest"
|
||||
DASHBOARD_ICON = "mdi:party-popper"
|
||||
DASHBOARD_STRATEGY_TYPE = "custom:nestquest-party"
|
||||
|
||||
# Configuration keys and default values
|
||||
CONF_HORIZON_DAYS = "horizon_days"
|
||||
DEFAULT_HORIZON_DAYS = 14
|
||||
@@ -78,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
|
||||
@@ -113,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",
|
||||
]
|
||||
@@ -1,161 +0,0 @@
|
||||
"""Business layer for presence writes (Feature 09, API admin plane).
|
||||
|
||||
Sits between the write callers (the Feature 09 HA service handlers and
|
||||
the API admin plane's presence routes) and the typed presence DAOs plus
|
||||
the pure presence model: callers get the model validation, the upsert /
|
||||
create / delete operations and the regeneration side effect here, and
|
||||
never touch the DAO or SQL directly. This is the ONE implementation of
|
||||
these business rules — every caller constructs the same
|
||||
:class:`~.presence.PresenceSchedule` / :class:`~.presence.PresenceOverride`
|
||||
models (whose constructors are total, so no re-validation happens in a
|
||||
handler) and gets the same post-write
|
||||
:func:`~.materialize.regenerate_for_child`, so a child's future quest
|
||||
instances track its changed presence no matter which plane issued the
|
||||
write. Like the model it is Home-Assistant-free: ``today`` is a plain
|
||||
calendar date threaded through to the regeneration, defaulting to the
|
||||
host clock there.
|
||||
|
||||
Upsert policy (Feature 05, unchanged): a child has AT MOST ONE presence
|
||||
schedule — setting again REPLACES the existing row rather than
|
||||
duplicating it. Overrides are deletable by design (children are not —
|
||||
the children layer owns that guardrail). Deleting a nonexistent
|
||||
override raises ValueError prefixed ``override_id:`` so a caller can
|
||||
distinguish "unknown id" from a rejected argument the same way the
|
||||
quest-definitions layer prefixes ``definition_id:``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from .dao_presence import (
|
||||
PresenceOverrideRecord,
|
||||
PresenceOverridesDao,
|
||||
PresenceSchedulesDao,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
from .materialize import regenerate_for_child
|
||||
from .presence import PresenceOverride, PresenceSchedule
|
||||
|
||||
|
||||
def _validate_override_id(value: object) -> int:
|
||||
"""Reject non-int override ids (bools would silently address id 1)."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"override_id must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
async def set_presence_schedule(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
cycle_length_weeks: int,
|
||||
anchor_date: object,
|
||||
pattern: dict[int, object],
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> PresenceSchedule:
|
||||
"""Set (upsert) a child's repeating presence schedule.
|
||||
|
||||
The :class:`~.presence.PresenceSchedule` model validates at
|
||||
construction — cycle length 1..4, a strict calendar anchor date,
|
||||
and a pattern covering week indices 0..n-1 with weekday sets — so
|
||||
no invalid schedule reaches the DAO. The encoded pattern is
|
||||
UPSERTed by child (a second set REPLACES the child's existing
|
||||
schedule; the schema's UNIQUE(child_id) makes duplication
|
||||
unrepresentable), then :func:`~.materialize.regenerate_for_child`
|
||||
re-materializes the child's future instances against the new
|
||||
presence. ``today``/``horizon_days`` thread through to that
|
||||
regeneration; raising ValueError names what to fix (an unknown
|
||||
child reads ``child N does not exist``).
|
||||
|
||||
Returns the STORED schedule decoded back through the model — the
|
||||
round-trip proves the encoded pattern the database now holds is
|
||||
exactly the one the caller asked for.
|
||||
"""
|
||||
schedule = PresenceSchedule(
|
||||
child_id, cycle_length_weeks, anchor_date, pattern
|
||||
)
|
||||
record = await PresenceSchedulesDao(database).upsert_by_child(
|
||||
schedule.child_id,
|
||||
schedule.cycle_length_weeks,
|
||||
schedule.anchor_date.isoformat(),
|
||||
schedule.encode(),
|
||||
)
|
||||
await regenerate_for_child(
|
||||
database, schedule.child_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
return PresenceSchedule.decode(
|
||||
record.child_id, record.anchor_date, record.pattern
|
||||
)
|
||||
|
||||
|
||||
async def create_presence_override(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
start_date: object,
|
||||
end_date: object,
|
||||
is_present: bool,
|
||||
note: str | None = None,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> PresenceOverrideRecord:
|
||||
"""Create one date-range presence override and return it as stored.
|
||||
|
||||
The :class:`~.presence.PresenceOverride` model validates at
|
||||
construction — strict calendar dates, ``end_date`` on or after
|
||||
``start_date``, a real ``is_present`` bool, a string-or-None note —
|
||||
then the DAO insert applies the same-child no-overlap policy (a
|
||||
conflicting range raises ValueError naming the existing override)
|
||||
and :func:`~.materialize.regenerate_for_child` re-materializes the
|
||||
child's future instances against the override's presence answer.
|
||||
An unknown child reads ``child N does not exist``.
|
||||
|
||||
Returns the stored record WITH its ``id`` — the handle a later
|
||||
:func:`delete_presence_override` takes.
|
||||
"""
|
||||
override = PresenceOverride(
|
||||
child_id, start_date, end_date, is_present, note=note
|
||||
)
|
||||
record = await PresenceOverridesDao(database).create(
|
||||
override.child_id,
|
||||
override.start_date.isoformat(),
|
||||
override.end_date.isoformat(),
|
||||
override.is_present,
|
||||
note=override.note,
|
||||
)
|
||||
await regenerate_for_child(
|
||||
database, override.child_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
async def delete_presence_override(
|
||||
database: NestQuestDatabase,
|
||||
override_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> PresenceOverrideRecord:
|
||||
"""Delete one presence override by id and return it as it was stored.
|
||||
|
||||
The override is looked up FIRST and an unknown id raises ValueError
|
||||
prefixed ``override_id:`` (naming the id) BEFORE any delete, so a
|
||||
mistyped id can never be silently swallowed by a no-op. The
|
||||
deletion is followed by :func:`~.materialize.regenerate_for_child`
|
||||
for the override's own child — the id decides the child, never a
|
||||
caller-supplied one. ``today``/``horizon_days`` thread through to
|
||||
that regeneration.
|
||||
"""
|
||||
_validate_override_id(override_id)
|
||||
dao = PresenceOverridesDao(database)
|
||||
override = await dao.get(override_id)
|
||||
if override is None:
|
||||
raise ValueError(
|
||||
f"override_id: presence override {override_id} does not exist"
|
||||
)
|
||||
await dao.delete(override_id)
|
||||
await regenerate_for_child(
|
||||
database, override.child_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
return override
|
||||
@@ -0,0 +1,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,
|
||||
)
|
||||
@@ -1,215 +0,0 @@
|
||||
"""Zero-config panel dashboard registration for NestQuest (Feature 20).
|
||||
|
||||
On setup the integration registers ONE storage-mode Lovelace dashboard
|
||||
whose CONFIG uses the bundle's ``custom:nestquest-party`` strategy (see
|
||||
``frontend/src/strategy.ts``): the views themselves are GENERATED at
|
||||
render time from the household roster, so the stored dashboard config
|
||||
carries nothing but the strategy reference.
|
||||
|
||||
Registration mirrors the REAL two-step sequence the lovelace
|
||||
integration itself uses when it creates the onboarding map dashboard
|
||||
(``homeassistant/components/lovelace/__init__.py``,
|
||||
``_create_map_dashboard``):
|
||||
|
||||
1. create the dashboard METADATA through the ``DashboardsCollection``
|
||||
(``hass.data["lovelace"]["dashboards_collection"]``) — url_path,
|
||||
title, icon, sidebar visibility. The strategy reference is NOT part
|
||||
of the create payload: the collection's create schema allows only
|
||||
the metadata fields, so a ``strategy`` key there fails the whole
|
||||
create (this was the silent no-op the first implementation shipped).
|
||||
2. save the dashboard CONFIG — ``{"strategy": ...}`` — through the
|
||||
per-url_path CONTENT manager that the collection's change listener
|
||||
registers under ``hass.data["lovelace"]["dashboards"][url_path]``
|
||||
(a ``LovelaceStorage``), exactly the second step HA performs.
|
||||
|
||||
Registration is IDEMPOTENT — a dashboard already under the stable
|
||||
``url_path`` (ours or a user's) skips BOTH steps and its stored config
|
||||
is never overwritten — and the Lovelace surfaces being unavailable
|
||||
(YAML mode, lovelace not loaded) is a logged warning, never a setup
|
||||
failure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
DASHBOARD_ICON,
|
||||
DASHBOARD_STRATEGY_TYPE,
|
||||
DASHBOARD_TITLE,
|
||||
DASHBOARD_URL_PATH,
|
||||
LOGGER,
|
||||
)
|
||||
|
||||
_LOGGER = LOGGER
|
||||
|
||||
# Single-flight guard for the check-then-create below: HA can set up
|
||||
# several config entries CONCURRENTLY, and two overlapping setups would
|
||||
# both pass the exists-check and race on the url_path create. The lock
|
||||
# is per hass instance (kept as an attribute on the hass object, since
|
||||
# hass.data is the integrations' shared registry the unload path
|
||||
# empties) rather than one module-level asyncio.Lock — a bare
|
||||
# module-level lock binds to whichever event loop first awaits it and
|
||||
# raises RuntimeError on any other loop, while the race it must prevent
|
||||
# only exists between setups running on the SAME hass anyway.
|
||||
_REGISTRATION_LOCK_ATTR = "_nestquest_dashboard_registration_lock"
|
||||
|
||||
|
||||
def _registration_lock(hass: HomeAssistant) -> asyncio.Lock:
|
||||
"""Return the per-hass registration lock, creating it on first use."""
|
||||
lock = getattr(hass, _REGISTRATION_LOCK_ATTR, None)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
setattr(hass, _REGISTRATION_LOCK_ATTR, lock)
|
||||
return lock
|
||||
|
||||
|
||||
def _lovelace_data(hass: HomeAssistant) -> dict | None:
|
||||
"""Return ``hass.data["lovelace"]`` when it is the real dict shape.
|
||||
|
||||
The lovelace integration stores a plain DICT of surfaces (``mode``,
|
||||
``dashboards``, ``dashboards_collection``, ``resources``,
|
||||
``yaml_dashboards``) under the ``lovelace`` key; anything else
|
||||
(missing key, unexpected object) means the surfaces below cannot be
|
||||
reached and registration must warn and continue.
|
||||
"""
|
||||
lovelace_data = hass.data.get("lovelace")
|
||||
if not isinstance(lovelace_data, dict):
|
||||
return None
|
||||
return lovelace_data
|
||||
|
||||
|
||||
def _dashboards_collection(hass: HomeAssistant):
|
||||
"""Return the Lovelace dashboards METADATA collection, or None.
|
||||
|
||||
The collection lives at ``hass.data["lovelace"]
|
||||
["dashboards_collection"]`` — NOT under ``["dashboards"]``, which
|
||||
maps url_path to the per-dashboard CONTENT managers
|
||||
(``LovelaceStorage``/``LovelaceYAML``). A missing key (lovelace
|
||||
not loaded) and a missing/None entry (YAML mode never sets the
|
||||
collection) both mean "cannot create storage dashboards here" —
|
||||
callers log and continue. The presence of the surface is verified
|
||||
by capability (``async_create_item`` callable), not by type, so no
|
||||
lovelace import is needed.
|
||||
"""
|
||||
lovelace_data = _lovelace_data(hass)
|
||||
if lovelace_data is None:
|
||||
return None
|
||||
collection = lovelace_data.get("dashboards_collection")
|
||||
if collection is None or not callable(
|
||||
getattr(collection, "async_create_item", None)
|
||||
):
|
||||
return None
|
||||
return collection
|
||||
|
||||
|
||||
def _dashboard_store(hass: HomeAssistant, url_path: str):
|
||||
"""Return a url_path's dashboard CONTENT manager, or None.
|
||||
|
||||
After a metadata create, the collection's change listener registers
|
||||
a ``LovelaceStorage`` for the new dashboard under
|
||||
``hass.data["lovelace"]["dashboards"][url_path]``; the dashboard
|
||||
CONFIG (the strategy reference) is saved through THAT manager —
|
||||
the second step of HA's own dashboard creation.
|
||||
"""
|
||||
lovelace_data = _lovelace_data(hass)
|
||||
if lovelace_data is None:
|
||||
return None
|
||||
dashboards = lovelace_data.get("dashboards")
|
||||
if not isinstance(dashboards, dict):
|
||||
return None
|
||||
store = dashboards.get(url_path)
|
||||
if store is None or not callable(getattr(store, "async_save", None)):
|
||||
return None
|
||||
return store
|
||||
|
||||
|
||||
def _item_url_path(item) -> str | None:
|
||||
"""Read a dashboard item's ``url_path`` through either access shape.
|
||||
|
||||
The collection hands back storage items as dicts while builtin
|
||||
dashboards come back as config objects; the url_path is reachable
|
||||
through ``item["url_path"]`` or ``item.url_path`` respectively.
|
||||
"""
|
||||
url_path = getattr(item, "url_path", None)
|
||||
if isinstance(url_path, str):
|
||||
return url_path
|
||||
get_url_path = getattr(item, "get", None)
|
||||
if callable(get_url_path):
|
||||
value = get_url_path("url_path")
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _existing_dashboard(items) -> bool:
|
||||
"""Whether a dashboard under the stable url_path already exists."""
|
||||
return any(_item_url_path(item) == DASHBOARD_URL_PATH for item in items)
|
||||
|
||||
|
||||
async def async_register_dashboard(hass: HomeAssistant) -> bool:
|
||||
"""Register the NestQuest panel dashboard exactly once.
|
||||
|
||||
Returns True when a dashboard under the stable ``url_path`` exists
|
||||
after the call (pre-existing or created here), False when the
|
||||
Lovelace surfaces were unavailable or refused. A dashboard already
|
||||
registered under the stable ``url_path`` — whether ours from a
|
||||
previous setup or a user's own — skips BOTH steps and is left
|
||||
COMPLETELY alone: its stored config is never overwritten, it is
|
||||
never deleted, never a duplicate. The strategy config is saved
|
||||
ONLY for the dashboard created in THIS call, whose fresh content
|
||||
store is empty, so the save cannot clobber anything.
|
||||
"""
|
||||
collection = _dashboards_collection(hass)
|
||||
if collection is None:
|
||||
_LOGGER.warning(
|
||||
"Lovelace dashboards collection unavailable; the NestQuest "
|
||||
"panel dashboard was not registered. Enable the Lovelace "
|
||||
"integration in storage mode or create a dashboard with "
|
||||
"strategy %s manually.",
|
||||
DASHBOARD_STRATEGY_TYPE,
|
||||
)
|
||||
return False
|
||||
try:
|
||||
# Single-flight: two concurrent entry setups must not both pass
|
||||
# the exists-check and race on the url_path create.
|
||||
async with _registration_lock(hass):
|
||||
# async_items() is a SYNC @callback on the real collection
|
||||
# (helpers/collection.py ObservableCollection) — not awaited.
|
||||
if _existing_dashboard(collection.async_items()):
|
||||
return True
|
||||
await collection.async_create_item(
|
||||
{
|
||||
"url_path": DASHBOARD_URL_PATH,
|
||||
"title": DASHBOARD_TITLE,
|
||||
"icon": DASHBOARD_ICON,
|
||||
"show_in_sidebar": True,
|
||||
"allow_single_word": True,
|
||||
}
|
||||
)
|
||||
# Step two (mirroring HA's _create_map_dashboard): the
|
||||
# strategy CONFIG goes through the fresh dashboard's
|
||||
# content manager, not through the metadata collection.
|
||||
store = _dashboard_store(hass, DASHBOARD_URL_PATH)
|
||||
if store is None:
|
||||
_LOGGER.warning(
|
||||
"The NestQuest panel dashboard was registered but its "
|
||||
"Lovelace content manager never appeared; add the "
|
||||
"strategy %s to the dashboard manually.",
|
||||
DASHBOARD_STRATEGY_TYPE,
|
||||
)
|
||||
return False
|
||||
await store.async_save(
|
||||
{"strategy": {"type": DASHBOARD_STRATEGY_TYPE}}
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.warning(
|
||||
"Registering the NestQuest panel dashboard in the Lovelace "
|
||||
"dashboards collection failed; set it up manually with "
|
||||
"strategy %s.",
|
||||
DASHBOARD_STRATEGY_TYPE,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
@@ -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)
|
||||
@@ -7,8 +7,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": [
|
||||
"frontend",
|
||||
"http",
|
||||
"lovelace"
|
||||
"http"
|
||||
],
|
||||
"documentation": "https://github.com/Maniacs-Mansion/NestQuest",
|
||||
"integration_type": "hub",
|
||||
|
||||
@@ -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,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
|
||||
@@ -32,7 +32,6 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
@@ -42,21 +41,13 @@ from .coordinator import (
|
||||
)
|
||||
from .core.snapshot import instance_payload
|
||||
|
||||
#: HA's state machine refuses states longer than 255
|
||||
#: Home Assistant's state machine refuses states longer than 255
|
||||
#: characters (its hard recorder/state limit). These sensors' states
|
||||
#: are numeric, but the guard is applied uniformly: a payload-bearing
|
||||
#: sensor can never crash the state write, and later string-state
|
||||
#: sensors reuse the same helper.
|
||||
MAX_STATE_LENGTH = 255
|
||||
|
||||
#: Entity-id length cap the roster applies to HA's slugify: HA's
|
||||
#: slugifier itself does NOT truncate, but the entity registry derives
|
||||
#: entity ids as ``<domain>.<slug>`` and truncates the full entity_id at
|
||||
#: MAX_LENGTH_STATE_ENTITY_ID (64), so for this module's ``sensor.``
|
||||
#: entities the slugged object id alone may not exceed
|
||||
#: 64 - len("sensor.").
|
||||
_MAX_SLUG_LENGTH = 64 - len("sensor.")
|
||||
|
||||
|
||||
def _state_within_limit(state: Any) -> Any:
|
||||
"""Return ``state`` unchanged, refusing strings over HA's limit.
|
||||
@@ -107,51 +98,6 @@ def _next_open_quest(
|
||||
)
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
"""Slugify a display name the way Home Assistant derives entity ids.
|
||||
|
||||
Runs ``homeassistant.util.slugify`` — the real slugifier HA runs
|
||||
entity names through, which transliterates every script to ASCII
|
||||
through unidecode (so non-Latin names survive: ``京子`` slugs to
|
||||
``jing_zi``) before folding to lowercase and turning every run of
|
||||
non-alphanumerics into one underscore, answering HA's ``unknown``
|
||||
sentinel when a name slugs to nothing — then caps the result at
|
||||
the object-id length the entity registry enforces (the registry
|
||||
truncates the full ``sensor.`` + slug entity_id at 64 characters,
|
||||
so the slug alone is capped like this; HA's slugify does not
|
||||
truncate by itself).
|
||||
"""
|
||||
return slugify(text)[:_MAX_SLUG_LENGTH]
|
||||
|
||||
|
||||
def _child_roster(children: Iterable[ChildDaySnapshot]) -> list[dict[str, Any]]:
|
||||
"""The ordered child roster the panel cards discover children from.
|
||||
|
||||
One entry per ACTIVE child in the snapshot's order (the API's
|
||||
sort_order, then id): ``child_id``, ``name``, and the ``slug`` the
|
||||
entity-id derivation produces for the child's name — the piece the
|
||||
cards need to build ``sensor.nestquest_<slug>_...`` ids. Duplicate
|
||||
display names get the registry's ``_2``/``_3`` suffix in snapshot
|
||||
order, matching how HA resolves the collision at registration. The
|
||||
rollups' ``children`` attribute (per-child counts) is unchanged and
|
||||
carries a different shape on purpose.
|
||||
"""
|
||||
slugs: dict[str, int] = {}
|
||||
roster: list[dict[str, Any]] = []
|
||||
for child in children:
|
||||
base = _slugify(child.child_name)
|
||||
taken = slugs.get(base, 0) + 1
|
||||
slugs[base] = taken
|
||||
roster.append(
|
||||
{
|
||||
"child_id": child.child_id,
|
||||
"name": child.child_name,
|
||||
"slug": base if taken == 1 else f"{base}_{taken}",
|
||||
}
|
||||
)
|
||||
return roster
|
||||
|
||||
|
||||
class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity):
|
||||
"""Base for the per-child day sensors.
|
||||
|
||||
@@ -410,12 +356,7 @@ class _NestQuestHouseholdSensor(CoordinatorEntity, SensorEntity):
|
||||
|
||||
|
||||
class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
|
||||
"""Total quests due today across active children.
|
||||
|
||||
Also publishes ``child_roster`` (see :func:`_child_roster`): the
|
||||
ordered child identity list the panel cards read to discover the
|
||||
board's children when the card config carries no ``child_order``.
|
||||
"""
|
||||
"""Total quests due today across active children."""
|
||||
|
||||
_attr_name = "NestQuest household quests due today"
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
@@ -439,8 +380,7 @@ class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
|
||||
"children": {
|
||||
str(child.child_id): child.due_today
|
||||
for child in data.children
|
||||
},
|
||||
"child_roster": _child_roster(data.children),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 (and the
|
||||
presence DAOs). ``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 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 .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
||||
from .events import fire_quest_completed, fire_quest_uncompleted
|
||||
from .materialize import regenerate_for_child
|
||||
from .permissions import permission_gate
|
||||
from .presence import PresenceSchedule
|
||||
from .recurrence import ScheduleRule
|
||||
from .service_policy import SERVICE_POLICY
|
||||
|
||||
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,243 @@ 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
|
||||
schedule = PresenceSchedule(
|
||||
data["child_id"],
|
||||
data["cycle_length_weeks"],
|
||||
data["anchor_date"],
|
||||
data["pattern"],
|
||||
)
|
||||
await PresenceSchedulesDao(runtime.database).upsert_by_child(
|
||||
schedule.child_id,
|
||||
schedule.cycle_length_weeks,
|
||||
schedule.anchor_date.isoformat(),
|
||||
schedule.encode(),
|
||||
)
|
||||
await regenerate_for_child(
|
||||
runtime.database,
|
||||
schedule.child_id,
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
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 PresenceOverridesDao(runtime.database).create(
|
||||
data["child_id"],
|
||||
data["start_date"],
|
||||
data["end_date"],
|
||||
data["is_present"],
|
||||
note=data.get("note"),
|
||||
)
|
||||
await regenerate_for_child(
|
||||
runtime.database,
|
||||
data["child_id"],
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
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)
|
||||
dao = PresenceOverridesDao(runtime.database)
|
||||
override_id = call.data["override_id"]
|
||||
override = await dao.get(override_id)
|
||||
if override 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)
|
||||
f"override_id: presence override {override_id} does not exist"
|
||||
)
|
||||
await dao.delete(override_id)
|
||||
await regenerate_for_child(
|
||||
runtime.database,
|
||||
override.child_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(
|
||||
"ordered_ids is required when action is reorder"
|
||||
)
|
||||
await children_layer.reorder_children(database, ordered_ids)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
@@ -166,25 +546,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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
Generated
-522
@@ -9,197 +9,10 @@
|
||||
"lit": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^30.1.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz",
|
||||
"integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^3.4.0",
|
||||
"@csstools/css-color-parser": "^4.2.3",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.1",
|
||||
"lru-cache": "^11.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz",
|
||||
"integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bidi-js": "^1.1.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
|
||||
"integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz",
|
||||
"integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz",
|
||||
"integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.1.1",
|
||||
"@csstools/css-calc": "^3.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
|
||||
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"peerDependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-tree": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz",
|
||||
"integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
@@ -642,24 +455,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
|
||||
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@lit-labs/ssr-dom-shim": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz",
|
||||
@@ -1097,79 +892,6 @@
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
|
||||
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.27.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls/node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
|
||||
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
@@ -1245,66 +967,6 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
|
||||
"integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.15.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "30.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz",
|
||||
"integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^7.0.0",
|
||||
"@asamuzakjp/dom-selector": "^9.2.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.13",
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^7.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.2",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"tough-cookie": "^6.0.2",
|
||||
"undici": "^8.10.2",
|
||||
"w3c-xmlserializer": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^17.1.1",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.2.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lit": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
|
||||
@@ -1336,23 +998,6 @@
|
||||
"@types/trusted-types": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.19",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
||||
@@ -1372,19 +1017,6 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1434,26 +1066,6 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.63.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",
|
||||
@@ -1500,19 +1112,6 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -1540,52 +1139,6 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.4.14",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.14.tgz",
|
||||
"integrity": "sha512-EahQoi+Q5oqmG3yxRHx+bwn36OaLbtw6jnTFcGjc8fcmktzTgk45xRnzAx8Ch87PFEiqRhoc3FWBmY+LBrvEGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.4.14"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.4.14",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.14.tgz",
|
||||
"integrity": "sha512-KYkjfJHnIC5t+Gy7hPrMHgJmWc/N9FfmS+fggRPSlpFckidpB9HD6OzPsSTkfDH+MpZgcu2tJPGgLfHl979N1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
|
||||
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -1600,16 +1153,6 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "8.11.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.11.0.tgz",
|
||||
"integrity": "sha512-AdzuGcAkzhbha3GgCSoUBx2P0quwym6qdFNCQz/fDGZc8w9XJ741wHUbt2kTmfPz+ZIOMkySFkiY7RXmiDDC8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
||||
@@ -1684,71 +1227,6 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
|
||||
"integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "17.1.2",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz",
|
||||
"integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.14.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"lit": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^30.1.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5"
|
||||
}
|
||||
|
||||
@@ -143,16 +143,6 @@ function asNumber(value: unknown, fallback: number): number {
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
/** The slug a discovered roster entry carries, or "" when the entry is
|
||||
* not an object with a non-empty slug string. */
|
||||
function asRosterSlug(entry: unknown): string {
|
||||
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return "";
|
||||
}
|
||||
const slug = (entry as Record<string, unknown>).slug;
|
||||
return typeof slug === "string" ? slug.trim() : "";
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
@@ -902,34 +892,15 @@ export class NestQuestPartyBoardCard extends LitElement {
|
||||
return [];
|
||||
}
|
||||
const order = this._config.child_order;
|
||||
if (Array.isArray(order)) {
|
||||
return order
|
||||
.filter(
|
||||
(entry): entry is string =>
|
||||
typeof entry === "string" && entry.trim().length > 0
|
||||
)
|
||||
.map((entry) => entry.trim());
|
||||
}
|
||||
return this._discoveredChildSlugs();
|
||||
}
|
||||
|
||||
/** Zero-config discovery (Feature 20): the ordered child roster the
|
||||
* integration publishes on the household rollup
|
||||
* (custom_components/nestquest/sensor.py — ``child_roster``, the
|
||||
* snapshot's sort_order, then id), each entry carrying the slug the
|
||||
* per-child entity ids are built from. Consulted only when the
|
||||
* config has no child_order — explicit configuration wins. */
|
||||
private _discoveredChildSlugs(): string[] {
|
||||
const household = this._state(
|
||||
"sensor.nestquest_household_quests_due_today"
|
||||
);
|
||||
const roster = household?.attributes?.child_roster;
|
||||
if (!Array.isArray(roster)) {
|
||||
if (!Array.isArray(order)) {
|
||||
return [];
|
||||
}
|
||||
return roster
|
||||
.map((entry) => asRosterSlug(entry))
|
||||
.filter((slug) => slug.length > 0);
|
||||
return order
|
||||
.filter(
|
||||
(entry): entry is string =>
|
||||
typeof entry === "string" && entry.trim().length > 0
|
||||
)
|
||||
.map((entry) => entry.trim());
|
||||
}
|
||||
|
||||
private _plates(): ChildPlate[] {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import "./cards/party-board";
|
||||
import "./cards/quest-log";
|
||||
import "./cards/admin";
|
||||
import "./strategy";
|
||||
|
||||
const FONTS_HREF = "/nestquest-static/nestquest-fonts.css";
|
||||
if (!document.querySelector('link[data-nq-fonts=""]')) {
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import type { HassLike } from "./types";
|
||||
|
||||
type StateObject = {
|
||||
state: unknown;
|
||||
attributes?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type RosterEntry = {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type View = {
|
||||
path?: string;
|
||||
title?: string;
|
||||
type: string;
|
||||
cards: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
type LovelaceConfig = {
|
||||
views: View[];
|
||||
};
|
||||
|
||||
type StrategyConfig = Record<string, unknown>;
|
||||
|
||||
type StrategyClass = {
|
||||
generate: (
|
||||
config: StrategyConfig,
|
||||
hass: HassLike | undefined
|
||||
) => Promise<LovelaceConfig>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
customStrategies?: Record<string, StrategyClass>;
|
||||
}
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
/** The slug a discovered roster entry carries, or "" when the entry is
|
||||
* not an object with a non-empty slug string — the same shape check
|
||||
* the party board applies to the same attribute. */
|
||||
function asRosterSlug(entry: unknown): string {
|
||||
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return "";
|
||||
}
|
||||
const slug = (entry as Record<string, unknown>).slug;
|
||||
return typeof slug === "string" ? slug.trim() : "";
|
||||
}
|
||||
|
||||
/** The dashboard's URL path segment, used to COMPUTE the card paths the
|
||||
* navigation needs. A dashboard lives at a single top-level segment
|
||||
* (/<url_path>), so the first pathname segment is the dashboard root —
|
||||
* this is where the strategy renders, and navigating back to it shows
|
||||
* the first view (the party board). An explicit ``url_path`` in the
|
||||
* strategy configuration wins for setups that need to pin it. */
|
||||
function dashboardUrlPath(config: StrategyConfig): string {
|
||||
const configured = asString(config.url_path).replace(/^\/+|\/+$/g, "");
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
const segment = window.location.pathname.split("/").filter(Boolean)[0];
|
||||
return segment ?? "";
|
||||
}
|
||||
|
||||
/** The display name a roster entry carries, or "" when it is not a
|
||||
* non-empty string — the roster's ``name`` field (sensor.py
|
||||
* ``_child_roster``), used for the quest-log view titles. */
|
||||
function asRosterName(entry: unknown): string {
|
||||
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return "";
|
||||
}
|
||||
const name = (entry as Record<string, unknown>).name;
|
||||
return typeof name === "string" ? name.trim() : "";
|
||||
}
|
||||
|
||||
/** The ordered child roster the integration publishes on the household
|
||||
* rollup (custom_components/nestquest/sensor.py — ``child_roster``,
|
||||
* the snapshot's sort_order, then id). The same source the party
|
||||
* board's plate discovery reads, consumed here at render time so the
|
||||
* quest-log views exist for exactly the active children. */
|
||||
function rosterEntries(hass: HassLike | undefined): RosterEntry[] {
|
||||
const states = hass?.states as Record<string, StateObject> | undefined;
|
||||
const household = states?.["sensor.nestquest_household_quests_due_today"];
|
||||
const roster = household?.attributes?.child_roster;
|
||||
if (!Array.isArray(roster)) {
|
||||
return [];
|
||||
}
|
||||
return roster
|
||||
.map((entry) => ({ slug: asRosterSlug(entry), name: asRosterName(entry) }))
|
||||
.filter((entry) => entry.slug.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-config NestQuest dashboard strategy (Feature 20).
|
||||
*
|
||||
* A dashboard configured with ``strategy: { type: custom:nestquest-party }``
|
||||
* gets its views GENERATED at render time from the household roster: one
|
||||
* party-board view (the dashboard root) and one quest-log view per child,
|
||||
* ordered by the roster's sort_order. The paths the cards navigate by are
|
||||
* COMPUTED, not configured:
|
||||
*
|
||||
* - the board's ``quest_log_path`` is the dashboard root; tapping a crest
|
||||
* pushes ``<root>/<slug>`` (party-board.ts ``_openQuestLog``);
|
||||
* - each log view's ``path`` IS the child's slug, so the log resolves its
|
||||
* child from the last pathname segment (quest-log.ts
|
||||
* ``childSlugFromPath``) and its ``board_path`` back to the dashboard
|
||||
* root (quest-log.ts ``_returnToBoard``).
|
||||
*
|
||||
* With zero children the board view still renders — the board card shows
|
||||
* its own "not set up yet" notice for an empty roster, so no log views are
|
||||
* needed. ``url_path`` and ``weather_entity`` may still be set explicitly
|
||||
* in the strategy configuration; everything else is derived.
|
||||
*/
|
||||
export class NestQuestPartyStrategy {
|
||||
static async generate(
|
||||
config: StrategyConfig,
|
||||
hass: HassLike | undefined
|
||||
): Promise<LovelaceConfig> {
|
||||
const root = `/${dashboardUrlPath(config)}`;
|
||||
const weatherEntity = asString(config.weather_entity);
|
||||
const boardCard: Record<string, unknown> = {
|
||||
type: "custom:nestquest-party-board-card",
|
||||
quest_log_path: root,
|
||||
};
|
||||
if (weatherEntity) {
|
||||
boardCard.weather_entity = weatherEntity;
|
||||
}
|
||||
const logCard: Record<string, unknown> = {
|
||||
type: "custom:nestquest-quest-log-card",
|
||||
board_path: root,
|
||||
};
|
||||
if (weatherEntity) {
|
||||
logCard.weather_entity = weatherEntity;
|
||||
}
|
||||
return {
|
||||
views: [
|
||||
{
|
||||
path: "party",
|
||||
title: "The Party",
|
||||
type: "panel",
|
||||
cards: [boardCard],
|
||||
},
|
||||
...rosterEntries(hass).map<View>((entry) => ({
|
||||
path: entry.slug,
|
||||
title: entry.name
|
||||
? `${entry.name}'s Quest Log`
|
||||
: `${entry.slug}'s Quest Log`,
|
||||
type: "panel",
|
||||
cards: [{ ...logCard }],
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
window.customStrategies = window.customStrategies ?? {};
|
||||
window.customStrategies["nestquest-party"] = NestQuestPartyStrategy;
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Render the built party-board card under jsdom and print its plates.
|
||||
*
|
||||
* Usage: node render-party-board.mjs < spec.json
|
||||
*
|
||||
* The spec JSON carries {bundle, tag, config, hass, url?, click_plate?};
|
||||
* stdout receives one JSON object {plates, tappable, location, html} where
|
||||
* plates is the ordered plate list the shadow DOM renders (name +
|
||||
* present/away/unknown kind), tappable the plate names rendered as tappable
|
||||
* <button> plates, and location the window pathname after the optional plate
|
||||
* tap. The Python panel tests drive this harness so the assertions run
|
||||
* against the exact bundle HACS ships.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const spec = JSON.parse(readFileSync(0, "utf8"));
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: spec.url ?? "http://homeassistant.local/nestquest/board",
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
|
||||
// Expose the jsdom globals the Lit bundle expects. Node's own intrinsics
|
||||
// (Object, Array, ...) are kept — only what globalThis lacks is copied.
|
||||
for (const key of Object.getOwnPropertyNames(dom.window)) {
|
||||
if (!(key in globalThis)) {
|
||||
try {
|
||||
globalThis[key] = dom.window[key];
|
||||
} catch {
|
||||
// jsdom getters may throw on access; the card does not need them.
|
||||
}
|
||||
}
|
||||
}
|
||||
globalThis.window = dom.window;
|
||||
// The card navigates by dispatching new Event("location-changed") on this
|
||||
// window; jsdom's EventTarget rejects Node's own global Event class, so the
|
||||
// jsdom Event must shadow it for the dispatched event to be delivered.
|
||||
globalThis.Event = dom.window.Event;
|
||||
|
||||
await import(pathToFileURL(spec.bundle).href);
|
||||
|
||||
const element = document.createElement(spec.tag);
|
||||
element.setConfig(spec.config);
|
||||
element.hass = spec.hass;
|
||||
document.body.appendChild(element);
|
||||
await element.updateComplete;
|
||||
|
||||
const plateNodes = [
|
||||
...element.shadowRoot.querySelectorAll(".plates .plate"),
|
||||
];
|
||||
const plates = plateNodes.map((plate) => ({
|
||||
name: plate.querySelector(".name")?.textContent?.trim() ?? "",
|
||||
kind: plate.classList.contains("unknown")
|
||||
? "unknown"
|
||||
: plate.classList.contains("away")
|
||||
? "away"
|
||||
: "present",
|
||||
}));
|
||||
const tappable = plateNodes
|
||||
.filter((plate) => plate.tagName === "BUTTON")
|
||||
.map((plate) => plate.querySelector(".name")?.textContent?.trim() ?? "");
|
||||
|
||||
if (spec.click_plate !== undefined) {
|
||||
const plate = plateNodes.find(
|
||||
(candidate) =>
|
||||
candidate.querySelector(".name")?.textContent?.trim() === spec.click_plate
|
||||
);
|
||||
plate?.click();
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
plates,
|
||||
tappable,
|
||||
location: window.location.pathname,
|
||||
html: element.shadowRoot.innerHTML,
|
||||
});
|
||||
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
|
||||
process.exit(0);
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* Render the built quest-log card under jsdom and report its location.
|
||||
*
|
||||
* Usage: node render-quest-log.mjs < spec.json
|
||||
*
|
||||
* The spec JSON carries {bundle, url, config, hass, idle_ms?, probe_ms?};
|
||||
* the card is mounted at the given URL (it resolves its child from the
|
||||
* pathname's last segment), after the optional probe wait one snapshot of
|
||||
* the render is taken mid-wait, then after the optional idle wait stdout
|
||||
* receives one JSON object {location, headline, countdown, html, probe} —
|
||||
* the final window pathname, the rendered title, the complete-screen
|
||||
* countdown text, the rendered shadow DOM, and the probe snapshot when
|
||||
* probe_ms was given. The Python panel tests drive this harness so the
|
||||
* assertions run against the exact bundle HACS ships.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const spec = JSON.parse(readFileSync(0, "utf8"));
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: spec.url ?? "http://homeassistant.local/nestquest/ada",
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
|
||||
// Expose the jsdom globals the Lit bundle expects. Node's own intrinsics
|
||||
// (Object, Array, ...) are kept — only what globalThis lacks is copied.
|
||||
for (const key of Object.getOwnPropertyNames(dom.window)) {
|
||||
if (!(key in globalThis)) {
|
||||
try {
|
||||
globalThis[key] = dom.window[key];
|
||||
} catch {
|
||||
// jsdom getters may throw on access; the card does not need them.
|
||||
}
|
||||
}
|
||||
}
|
||||
globalThis.window = dom.window;
|
||||
// The card navigates by dispatching new Event("location-changed") on this
|
||||
// window; jsdom's EventTarget rejects Node's own global Event class, so the
|
||||
// jsdom Event must shadow it for the return navigation to be delivered.
|
||||
globalThis.Event = dom.window.Event;
|
||||
|
||||
await import(pathToFileURL(spec.bundle).href);
|
||||
|
||||
const element = document.createElement("nestquest-quest-log-card");
|
||||
element.setConfig(spec.config);
|
||||
element.hass = spec.hass;
|
||||
document.body.appendChild(element);
|
||||
await element.updateComplete;
|
||||
|
||||
const snapshot = () => ({
|
||||
location: window.location.pathname,
|
||||
headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "",
|
||||
countdown:
|
||||
element.shadowRoot.querySelector(".complete-countdown")?.textContent?.trim() ??
|
||||
"",
|
||||
html: element.shadowRoot.innerHTML,
|
||||
});
|
||||
|
||||
let probe = null;
|
||||
if (spec.probe_ms !== undefined) {
|
||||
await new Promise((resolve) => setTimeout(resolve, spec.probe_ms));
|
||||
probe = snapshot();
|
||||
}
|
||||
|
||||
if (spec.idle_ms !== undefined) {
|
||||
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({ ...snapshot(), probe });
|
||||
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
|
||||
process.exit(0);
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Import the built bundle into jsdom and generate the dashboard strategy's
|
||||
* views.
|
||||
*
|
||||
* Usage: node render-strategy.mjs < spec.json
|
||||
*
|
||||
* The spec JSON carries {bundle, url, config, hass}; stdout receives one
|
||||
* JSON object {views} — the strategy output's views array with the paths
|
||||
* and card configs the generated dashboard renders. The Python panel
|
||||
* tests drive this harness so the assertions run against the exact bundle
|
||||
* HACS ships.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const spec = JSON.parse(readFileSync(0, "utf8"));
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: spec.url ?? "http://homeassistant.local/nestquest/party",
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
|
||||
// Expose the jsdom globals the Lit bundle expects. Node's own intrinsics
|
||||
// (Object, Array, ...) are kept — only what globalThis lacks is copied.
|
||||
for (const key of Object.getOwnPropertyNames(dom.window)) {
|
||||
if (!(key in globalThis)) {
|
||||
try {
|
||||
globalThis[key] = dom.window[key];
|
||||
} catch {
|
||||
// jsdom getters may throw on access; the strategy does not need them.
|
||||
}
|
||||
}
|
||||
}
|
||||
globalThis.window = dom.window;
|
||||
|
||||
await import(pathToFileURL(spec.bundle).href);
|
||||
|
||||
const strategy = window.customStrategies?.["nestquest-party"];
|
||||
if (!strategy) {
|
||||
console.error("nestquest-party strategy is not registered on the bundle");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const lovelace = await strategy.generate(spec.config, spec.hass);
|
||||
const payload = JSON.stringify({ views: lovelace.views });
|
||||
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
|
||||
process.exit(0);
|
||||
@@ -1,86 +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)
|
||||
|
||||
def test_task_complete_feature_triggers_one_guarded_promotion(self):
|
||||
output = RENDER(TEMPLATE, PROFILE)
|
||||
self.assertIn(
|
||||
"feature becomes task-complete and its reviewed merge into `dev` is verified",
|
||||
output,
|
||||
)
|
||||
self.assertIn("Every required feature task is `done`", output)
|
||||
self.assertIn("No task review, remediation, approval, or merge remains pending", output)
|
||||
self.assertIn("feature acceptance criteria and integrated checks pass", output)
|
||||
self.assertIn("Independent approval applies to the exact feature head", output)
|
||||
self.assertIn("approver's merge of that head into `dev` is verified", output)
|
||||
self.assertIn("reflects the completed feature and exact repository references", output)
|
||||
self.assertIn("Task completion inside an unmerged feature branch does not trigger", output)
|
||||
self.assertIn("Do not create a duplicate promotion", output)
|
||||
self.assertIn("Allow only one active promotion pull request to `main`", output)
|
||||
self.assertIn("if it was closed without merge, recalculate", output)
|
||||
self.assertIn("sole active orchestrator for the project", output)
|
||||
self.assertIn("including controller exclusivity, reconciliation", output)
|
||||
self.assertIn("RELEASE_PREPARATION_POLICY = EXCLUDED", output)
|
||||
self.assertIn("Active promotion branch and pull request", output)
|
||||
self.assertIn("An active promotion that has not reached `RELEASE_READY`", output)
|
||||
self.assertIn("RELEASE_PREPARATION_POLICY:\n", output)
|
||||
self.assertIn("A ready promotion is a local checkpoint", output)
|
||||
self.assertIn("continue that work while the ready promotion waits", output)
|
||||
self.assertLess(
|
||||
output.index("Begin an automatically triggered, requested, or scheduled release"),
|
||||
output.index("Advance to the next eligible feature"),
|
||||
)
|
||||
self.assertIn("Only Joshua may merge into `main`", 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,91 +0,0 @@
|
||||
from pathlib import Path
|
||||
import fcntl
|
||||
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),
|
||||
"NQ_STATE_DIR": str(root / "state"),
|
||||
})
|
||||
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()
|
||||
lock_path = root / "state/controller.lock"
|
||||
with lock_path.open("w") as held_lock:
|
||||
fcntl.flock(held_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
locked = subprocess.run([str(SCRIPT)], env=env, text=True,
|
||||
capture_output=True)
|
||||
self.assertEqual(locked.returncode, 75)
|
||||
self.assertIn("Another NestQuest orchestrator or controller",
|
||||
locked.stderr)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
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()
|
||||
@@ -21,7 +21,6 @@ dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pyyaml>=6",
|
||||
"unidecode>=1.4.0",
|
||||
"uvicorn>=0.30",
|
||||
"voluptuous>=0.14",
|
||||
]
|
||||
|
||||
@@ -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,64 +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}
|
||||
state_dir=${NQ_STATE_DIR:-/home/overseer/.local/state/nestquest-controller}
|
||||
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
|
||||
|
||||
# Share the controller's lock and hold it across the final exec. This closes
|
||||
# the launch-time race between two foreground sessions as well as the race with
|
||||
# the background controller.
|
||||
mkdir -p "$state_dir"
|
||||
chmod 700 "$state_dir"
|
||||
exec 9>"$state_dir/controller.lock"
|
||||
if ! flock -n 9; then
|
||||
echo "Another NestQuest orchestrator or controller is running." >&2
|
||||
exit 75
|
||||
fi
|
||||
|
||||
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())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
"""Shared admin-plane JWT test harness: local keys, stubbed JWKS.
|
||||
|
||||
The admin-plane API test modules share ONE harness instead of
|
||||
duplicating it: the auth contract tests (:mod:`tests.test_api_admin_auth`)
|
||||
and the children route tests (:mod:`tests.test_api_admin_children`) both
|
||||
drive the ASGI app with httpx using a locally generated RSA key pair and
|
||||
a stubbed JWKS fetcher — NO network access:
|
||||
|
||||
- ``ADMIN_KEY`` signs the test tokens; :func:`jwks_for` builds the JWKS
|
||||
document publishing its public half, so genuinely signed tokens
|
||||
verify, and tokens signed with the second pair (``OTHER_KEY``) fail
|
||||
the signature check for real.
|
||||
- :func:`make_token` mints one RS256 JWT the way Authentik would (its
|
||||
claims, our key), defaulting ``groups`` to the admin group a real
|
||||
admin's token carries.
|
||||
- :class:`StubbedFetch` is the counting JWKS fetcher stub.
|
||||
- :class:`AdminRunner` runs the app's lifespan around an httpx
|
||||
AsyncClient (httpx's ASGI transport does not run the lifespan, so it
|
||||
is driven explicitly and shut down in ``__aexit__``'s finally block
|
||||
even on failure) with the app's JWKS cache swapped for one built
|
||||
around the stub fetcher.
|
||||
|
||||
The harness carries no tests itself; the ``admin_client`` fixture in
|
||||
``tests/conftest.py`` hands its runner to the test modules.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
from api.app import create_app
|
||||
from api.auth import ADMIN_GROUP, JwksCache
|
||||
from api.config import ApiConfig
|
||||
|
||||
#: The configured OIDC settings the test apps run with (values a real
|
||||
#: deployment would get from the NESTQUEST_OIDC_* environment vars).
|
||||
ISSUER = "https://authentik.example.com/application/o/nestquest/"
|
||||
AUDIENCE = "nestquest-api"
|
||||
JWKS_URL = "https://authentik.example.com/application/o/nestquest/jwks/"
|
||||
|
||||
#: The ``kid`` the test tokens carry, matching the JWKS entry.
|
||||
KID = "admin-test-key-1"
|
||||
|
||||
#: The subject a valid token authenticates as.
|
||||
SUBJECT = "admin-user-1"
|
||||
|
||||
|
||||
def _generate_key() -> rsa.RSAPrivateKey:
|
||||
"""Generate one local RSA key pair (no network, no fixtures)."""
|
||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
#: The key pair whose PUBLIC half the stubbed JWKS publishes — tokens
|
||||
#: signed with it verify; the second pair below proves they don't when
|
||||
#: signed otherwise.
|
||||
ADMIN_KEY = _generate_key()
|
||||
OTHER_KEY = _generate_key()
|
||||
|
||||
|
||||
def _b64url_uint(value: int) -> str:
|
||||
"""Base64url-encode a non-negative integer (no padding), per JWK."""
|
||||
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def jwks_for(private_key: rsa.RSAPrivateKey, kid: str) -> dict[str, object]:
|
||||
"""Build a JWKS document publishing ``private_key``'s public half."""
|
||||
numbers = private_key.public_key().public_numbers()
|
||||
return {
|
||||
"keys": [
|
||||
{
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"kid": kid,
|
||||
"n": _b64url_uint(numbers.n),
|
||||
"e": _b64url_uint(numbers.e),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class StubbedFetch:
|
||||
"""A JWKS fetcher stub: returns a fixed document, counts calls."""
|
||||
|
||||
def __init__(self, document: dict[str, object]) -> None:
|
||||
self.document = document
|
||||
self.calls = 0
|
||||
|
||||
async def __call__(self, url: str) -> dict[str, object]:
|
||||
self.calls += 1
|
||||
return self.document
|
||||
|
||||
|
||||
def make_token(
|
||||
signing_key: rsa.RSAPrivateKey = ADMIN_KEY,
|
||||
*,
|
||||
kid: str | None = KID,
|
||||
iss: str = ISSUER,
|
||||
aud: object = AUDIENCE,
|
||||
sub: str = SUBJECT,
|
||||
expires_in: int | None = 300,
|
||||
nbf_in: int | None = -1,
|
||||
groups: object = (ADMIN_GROUP,),
|
||||
) -> str:
|
||||
"""Mint one RS256 JWT the way Authentik would (its claims, our key).
|
||||
|
||||
``groups`` defaults to the admin group (what a real admin user's
|
||||
token carries); tests pass a different value — or ``None`` for NO
|
||||
groups claim at all — to prove the group gate rejects it.
|
||||
"""
|
||||
now = int(time.time())
|
||||
claims: dict[str, object] = {"iss": iss, "aud": aud, "sub": sub, "iat": now}
|
||||
if groups is not None:
|
||||
claims["groups"] = (
|
||||
list(groups) if isinstance(groups, (list, tuple)) else groups
|
||||
)
|
||||
if nbf_in is not None:
|
||||
claims["nbf"] = now + nbf_in
|
||||
if expires_in is not None:
|
||||
claims["exp"] = now + expires_in
|
||||
headers: dict[str, str] = {}
|
||||
if kid is not None:
|
||||
headers["kid"] = kid
|
||||
return jwt.encode(
|
||||
claims, signing_key, algorithm="RS256", headers=headers
|
||||
)
|
||||
|
||||
|
||||
class AdminRunner:
|
||||
"""Run the app's lifespan around an httpx AsyncClient.
|
||||
|
||||
Same pattern as test_api_health.py's ``_AppRunner``: httpx's ASGI
|
||||
transport does not run the lifespan, so it is driven explicitly and
|
||||
shut down in ``__aexit__``'s finally block even on failure.
|
||||
|
||||
The app's JWKS cache is replaced (before the lifespan runs) with
|
||||
one built around a counting stub fetcher, so verification goes
|
||||
through the REAL cache logic but never the network.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path: str,
|
||||
jwks_document: dict[str, object],
|
||||
*,
|
||||
ttl_seconds: float | None = None,
|
||||
backoff_seconds: float | None = None,
|
||||
kid_refetch_seconds: float | None = None,
|
||||
) -> None:
|
||||
self.app = create_app(
|
||||
ApiConfig(
|
||||
db_path=db_path,
|
||||
panel_token="admin-test-panel-token",
|
||||
oidc_issuer=ISSUER,
|
||||
oidc_audience=AUDIENCE,
|
||||
oidc_jwks_url=JWKS_URL,
|
||||
)
|
||||
)
|
||||
self.fetch = StubbedFetch(jwks_document)
|
||||
cache_kwargs: dict[str, float] = {}
|
||||
if ttl_seconds is not None:
|
||||
cache_kwargs["ttl_seconds"] = ttl_seconds
|
||||
if backoff_seconds is not None:
|
||||
cache_kwargs["backoff_seconds"] = backoff_seconds
|
||||
if kid_refetch_seconds is not None:
|
||||
cache_kwargs["kid_refetch_seconds"] = kid_refetch_seconds
|
||||
self.app.state.jwks_cache = JwksCache(
|
||||
JWKS_URL, fetcher=self.fetch, **cache_kwargs
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> httpx.AsyncClient:
|
||||
self._lifespan = self.app.router.lifespan_context(self.app)
|
||||
await self._lifespan.__aenter__()
|
||||
self._client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=self.app),
|
||||
base_url="http://testserver",
|
||||
)
|
||||
return await self._client.__aenter__()
|
||||
|
||||
async def __aexit__(self, *exc) -> None:
|
||||
try:
|
||||
await self._client.__aexit__(*exc)
|
||||
finally:
|
||||
await self._lifespan.__aexit__(*exc)
|
||||
+39
-304
@@ -22,24 +22,18 @@ 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 re
|
||||
import sys
|
||||
import tempfile
|
||||
import unicodedata
|
||||
import weakref
|
||||
from dataclasses import dataclass
|
||||
from html.entities import name2codepoint
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from unidecode import unidecode as _unidecode
|
||||
|
||||
from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Homeassistant module provisioning (explicitly mock-only).
|
||||
@@ -50,13 +44,11 @@ _HA_MODULES = (
|
||||
"homeassistant.config_entries",
|
||||
"homeassistant.data_entry_flow",
|
||||
"homeassistant.exceptions",
|
||||
"homeassistant.util",
|
||||
"homeassistant.helpers",
|
||||
"homeassistant.helpers.config_validation",
|
||||
"homeassistant.helpers.entity",
|
||||
"homeassistant.helpers.event",
|
||||
"homeassistant.helpers.update_coordinator",
|
||||
"homeassistant.helpers.aiohttp_client",
|
||||
"homeassistant.components",
|
||||
"homeassistant.components.sensor",
|
||||
"homeassistant.components.binary_sensor",
|
||||
@@ -99,13 +91,11 @@ for _parent, _child in (
|
||||
("homeassistant", "data_entry_flow"),
|
||||
("homeassistant", "core"),
|
||||
("homeassistant", "exceptions"),
|
||||
("homeassistant", "util"),
|
||||
("homeassistant", "helpers"),
|
||||
("homeassistant.helpers", "config_validation"),
|
||||
("homeassistant.helpers", "entity"),
|
||||
("homeassistant.helpers", "event"),
|
||||
("homeassistant.helpers", "update_coordinator"),
|
||||
("homeassistant.helpers", "aiohttp_client"),
|
||||
("homeassistant", "components"),
|
||||
("homeassistant.components", "sensor"),
|
||||
("homeassistant.components", "binary_sensor"),
|
||||
@@ -224,68 +214,6 @@ _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
||||
_exceptions_mock.Unauthorized = Unauthorized
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# homeassistant.util.slugify: a faithful stand-in for the real slugifier.
|
||||
#
|
||||
# Real HA (2024.6) wraps python-slugify's frozen legacy pipeline and answers
|
||||
# its "unknown" sentinel when a non-empty name slugs to nothing. The stub
|
||||
# mirrors that pipeline step for step — quotes to dashes, NFKD-normalize,
|
||||
# TRANSLITERATE through unidecode (the backend python-slugify's "auto" mode
|
||||
# prefers, and a dev dependency here), decode HTML entities, NFKD again,
|
||||
# lowercase, drop quotes, fold runs of non-[a-z0-9-] to one dash, collapse
|
||||
# and strip dashes, then map dashes to the requested separator — and was
|
||||
# verified byte-for-byte against real python-slugify across Latin, Cyrillic,
|
||||
# CJK and Greek household-name shapes. Transliteration is the point: HA
|
||||
# derives the per-child entity ids from a name like "京子" as
|
||||
# "nestquest_jing_zi_…", so the roster's slugs must transliterate too,
|
||||
# never dropping characters into "unknown". Deliberately NOT truncating:
|
||||
# real slugify caps nothing; the entity registry truncates the full
|
||||
# entity_id instead.
|
||||
# ---------------------------------------------------------------------------
|
||||
_HA_QUOTE_PATTERN = re.compile(r"[']+")
|
||||
_HA_CHAR_ENTITY_PATTERN = re.compile(r"&(%s);" % "|".join(name2codepoint))
|
||||
_HA_DECIMAL_PATTERN = re.compile(r"&#(\d+);")
|
||||
_HA_HEX_PATTERN = re.compile(r"&#x([\da-fA-F]+);")
|
||||
_HA_NUMBERS_PATTERN = re.compile(r"(?<=\d),(?=\d)")
|
||||
_HA_DISALLOWED_CHARS_PATTERN = re.compile(r"[^-a-zA-Z0-9]+")
|
||||
_HA_DUPLICATE_DASH_PATTERN = re.compile(r"-{2,}")
|
||||
|
||||
|
||||
def _ha_slugify(text: str | None, *, separator: str = "_") -> str:
|
||||
"""Faithful homeassistant.util.slugify stand-in (see section above)."""
|
||||
if text == "" or text is None:
|
||||
return ""
|
||||
text = str(text)
|
||||
text = _HA_QUOTE_PATTERN.sub("-", text)
|
||||
text = unicodedata.normalize("NFKD", text)
|
||||
text = _unidecode(text)
|
||||
text = _HA_CHAR_ENTITY_PATTERN.sub(
|
||||
lambda match: chr(name2codepoint[match.group(1)]), text
|
||||
)
|
||||
try:
|
||||
text = _HA_DECIMAL_PATTERN.sub(
|
||||
lambda match: chr(int(match.group(1))), text
|
||||
)
|
||||
except (ValueError, OverflowError):
|
||||
pass
|
||||
try:
|
||||
text = _HA_HEX_PATTERN.sub(
|
||||
lambda match: chr(int(match.group(1), 16)), text
|
||||
)
|
||||
except (ValueError, OverflowError):
|
||||
pass
|
||||
text = unicodedata.normalize("NFKD", text)
|
||||
text = text.lower()
|
||||
text = _HA_QUOTE_PATTERN.sub("", text)
|
||||
text = _HA_NUMBERS_PATTERN.sub("", text)
|
||||
text = _HA_DISALLOWED_CHARS_PATTERN.sub("-", text)
|
||||
text = _HA_DUPLICATE_DASH_PATTERN.sub("-", text).strip("-")
|
||||
return "unknown" if text == "" else text.replace("-", separator)
|
||||
|
||||
|
||||
_ha_mock("homeassistant.util").slugify = _ha_slugify
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceInfo:
|
||||
"""Stand-in for homeassistant.helpers.entity.DeviceInfo.
|
||||
@@ -434,15 +362,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.
|
||||
|
||||
@@ -451,20 +370,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):
|
||||
@@ -474,7 +381,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):
|
||||
@@ -494,35 +400,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."""
|
||||
@@ -532,7 +421,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
|
||||
@@ -809,174 +697,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."""
|
||||
|
||||
@@ -1200,22 +920,37 @@ def make_config_flow_entry():
|
||||
return _factory
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin-plane API fixtures (shared by the admin-plane test modules).
|
||||
#
|
||||
# The local-key/stubbed-JWKS harness itself lives in
|
||||
# :mod:`tests.admin_jwt_harness` (imported by name, the same pattern as
|
||||
# tests.blueprint_helpers); these fixtures hand its runner to every test
|
||||
# module in tests/ without re-declaring it.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path: Path) -> str:
|
||||
"""A fresh per-test SQLite path under pytest's tmp_path."""
|
||||
return str(tmp_path / "nestquest.db")
|
||||
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
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_client(temp_db_path: str) -> httpx.AsyncClient:
|
||||
"""An app client whose provider publishes the admin test key."""
|
||||
async with AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) as client:
|
||||
yield client
|
||||
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
|
||||
@@ -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"):
|
||||
|
||||
+160
-129
@@ -2,10 +2,7 @@
|
||||
|
||||
These tests exercise the NestQuest API's admin plane against the ASGI
|
||||
app with httpx, using a locally generated RSA key pair and a stubbed
|
||||
JWKS fetcher — NO network access. That harness (the key pairs, the
|
||||
JWKS builder, the token minting and the app runner) is shared with the
|
||||
other admin-plane route tests in :mod:`tests.admin_jwt_harness`; this
|
||||
module imports it and proves the auth CONTRACT itself:
|
||||
JWKS fetcher — NO network access:
|
||||
|
||||
- ``GET /api/v1/admin/ping`` requires a valid Authentik OIDC JWT: a
|
||||
request with NO ``Authorization`` header and a MALFORMED header
|
||||
@@ -44,37 +41,78 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
from api.app import create_app
|
||||
from api.auth import JwksCache
|
||||
from api.config import ApiConfig, ConfigError
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
AUDIENCE,
|
||||
ISSUER,
|
||||
JWKS_URL,
|
||||
KID,
|
||||
OTHER_KEY,
|
||||
SUBJECT,
|
||||
AdminRunner as _AdminRunner,
|
||||
jwks_for as _jwks_for,
|
||||
make_token as _make_token,
|
||||
)
|
||||
|
||||
#: The configured OIDC settings the test apps run with (values a real
|
||||
#: deployment would get from the NESTQUEST_OIDC_* environment vars).
|
||||
ISSUER = "https://authentik.example.com/application/o/nestquest/"
|
||||
AUDIENCE = "nestquest-api"
|
||||
JWKS_URL = "https://authentik.example.com/application/o/nestquest/jwks/"
|
||||
|
||||
#: The ``kid`` the test tokens carry, matching the JWKS entry.
|
||||
KID = "admin-test-key-1"
|
||||
|
||||
#: The subject a valid token authenticates as.
|
||||
SUBJECT = "admin-user-1"
|
||||
|
||||
#: The uniform 401 detail every rejection must carry.
|
||||
REJECTION_DETAIL = "Missing or invalid admin credentials"
|
||||
|
||||
#: The 403 detail for the panel service token on an admin route.
|
||||
PANEL_TOKEN_REJECTION_DETAIL = (
|
||||
"This credential type is not accepted for admin access"
|
||||
)
|
||||
|
||||
#: The 403 detail for a valid JWT without the admin group.
|
||||
FORBIDDEN_DETAIL = (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
def _generate_key() -> rsa.RSAPrivateKey:
|
||||
"""Generate one local RSA key pair (no network, no fixtures)."""
|
||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
#: The key pair whose PUBLIC half the stubbed JWKS publishes — tokens
|
||||
#: signed with it verify; the second pair below proves they don't when
|
||||
#: signed otherwise.
|
||||
ADMIN_KEY = _generate_key()
|
||||
OTHER_KEY = _generate_key()
|
||||
|
||||
|
||||
def _b64url_uint(value: int) -> str:
|
||||
"""Base64url-encode a non-negative integer (no padding), per JWK."""
|
||||
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _jwks_for(private_key: rsa.RSAPrivateKey, kid: str) -> dict[str, object]:
|
||||
"""Build a JWKS document publishing ``private_key``'s public half."""
|
||||
numbers = private_key.public_key().public_numbers()
|
||||
return {
|
||||
"keys": [
|
||||
{
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"kid": kid,
|
||||
"n": _b64url_uint(numbers.n),
|
||||
"e": _b64url_uint(numbers.e),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class _StubbedFetch:
|
||||
"""A JWKS fetcher stub: returns a fixed document, counts calls."""
|
||||
|
||||
def __init__(self, document: dict[str, object]) -> None:
|
||||
self.document = document
|
||||
self.calls = 0
|
||||
|
||||
async def __call__(self, url: str) -> dict[str, object]:
|
||||
self.calls += 1
|
||||
return self.document
|
||||
|
||||
|
||||
class _FailingFetch:
|
||||
@@ -116,6 +154,31 @@ class _FailThenServe:
|
||||
return self.document
|
||||
|
||||
|
||||
def _make_token(
|
||||
signing_key: rsa.RSAPrivateKey = ADMIN_KEY,
|
||||
*,
|
||||
kid: str | None = KID,
|
||||
iss: str = ISSUER,
|
||||
aud: object = AUDIENCE,
|
||||
sub: str = SUBJECT,
|
||||
expires_in: int | None = 300,
|
||||
nbf_in: int | None = -1,
|
||||
) -> str:
|
||||
"""Mint one RS256 JWT the way Authentik would (its claims, our key)."""
|
||||
now = int(time.time())
|
||||
claims: dict[str, object] = {"iss": iss, "aud": aud, "sub": sub, "iat": now}
|
||||
if nbf_in is not None:
|
||||
claims["nbf"] = now + nbf_in
|
||||
if expires_in is not None:
|
||||
claims["exp"] = now + expires_in
|
||||
headers: dict[str, str] = {}
|
||||
if kid is not None:
|
||||
headers["kid"] = kid
|
||||
return jwt.encode(
|
||||
claims, signing_key, algorithm="RS256", headers=headers
|
||||
)
|
||||
|
||||
|
||||
def _token_with_header(
|
||||
header: dict[str, object], payload: dict[str, object]
|
||||
) -> str:
|
||||
@@ -143,6 +206,79 @@ def _verified_payload() -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
class _AdminRunner:
|
||||
"""Run the app's lifespan around an httpx AsyncClient.
|
||||
|
||||
Same pattern as test_api_health.py's ``_AppRunner``: httpx's ASGI
|
||||
transport does not run the lifespan, so it is driven explicitly and
|
||||
shut down in ``__aexit__``'s finally block even on failure.
|
||||
|
||||
The app's JWKS cache is replaced (before the lifespan runs) with
|
||||
one built around a counting stub fetcher, so verification goes
|
||||
through the REAL cache logic but never the network.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path: str,
|
||||
jwks_document: dict[str, object],
|
||||
*,
|
||||
ttl_seconds: float | None = None,
|
||||
backoff_seconds: float | None = None,
|
||||
kid_refetch_seconds: float | None = None,
|
||||
) -> None:
|
||||
self.app = create_app(
|
||||
ApiConfig(
|
||||
db_path=db_path,
|
||||
panel_token="admin-test-panel-token",
|
||||
oidc_issuer=ISSUER,
|
||||
oidc_audience=AUDIENCE,
|
||||
oidc_jwks_url=JWKS_URL,
|
||||
)
|
||||
)
|
||||
self.fetch = _StubbedFetch(jwks_document)
|
||||
cache_kwargs: dict[str, float] = {}
|
||||
if ttl_seconds is not None:
|
||||
cache_kwargs["ttl_seconds"] = ttl_seconds
|
||||
if backoff_seconds is not None:
|
||||
cache_kwargs["backoff_seconds"] = backoff_seconds
|
||||
if kid_refetch_seconds is not None:
|
||||
cache_kwargs["kid_refetch_seconds"] = kid_refetch_seconds
|
||||
self.app.state.jwks_cache = JwksCache(
|
||||
JWKS_URL, fetcher=self.fetch, **cache_kwargs
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> httpx.AsyncClient:
|
||||
self._lifespan = self.app.router.lifespan_context(self.app)
|
||||
await self._lifespan.__aenter__()
|
||||
self._client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=self.app),
|
||||
base_url="http://testserver",
|
||||
)
|
||||
return await self._client.__aenter__()
|
||||
|
||||
async def __aexit__(self, *exc) -> None:
|
||||
try:
|
||||
await self._client.__aexit__(*exc)
|
||||
finally:
|
||||
await self._lifespan.__aexit__(*exc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path: Path) -> str:
|
||||
"""A fresh per-test SQLite path under pytest's tmp_path."""
|
||||
return str(tmp_path / "nestquest.db")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_client(
|
||||
temp_db_path: str,
|
||||
) -> httpx.AsyncClient:
|
||||
"""An app client whose provider publishes the admin test key."""
|
||||
async with _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID)) as client:
|
||||
yield client
|
||||
|
||||
|
||||
# --- the happy path -------------------------------------------------------
|
||||
|
||||
|
||||
@@ -653,108 +789,3 @@ def test_from_env_requires_oidc_setting(env_var: str) -> None:
|
||||
env[env_var] = " "
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env(env)
|
||||
|
||||
|
||||
# --- the nestquest-admins group gate (task 0f2afb29) -------------------------
|
||||
|
||||
|
||||
async def test_admin_jwt_with_group_returns_200(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A valid JWT whose groups claim names nestquest-admins returns 200."""
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {_make_token()}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok", "sub": SUBJECT}
|
||||
|
||||
|
||||
async def test_non_admin_jwt_is_forbidden(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A valid JWT WITHOUT nestquest-admins in groups returns 403."""
|
||||
token = _make_token(groups=("some-other-group",))
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
||||
|
||||
|
||||
async def test_empty_groups_is_forbidden(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A valid JWT with an EMPTY groups list returns 403."""
|
||||
token = _make_token(groups=())
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("groups", ["nestquest-admins", None])
|
||||
async def test_malformed_groups_is_forbidden(
|
||||
admin_client: httpx.AsyncClient, groups: object
|
||||
) -> None:
|
||||
"""A valid JWT with a non-list (or missing) groups claim returns 403.
|
||||
|
||||
Even a plain STRING naming the group is not enough — the claim must
|
||||
be a list, so a token crafted with a scalar ``groups`` cannot pass.
|
||||
"""
|
||||
token = _make_token(groups=groups)
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
||||
|
||||
|
||||
async def test_panel_service_token_is_refused_outright(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""The panel service token on an admin route is 403, never a JWT.
|
||||
|
||||
403 (not the uniform 401) proves the credential was refused as a
|
||||
TYPE, not run through JWT verification — and the JWKS fetcher is
|
||||
never called, proving it was not treated as a token to verify.
|
||||
"""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": "Bearer admin-test-panel-token"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
||||
assert runner.fetch.calls == 0
|
||||
|
||||
|
||||
async def test_admin_router_is_wired_through_require_admin(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""The admin router's dependency IS the one require_admin dependency.
|
||||
|
||||
The router-level dependency list carries
|
||||
:func:`api.auth.require_admin`, so every admin route (current and
|
||||
later) inherits the service-token refusal and the group gate.
|
||||
"""
|
||||
from api.auth import require_admin
|
||||
from api.routes_admin import router as admin_router
|
||||
|
||||
dependencies = [d.dependency for d in admin_router.dependencies]
|
||||
assert require_admin in dependencies
|
||||
|
||||
# Behavioral proof: the probe returns 200 for an admin JWT through
|
||||
# the same dependency.
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {_make_token()}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@@ -1,464 +0,0 @@
|
||||
"""Admin-plane children route tests (task e4845baa).
|
||||
|
||||
These tests exercise the admin plane's children routes against the
|
||||
ASGI app with httpx, reusing the shared local-RSA-key/stubbed-JWKS
|
||||
harness (:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely
|
||||
authenticates — signature, issuer, audience, expiry and the
|
||||
nestquest-admins group — with NO network access.
|
||||
|
||||
Every done-condition case is proven against the DATABASE, read through
|
||||
the SAME core copy the app uses (``nestquest_core.*``; see the two-copy
|
||||
caveat in api/nestquest_core.py):
|
||||
|
||||
- Authentication runs through the router's ONE ``require_admin``
|
||||
dependency: an admin JWT passes, a JWT without the group is 403, an
|
||||
absent credential is 401 — and no row is written.
|
||||
- ``POST /api/v1/admin/children``: the created child EXISTS with the
|
||||
given display name (and the optional colour/avatar_ref/sort_order).
|
||||
- ``PATCH /api/v1/admin/children/{id}``: an edit PERSISTS the supplied
|
||||
fields and leaves omitted fields untouched (exclude_unset semantics —
|
||||
an omitted field is never passed to the core).
|
||||
- ``PATCH /api/v1/admin/children/{id}/active`` with ``is_active: false``
|
||||
sets the active flag FALSE and the row STILL EXISTS (children are
|
||||
never hard-deleted).
|
||||
- ``POST /api/v1/admin/children/reorder``: the posted order becomes the
|
||||
rows' sort_order; a list that is not a complete permutation is 422
|
||||
and leaves the stored order untouched.
|
||||
|
||||
Error mapping (documented in api/routes_admin.py): a core ValueError
|
||||
for a non-existent child is 404; every other core ValueError (an empty
|
||||
display name, an explicit null colour, a bad permutation) is 422 — the
|
||||
same failure class FastAPI reports for malformed bodies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
|
||||
# The children business layer through the SAME core copy the app uses
|
||||
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy) —
|
||||
# used for the tests' database assertions only; the routes reach it
|
||||
# through api.nestquest_core.
|
||||
_children = importlib.import_module("nestquest_core.children")
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
async def _children_by_id(database: object) -> dict[int, object]:
|
||||
"""Every child row through the app's core copy, keyed by id."""
|
||||
records = await _children.list_children(database)
|
||||
return {record.id: record for record in records}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_children(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database.
|
||||
|
||||
The same runner the auth tests use (stubbed JWKS, no network); the
|
||||
fixture also exposes the app's ONE database connection so tests
|
||||
assert the routes' effects on the stored rows themselves.
|
||||
"""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
database=runner.app.state.db.database,
|
||||
)
|
||||
|
||||
|
||||
# --- authentication: the ONE require_admin dependency guards every route ---
|
||||
|
||||
|
||||
async def test_children_routes_refuse_non_admin_and_absent_credentials(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-admin JWT is 403, an absent credential is 401: nothing written."""
|
||||
client = admin_children.client
|
||||
body = {"display_name": "Ada"}
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
|
||||
forbidden = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
assert forbidden.json()["detail"] == (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
|
||||
unauthenticated = await client.post("/api/v1/admin/children", json=body)
|
||||
assert unauthenticated.status_code == 401
|
||||
|
||||
unauthenticated_list = await client.get("/api/v1/admin/children")
|
||||
assert unauthenticated_list.status_code == 401
|
||||
|
||||
# No route ran its handler: the household is still empty.
|
||||
assert await _children_by_id(admin_children.database) == {}
|
||||
|
||||
|
||||
# --- create -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_create_child_persists_the_given_name(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""POST /children creates the row; the child exists with that name."""
|
||||
response = await admin_children.client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={
|
||||
"display_name": "Ada",
|
||||
"colour": "#ff8800",
|
||||
"avatar_ref": "avatar:bee",
|
||||
"sort_order": 2,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["display_name"] == "Ada"
|
||||
assert body["colour"] == "#ff8800"
|
||||
assert body["avatar_ref"] == "avatar:bee"
|
||||
assert body["sort_order"] == 2
|
||||
assert body["is_active"] is True
|
||||
|
||||
stored = (await _children_by_id(admin_children.database))[body["id"]]
|
||||
assert stored.display_name == "Ada"
|
||||
assert stored.colour == "#ff8800"
|
||||
assert stored.avatar_ref == "avatar:bee"
|
||||
assert stored.sort_order == 2
|
||||
assert stored.is_active is True
|
||||
|
||||
|
||||
async def test_create_child_with_only_a_name_uses_core_defaults(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Omitted optional fields default through the core layer."""
|
||||
response = await admin_children.client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Bo"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
stored = (await _children_by_id(admin_children.database))[
|
||||
response.json()["id"]
|
||||
]
|
||||
assert stored.display_name == "Bo"
|
||||
assert stored.colour is None
|
||||
assert stored.avatar_ref is None
|
||||
assert stored.sort_order == 0
|
||||
assert stored.is_active is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
{}, # display_name missing
|
||||
{"display_name": 5}, # wrong type
|
||||
{"display_name": ""}, # empty: the core rejects it
|
||||
{"display_name": " "}, # whitespace-only: empty to a parent
|
||||
{"display_name": "Ada", "sort_order": "first"}, # wrong type
|
||||
],
|
||||
)
|
||||
async def test_create_child_malformed_input_is_422(
|
||||
admin_children: SimpleNamespace, body: dict[str, object]
|
||||
) -> None:
|
||||
"""Malformed bodies are 422 and never create a row."""
|
||||
response = await admin_children.client.post(
|
||||
"/api/v1/admin/children", json=body, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert await _children_by_id(admin_children.database) == {}
|
||||
|
||||
|
||||
# --- edit -------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_edit_child_persists_supplied_fields_and_skips_omitted_ones(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""PATCH persists exactly the supplied fields (exclude_unset)."""
|
||||
client = admin_children.client
|
||||
created = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={
|
||||
"display_name": "Ada",
|
||||
"colour": "#ff8800",
|
||||
"avatar_ref": "avatar:bee",
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
child_id = created.json()["id"]
|
||||
|
||||
# Only display_name and sort_order: colour/avatar_ref must survive.
|
||||
edited = await client.patch(
|
||||
f"/api/v1/admin/children/{child_id}",
|
||||
json={"display_name": "Ada Lovelace", "sort_order": 4},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert edited.status_code == 200
|
||||
assert edited.json()["display_name"] == "Ada Lovelace"
|
||||
assert edited.json()["sort_order"] == 4
|
||||
stored = (await _children_by_id(admin_children.database))[child_id]
|
||||
assert stored.display_name == "Ada Lovelace"
|
||||
assert stored.sort_order == 4
|
||||
# The omitted fields were never passed to the core: unchanged.
|
||||
assert stored.colour == "#ff8800"
|
||||
assert stored.avatar_ref == "avatar:bee"
|
||||
|
||||
# A second edit touching only colour leaves the name alone.
|
||||
recoloured = await client.patch(
|
||||
f"/api/v1/admin/children/{child_id}",
|
||||
json={"colour": "#00aa55"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert recoloured.status_code == 200
|
||||
stored = (await _children_by_id(admin_children.database))[child_id]
|
||||
assert stored.colour == "#00aa55"
|
||||
assert stored.display_name == "Ada Lovelace"
|
||||
|
||||
|
||||
async def test_edit_unknown_child_is_404(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An edit naming a child that does not exist is 404."""
|
||||
response = await admin_children.client.patch(
|
||||
"/api/v1/admin/children/999",
|
||||
json={"display_name": "Nobody"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Child not found"
|
||||
|
||||
|
||||
async def test_edit_explicit_null_colour_is_422_and_changes_nothing(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An explicit null (clearing) is passed through and rejected by the core.
|
||||
|
||||
Only the fields the request SUPPLIES are forwarded: a null colour is
|
||||
a supplied None, which the core refuses (the DAO cannot express
|
||||
NULL) — mapped to 422, with the stored colour left untouched.
|
||||
"""
|
||||
created = await admin_children.client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Ada", "colour": "#ff8800"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
child_id = created.json()["id"]
|
||||
response = await admin_children.client.patch(
|
||||
f"/api/v1/admin/children/{child_id}",
|
||||
json={"colour": None},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
stored = (await _children_by_id(admin_children.database))[child_id]
|
||||
assert stored.colour == "#ff8800"
|
||||
|
||||
|
||||
# --- set_active -------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_deactivation_sets_the_flag_false_and_never_deletes(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""is_active=false deactivates the row; the row STILL exists."""
|
||||
client = admin_children.client
|
||||
created = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Ada"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
child_id = created.json()["id"]
|
||||
|
||||
deactivated = await client.patch(
|
||||
f"/api/v1/admin/children/{child_id}/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
assert deactivated.json()["is_active"] is False
|
||||
|
||||
# Database proof: the flag is False and the row was NOT hard-deleted.
|
||||
rows = await _children_by_id(admin_children.database)
|
||||
stored = rows[child_id]
|
||||
assert stored.is_active is False
|
||||
assert stored.display_name == "Ada"
|
||||
|
||||
# Reactivation goes through the same route: the flag is set, not
|
||||
# one-way.
|
||||
reactivated = await client.patch(
|
||||
f"/api/v1/admin/children/{child_id}/active",
|
||||
json={"is_active": True},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert reactivated.status_code == 200
|
||||
assert reactivated.json()["is_active"] is True
|
||||
rows = await _children_by_id(admin_children.database)
|
||||
assert rows[child_id].is_active is True
|
||||
|
||||
|
||||
async def test_set_active_unknown_child_is_404(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A set_active naming a child that does not exist is 404."""
|
||||
response = await admin_children.client.patch(
|
||||
"/api/v1/admin/children/999/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Child not found"
|
||||
|
||||
|
||||
async def test_set_active_malformed_body_is_422(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-bool is_active is rejected by the body model (422)."""
|
||||
response = await admin_children.client.patch(
|
||||
"/api/v1/admin/children/1/active",
|
||||
json={"is_active": "maybe"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# --- reorder ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_reorder_writes_the_posted_order_to_sort_order(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The posted id order becomes the rows' sort_order."""
|
||||
client = admin_children.client
|
||||
ids = []
|
||||
for name in ("Ada", "Bo", "Cyd"):
|
||||
created = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": name},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
ids.append(created.json()["id"])
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/children/reorder",
|
||||
json={"ordered_ids": [ids[2], ids[0], ids[1]]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
# Database proof: each posted position became that child's sort_order.
|
||||
rows = await _children_by_id(admin_children.database)
|
||||
assert rows[ids[2]].sort_order == 0
|
||||
assert rows[ids[0]].sort_order == 1
|
||||
assert rows[ids[1]].sort_order == 2
|
||||
|
||||
|
||||
async def test_reorder_rejects_non_permutations_and_writes_nothing(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A list that is not a complete permutation is 422, order intact.
|
||||
|
||||
The core rejects the list BEFORE any write, so the stored sort_order
|
||||
values survive every rejected call unchanged.
|
||||
"""
|
||||
client = admin_children.client
|
||||
ids = []
|
||||
for name in ("Ada", "Bo"):
|
||||
created = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": name},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
ids.append(created.json()["id"])
|
||||
|
||||
bad_lists = [
|
||||
ids[:1], # partial: missing an existing child
|
||||
[ids[0], ids[0], ids[1]], # duplicate id
|
||||
[*ids, ids[1] + 999], # unknown id
|
||||
]
|
||||
for ordered_ids in bad_lists:
|
||||
response = await client.post(
|
||||
"/api/v1/admin/children/reorder",
|
||||
json={"ordered_ids": ordered_ids},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422, ordered_ids
|
||||
|
||||
# A non-integer id never reaches the core either (body model).
|
||||
malformed = await client.post(
|
||||
"/api/v1/admin/children/reorder",
|
||||
json={"ordered_ids": ["first", "second"]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert malformed.status_code == 422
|
||||
|
||||
# Database proof: no write happened for any rejected call.
|
||||
rows = await _children_by_id(admin_children.database)
|
||||
assert [rows[child_id].sort_order for child_id in ids] == [0, 0]
|
||||
|
||||
|
||||
# --- list (the read model the admin PWA re-renders from) ---------------------
|
||||
|
||||
|
||||
async def test_list_children_returns_all_in_display_order(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""GET /children returns every child (inactive included) in order."""
|
||||
client = admin_children.client
|
||||
ada = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Ada", "sort_order": 5},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
bo = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Bo", "sort_order": 1},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
deactivated = await client.patch(
|
||||
f"/api/v1/admin/children/{ada.json()['id']}/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/children", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
children = response.json()["children"]
|
||||
assert [child["id"] for child in children] == [
|
||||
bo.json()["id"],
|
||||
ada.json()["id"],
|
||||
]
|
||||
by_id = {child["id"]: child for child in children}
|
||||
assert by_id[ada.json()["id"]]["is_active"] is False
|
||||
assert by_id[bo.json()["id"]]["is_active"] is True
|
||||
# The serialized shape is the documented child payload.
|
||||
assert set(by_id[bo.json()["id"]]) == {
|
||||
"id",
|
||||
"display_name",
|
||||
"colour",
|
||||
"avatar_ref",
|
||||
"sort_order",
|
||||
"is_active",
|
||||
}
|
||||
@@ -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,571 +0,0 @@
|
||||
"""Admin-plane presence route tests (task e4a9d31b).
|
||||
|
||||
These tests exercise the admin plane's presence routes against the ASGI
|
||||
app with httpx, reusing the shared local-RSA-key/stubbed-JWKS harness
|
||||
(:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely
|
||||
authenticates — signature, issuer, audience, expiry and the
|
||||
nestquest-admins group — with NO network access.
|
||||
|
||||
Every done-condition case is proven against the DATABASE and the DOMAIN,
|
||||
read through the SAME core copy the app uses (``nestquest_core.*``; see
|
||||
the two-copy caveat in api/nestquest_core.py):
|
||||
|
||||
- Authentication runs through the router's ONE ``require_admin``
|
||||
dependency: an admin JWT passes, a JWT without the group is 403, an
|
||||
absent credential is 401 — and nothing is written.
|
||||
- ``PUT /api/v1/admin/children/{id}/presence-schedule`` UPSERTS: the
|
||||
first set persists the schedule, setting again REPLACES it — one
|
||||
schedule row per child, never a duplicate.
|
||||
- ``POST /api/v1/admin/presence-overrides`` creates the override (its
|
||||
``id`` comes back) and ``DELETE /api/v1/admin/presence-overrides/{id}``
|
||||
removes it again.
|
||||
- The child's RESOLVED presence changes accordingly: the pure
|
||||
:class:`~nestquest_core.presence.PresenceEngine` built from the
|
||||
stored rows answers differently before/after each write — a schedule
|
||||
restricts the child's present weekdays, an override beats the pattern
|
||||
for its range, and deleting the override restores the pattern's
|
||||
answer.
|
||||
- The regeneration side effect lives in the core layer the route
|
||||
calls: setting an all-absent schedule through the route deletes the
|
||||
child's future open quest instances and they are NOT rebuilt while
|
||||
the child is absent every day — the same proof the HA service test
|
||||
makes, through the API instead.
|
||||
|
||||
Error mapping (documented in api/routes_admin.py): a core ValueError
|
||||
for a non-existent child (schedule or override create) or override
|
||||
(delete) is 404; every other core ValueError — a rejected schedule
|
||||
(cycle length, pattern coverage, anchor date) or override (malformed or
|
||||
inverted dates, an overlapping range) — is 422, the same failure class
|
||||
FastAPI reports for malformed bodies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
|
||||
# The presence model, DAOs, business layer and materializer through the
|
||||
# SAME core copy the app uses (nestquest_core.*, NOT the conftest-stubbed
|
||||
# custom_components copy) — used for the tests' database and resolved-
|
||||
# presence assertions only; the routes reach them through api.nestquest_core.
|
||||
_children = importlib.import_module("nestquest_core.children")
|
||||
_dao_presence = importlib.import_module("nestquest_core.dao_presence")
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
_materialize = importlib.import_module("nestquest_core.materialize")
|
||||
_presence = importlib.import_module("nestquest_core.presence")
|
||||
|
||||
#: A Monday anchoring every schedule: cycle week 0 starts here.
|
||||
ANCHOR_MONDAY = "2026-01-05"
|
||||
#: The Tuesday of that week — absent under the test schedule.
|
||||
TUESDAY = "2026-01-06"
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _schedule_body(
|
||||
cycle_length_weeks: int = 1, pattern: dict[int, list[int]] | None = None
|
||||
) -> dict[str, object]:
|
||||
"""A valid schedule body anchored to the test Monday."""
|
||||
return {
|
||||
"cycle_length_weeks": cycle_length_weeks,
|
||||
"anchor_date": ANCHOR_MONDAY,
|
||||
"pattern": {"0": [0, 2, 4]} if pattern is None else pattern,
|
||||
}
|
||||
|
||||
|
||||
def _override_body(
|
||||
child_id: int,
|
||||
*,
|
||||
start_date: str = TUESDAY,
|
||||
end_date: str = TUESDAY,
|
||||
is_present: bool = True,
|
||||
) -> dict[str, object]:
|
||||
"""A valid single-day override body for the test child."""
|
||||
return {
|
||||
"child_id": child_id,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"is_present": is_present,
|
||||
"note": "grandmother's week",
|
||||
}
|
||||
|
||||
|
||||
async def _create_child(client: object, name: str = "Ada") -> int:
|
||||
"""Create one child through the admin children route; return the id."""
|
||||
created = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": name},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
return created.json()["id"]
|
||||
|
||||
|
||||
def _engine(
|
||||
database: object,
|
||||
child_id: int,
|
||||
schedule_record: object | None,
|
||||
override_records: list[object],
|
||||
) -> object:
|
||||
"""Build the pure PresenceEngine from stored rows (the snapshot shape).
|
||||
|
||||
Mirrors what the core snapshot builder assembles: the child's
|
||||
decoded schedule (absent → present every day) plus its overrides,
|
||||
so :meth:`PresenceEngine.is_present` answers from the SAME rows the
|
||||
routes produced.
|
||||
"""
|
||||
schedules: dict[int, object] = {}
|
||||
if schedule_record is not None:
|
||||
schedules[child_id] = _presence.PresenceSchedule.decode(
|
||||
schedule_record.child_id,
|
||||
schedule_record.anchor_date,
|
||||
schedule_record.pattern,
|
||||
)
|
||||
overrides = {
|
||||
child_id: [
|
||||
_presence.PresenceOverride(
|
||||
record.child_id,
|
||||
record.start_date,
|
||||
record.end_date,
|
||||
record.is_present,
|
||||
note=record.note,
|
||||
)
|
||||
for record in override_records
|
||||
]
|
||||
}
|
||||
return _presence.PresenceEngine(schedules, overrides)
|
||||
|
||||
|
||||
async def _child_state(
|
||||
database: object, child_id: int
|
||||
) -> tuple[object | None, list[object], object]:
|
||||
"""The child's stored schedule record, overrides and PresenceEngine."""
|
||||
schedule_record = await _dao_presence.PresenceSchedulesDao(
|
||||
database
|
||||
).get_by_child(child_id)
|
||||
override_records = await _dao_presence.PresenceOverridesDao(
|
||||
database
|
||||
).list_by_child_and_range(child_id, "2020-01-01", "2030-01-01")
|
||||
engine = _engine(database, child_id, schedule_record, override_records)
|
||||
return schedule_record, override_records, engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_presence(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database.
|
||||
|
||||
The same runner the auth, children and quest-definition tests use
|
||||
(stubbed JWKS, no network); the fixture also exposes the app's ONE
|
||||
database connection so tests assert the routes' effects on the
|
||||
stored rows and the resolved presence built from them.
|
||||
"""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
database=runner.app.state.db.database,
|
||||
)
|
||||
|
||||
|
||||
# --- authentication: the ONE require_admin dependency guards every route ---
|
||||
|
||||
|
||||
async def test_presence_routes_refuse_non_admin_and_absent_credentials(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-admin JWT is 403, an absent credential is 401: nothing written."""
|
||||
client = admin_presence.client
|
||||
child_id = await _create_child(client)
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
non_admin_headers = {"Authorization": f"Bearer {non_admin}"}
|
||||
|
||||
forbidden_schedule = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
headers=non_admin_headers,
|
||||
)
|
||||
assert forbidden_schedule.status_code == 403
|
||||
assert forbidden_schedule.json()["detail"] == (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
|
||||
forbidden_override = await client.post(
|
||||
"/api/v1/admin/presence-overrides",
|
||||
json=_override_body(child_id),
|
||||
headers=non_admin_headers,
|
||||
)
|
||||
assert forbidden_override.status_code == 403
|
||||
|
||||
forbidden_delete = await client.delete(
|
||||
"/api/v1/admin/presence-overrides/1", headers=non_admin_headers
|
||||
)
|
||||
assert forbidden_delete.status_code == 403
|
||||
|
||||
unauthenticated_schedule = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
)
|
||||
assert unauthenticated_schedule.status_code == 401
|
||||
|
||||
unauthenticated_override = await client.post(
|
||||
"/api/v1/admin/presence-overrides", json=_override_body(child_id)
|
||||
)
|
||||
assert unauthenticated_override.status_code == 401
|
||||
|
||||
unauthenticated_delete = await client.delete(
|
||||
"/api/v1/admin/presence-overrides/1"
|
||||
)
|
||||
assert unauthenticated_delete.status_code == 401
|
||||
|
||||
# No route ran its handler: the child still has no schedule and no
|
||||
# overrides (and is present every day, the no-schedule default).
|
||||
_schedule, _overrides, engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert _schedule is None
|
||||
assert _overrides == []
|
||||
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
|
||||
|
||||
|
||||
# --- schedule upsert --------------------------------------------------------
|
||||
|
||||
|
||||
async def test_schedule_upserts_and_replaces_one_row_per_child(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""PUT /presence-schedule persists, and setting again REPLACES it."""
|
||||
client = admin_presence.client
|
||||
child_id = await _create_child(client)
|
||||
|
||||
first = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert first.json() == {
|
||||
"child_id": child_id,
|
||||
"cycle_length_weeks": 1,
|
||||
"anchor_date": ANCHOR_MONDAY,
|
||||
"pattern": {"0": [0, 2, 4]},
|
||||
}
|
||||
# Database proof: exactly ONE schedule row (the schema's
|
||||
# UNIQUE(child_id) makes a second row unrepresentable), stored as
|
||||
# encoded CSV.
|
||||
schedule, _overrides, _engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert schedule is not None
|
||||
row_id = schedule.id
|
||||
assert schedule.cycle_length_weeks == 1
|
||||
assert schedule.anchor_date == ANCHOR_MONDAY
|
||||
assert schedule.pattern == "0,2,4"
|
||||
|
||||
# Setting again UPSERTS: the SAME row is updated in place — new
|
||||
# values, unchanged id — never a duplicate or a stale first set.
|
||||
second = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(
|
||||
cycle_length_weeks=2, pattern={"0": [], "1": [1, 3]}
|
||||
),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json() == {
|
||||
"child_id": child_id,
|
||||
"cycle_length_weeks": 2,
|
||||
"anchor_date": ANCHOR_MONDAY,
|
||||
"pattern": {"0": [], "1": [1, 3]},
|
||||
}
|
||||
schedule, _overrides, _engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert schedule is not None
|
||||
assert schedule.id == row_id
|
||||
assert schedule.cycle_length_weeks == 2
|
||||
assert schedule.pattern == "|1,3"
|
||||
|
||||
|
||||
async def test_schedule_changes_the_childs_resolved_presence(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The resolved presence (PresenceEngine) follows the stored schedule."""
|
||||
client = admin_presence.client
|
||||
child_id = await _create_child(client)
|
||||
|
||||
# Before any schedule the child is present every day.
|
||||
_schedule, _overrides, engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
|
||||
assert engine.is_present(child_id, TUESDAY) is True
|
||||
|
||||
# Mon/Wed/Fri only, week 0 of a one-week cycle.
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
_schedule, _overrides, engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
|
||||
assert engine.is_present(child_id, TUESDAY) is False
|
||||
|
||||
|
||||
async def test_schedule_upsert_regenerates_future_instances(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Setting an all-absent schedule deletes the child's future instances.
|
||||
|
||||
The regeneration side effect lives in the core layer the route
|
||||
calls: the child's open instances at or after today are deleted
|
||||
across ALL its definitions and NOT rebuilt while the child is
|
||||
absent every day — the same proof the HA service test makes,
|
||||
exercised here through the API route instead.
|
||||
"""
|
||||
client = admin_presence.client
|
||||
database = admin_presence.database
|
||||
child_id = await _create_child(client)
|
||||
created = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Brush teeth",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [child_id],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
today = datetime.date.today()
|
||||
start = today.isoformat()
|
||||
end = (today + datetime.timedelta(days=14)).isoformat()
|
||||
await _materialize.materialize(database, start, end, today=today)
|
||||
instances = _dao_instances.QuestInstancesDao(database)
|
||||
assert await instances.list_by_date_range(child_id, start, end)
|
||||
|
||||
# The route's write must regenerate: absent every day removes every
|
||||
# open future instance.
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(pattern={"0": []}),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert await instances.list_by_date_range(child_id, start, end) == []
|
||||
|
||||
|
||||
# --- override create + delete -----------------------------------------------
|
||||
|
||||
|
||||
async def test_override_is_created_then_deleted_and_presence_resolves(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""POST creates the override, DELETE removes it; presence follows."""
|
||||
client = admin_presence.client
|
||||
database = admin_presence.database
|
||||
child_id = await _create_child(client)
|
||||
schedule_response = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert schedule_response.status_code == 200
|
||||
|
||||
# Create: the override comes back with its id and the posted shape.
|
||||
created = await client.post(
|
||||
"/api/v1/admin/presence-overrides",
|
||||
json=_override_body(child_id),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
payload = created.json()
|
||||
assert set(payload) == {
|
||||
"id",
|
||||
"child_id",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"is_present",
|
||||
"note",
|
||||
}
|
||||
assert payload["child_id"] == child_id
|
||||
assert payload["start_date"] == TUESDAY
|
||||
assert payload["end_date"] == TUESDAY
|
||||
assert payload["is_present"] is True
|
||||
assert payload["note"] == "grandmother's week"
|
||||
|
||||
# Database proof: the row exists — and the RESOLVED presence for the
|
||||
# affected date flips: the override beats the Tuesday-absent pattern.
|
||||
_schedule, overrides, engine = await _child_state(database, child_id)
|
||||
assert [record.id for record in overrides] == [payload["id"]]
|
||||
assert engine.is_present(child_id, TUESDAY) is True
|
||||
# A date the override does not cover keeps the pattern's answer.
|
||||
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
|
||||
|
||||
# Delete: the row is gone and the resolved presence follows the
|
||||
# pattern again.
|
||||
deleted = await client.delete(
|
||||
f"/api/v1/admin/presence-overrides/{payload['id']}",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert deleted.status_code == 200
|
||||
assert deleted.json() == {"status": "ok"}
|
||||
assert (
|
||||
await _dao_presence.PresenceOverridesDao(database).get(payload["id"])
|
||||
is None
|
||||
)
|
||||
_schedule, overrides, engine = await _child_state(database, child_id)
|
||||
assert overrides == []
|
||||
assert engine.is_present(child_id, TUESDAY) is False
|
||||
|
||||
|
||||
# --- error mapping: 404 vs 422 ----------------------------------------------
|
||||
|
||||
|
||||
async def test_schedule_and_override_for_unknown_child_are_404(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A presence write naming a child that does not exist is 404."""
|
||||
client = admin_presence.client
|
||||
|
||||
schedule = await client.put(
|
||||
"/api/v1/admin/children/999/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert schedule.status_code == 404
|
||||
assert schedule.json()["detail"] == "Child not found"
|
||||
|
||||
override = await client.post(
|
||||
"/api/v1/admin/presence-overrides",
|
||||
json=_override_body(999),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert override.status_code == 404
|
||||
assert override.json()["detail"] == "Child not found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
_schedule_body(cycle_length_weeks=0), # below the 1..4 range
|
||||
_schedule_body(cycle_length_weeks=5), # above the documented cap
|
||||
# week 1 of the cycle missing:
|
||||
_schedule_body(cycle_length_weeks=2, pattern={"0": [0]}),
|
||||
{
|
||||
"cycle_length_weeks": 1,
|
||||
"anchor_date": "2026-13-40",
|
||||
"pattern": {"0": [0]},
|
||||
},
|
||||
_schedule_body(pattern={"0": [7]}), # weekday out of 0..6
|
||||
_schedule_body(pattern={"nope": [0]}), # week index not an integer
|
||||
_schedule_body(pattern={"0": "0,2,4"}), # weekdays not a list
|
||||
],
|
||||
)
|
||||
async def test_rejected_schedule_is_422(
|
||||
admin_presence: SimpleNamespace, body: dict[str, object]
|
||||
) -> None:
|
||||
"""A rejected schedule is 422 and stores nothing."""
|
||||
client = admin_presence.client
|
||||
child_id = await _create_child(client)
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=body,
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert (
|
||||
await _dao_presence.PresenceSchedulesDao(
|
||||
admin_presence.database
|
||||
).get_by_child(child_id)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
_override_body(1, start_date="2026-01-07", end_date="2026-01-06"),
|
||||
_override_body(1, start_date="2026-02-30", end_date="2026-02-30"),
|
||||
dict(_override_body(1), note=5),
|
||||
dict(_override_body(1), child_id="one"),
|
||||
],
|
||||
)
|
||||
async def test_rejected_override_is_422(
|
||||
admin_presence: SimpleNamespace, body: dict[str, object]
|
||||
) -> None:
|
||||
"""A rejected override (inverted/malformed dates, bad note/id) is 422.
|
||||
|
||||
The shape failures (a non-string note, a non-integer child id) are
|
||||
the body model's; the date failures are the PresenceOverride
|
||||
model's. A string ``is_present`` like ``"yes"`` is deliberately
|
||||
absent: Pydantic's lax bool COERCES it to ``True`` (the same design
|
||||
as the children active route), so it never reaches the model's
|
||||
real-bool check.
|
||||
"""
|
||||
response = await admin_presence.client.post(
|
||||
"/api/v1/admin/presence-overrides", json=body, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_overlapping_override_is_422_and_first_survives(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An override overlapping the child's existing one is 422.
|
||||
|
||||
The conflicting write persists nothing: the child's original
|
||||
override survives unchanged.
|
||||
"""
|
||||
client = admin_presence.client
|
||||
child_id = await _create_child(client)
|
||||
created = await client.post(
|
||||
"/api/v1/admin/presence-overrides",
|
||||
json=_override_body(child_id),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/presence-overrides",
|
||||
json=_override_body(child_id, is_present=False),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
_schedule, overrides, _engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert [record.id for record in overrides] == [created.json()["id"]]
|
||||
|
||||
|
||||
async def test_delete_unknown_override_is_404(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Deleting an override id that does not exist is 404."""
|
||||
response = await admin_presence.client.delete(
|
||||
"/api/v1/admin/presence-overrides/999", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Presence override not found"
|
||||
|
||||
malformed = await admin_presence.client.delete(
|
||||
"/api/v1/admin/presence-overrides/not-an-id",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert malformed.status_code == 422
|
||||
@@ -1,611 +0,0 @@
|
||||
"""Admin-plane quest-definition route tests (task b586a2f1).
|
||||
|
||||
These tests exercise the admin plane's quest-definition routes against
|
||||
the ASGI app with httpx, reusing the shared local-RSA-key/stubbed-JWKS
|
||||
harness (:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely
|
||||
authenticates — signature, issuer, audience, expiry and the
|
||||
nestquest-admins group — with NO network access.
|
||||
|
||||
Every done-condition case is proven against the DATABASE, read through
|
||||
the SAME core copy the app uses (``nestquest_core.*``; see the two-copy
|
||||
caveat in api/nestquest_core.py):
|
||||
|
||||
- Authentication runs through the router's ONE ``require_admin``
|
||||
dependency: an admin JWT passes, a JWT without the group is 403, an
|
||||
absent credential is 401 — and no definition is written.
|
||||
- ``POST /api/v1/admin/quest-definitions``: the created definition
|
||||
EXISTS with its title, description, icon, decoded rule, assignees
|
||||
and windows exactly as posted.
|
||||
- ``PATCH /api/v1/admin/quest-definitions/{id}``: an edit PERSISTS the
|
||||
supplied fields and leaves omitted fields untouched (exclude_unset
|
||||
semantics); a supplied ``windows`` list replaces the whole set;
|
||||
assignment is not settable through the edit.
|
||||
- ``PATCH /api/v1/admin/quest-definitions/{id}/active`` with
|
||||
``is_active: false`` sets the flag FALSE and the definition STILL
|
||||
EXISTS with its rule, assignees and windows (never hard-deleted);
|
||||
reactivation restores it.
|
||||
|
||||
Error mapping (documented in api/routes_admin.py): a core ValueError
|
||||
for a non-existent DEFINITION is 404; every other core ValueError — a
|
||||
rejected rule (RuleValidationError), a bad window name or due time, a
|
||||
rejected assignee list (an unknown assignee arrives in the BODY, so it
|
||||
is 422, never 404) — is 422, the same failure class FastAPI reports
|
||||
for malformed bodies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
|
||||
# The quest-definition business layer and its DAO through the SAME core
|
||||
# copy the app uses (nestquest_core.*, NOT the conftest-stubbed
|
||||
# custom_components copy) — used for the tests' database assertions
|
||||
# only; the routes reach them through api.nestquest_core.
|
||||
_quest_definitions = importlib.import_module(
|
||||
"nestquest_core.quest_definitions"
|
||||
)
|
||||
_dao_rules = importlib.import_module("nestquest_core.dao_rules")
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _weekly_rule_body() -> dict[str, object]:
|
||||
"""A valid weekly rule body: Mondays and Wednesdays from 2026-03-02."""
|
||||
return {
|
||||
"rule_type": "weekly",
|
||||
"interval": 1,
|
||||
"weekday_set": [0, 2],
|
||||
"start_date": "2026-03-02",
|
||||
}
|
||||
|
||||
|
||||
def _weekly_rule_dict() -> dict[str, object]:
|
||||
"""The lossless dict form _weekly_rule_body() must decode back to."""
|
||||
return {
|
||||
"rule_type": "weekly",
|
||||
"interval": 1,
|
||||
"weekday_set": [0, 2],
|
||||
"day_of_month": None,
|
||||
"nth_weekday": None,
|
||||
"nth_weekday_weekday": None,
|
||||
"month": None,
|
||||
"start_date": "2026-03-02",
|
||||
"end_date": None,
|
||||
}
|
||||
|
||||
|
||||
async def _create_children(
|
||||
client: object, names: tuple[str, ...]
|
||||
) -> list[int]:
|
||||
"""Create ACTIVE children through the admin children route."""
|
||||
ids = []
|
||||
for name in names:
|
||||
created = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": name},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
ids.append(created.json()["id"])
|
||||
return ids
|
||||
|
||||
|
||||
async def _create_definition(
|
||||
client: object, assignee_ids: list[int]
|
||||
) -> object:
|
||||
"""POST a valid two-window definition for the given assignees."""
|
||||
return await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Tidy the den",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": assignee_ids,
|
||||
"windows": ["morning", ["evening", "19:30"]],
|
||||
"description": "Put everything back where it belongs.",
|
||||
"icon": "toy-box",
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
|
||||
async def _definitions_by_id(database: object) -> dict[int, object]:
|
||||
"""Every ACTIVE definition bundle through the app's core copy."""
|
||||
bundles = await _quest_definitions.list_active_definitions(database)
|
||||
return {bundle.definition.id: bundle for bundle in bundles}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_questdefs(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database.
|
||||
|
||||
The same runner the auth and children tests use (stubbed JWKS, no
|
||||
network); the fixture also exposes the app's ONE database connection
|
||||
so tests assert the routes' effects on the stored rows themselves.
|
||||
"""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
database=runner.app.state.db.database,
|
||||
)
|
||||
|
||||
|
||||
# --- authentication: the ONE require_admin dependency guards every route ---
|
||||
|
||||
|
||||
async def test_quest_definition_routes_refuse_non_admin_and_absent(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-admin JWT is 403, an absent credential is 401: nothing written."""
|
||||
client = admin_questdefs.client
|
||||
body = {"title": "Sweep the porch"}
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
|
||||
forbidden = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
assert forbidden.json()["detail"] == (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
|
||||
unauthenticated_create = await client.post(
|
||||
"/api/v1/admin/quest-definitions", json=body
|
||||
)
|
||||
assert unauthenticated_create.status_code == 401
|
||||
|
||||
unauthenticated_edit = await client.patch(
|
||||
"/api/v1/admin/quest-definitions/1", json={"title": "X"}
|
||||
)
|
||||
assert unauthenticated_edit.status_code == 401
|
||||
|
||||
unauthenticated_active = await client.patch(
|
||||
"/api/v1/admin/quest-definitions/1/active", json={"is_active": False}
|
||||
)
|
||||
assert unauthenticated_active.status_code == 401
|
||||
|
||||
# No route ran its handler: no definition was written.
|
||||
assert await _definitions_by_id(admin_questdefs.database) == {}
|
||||
|
||||
|
||||
# --- create -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_create_persists_definition_with_assignees_and_windows(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""POST /quest-definitions persists rule, assignees and windows."""
|
||||
client = admin_questdefs.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Tidy the den",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": [ada, bo],
|
||||
"windows": ["morning", ["evening", "19:30"]],
|
||||
"description": "Put everything back where it belongs.",
|
||||
"icon": "toy-box",
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
payload = response.json()
|
||||
definition_id = payload["id"]
|
||||
# The serialized shape is the documented definition payload.
|
||||
assert set(payload) == {
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"icon",
|
||||
"is_active",
|
||||
"rule",
|
||||
"assignees",
|
||||
"windows",
|
||||
}
|
||||
assert payload["title"] == "Tidy the den"
|
||||
assert payload["description"] == "Put everything back where it belongs."
|
||||
assert payload["icon"] == "toy-box"
|
||||
assert payload["is_active"] is True
|
||||
assert payload["rule"] == _weekly_rule_dict()
|
||||
assert {a["id"]: a["display_name"] for a in payload["assignees"]} == {
|
||||
ada: "Ada",
|
||||
bo: "Bo",
|
||||
}
|
||||
assert {w["window"]: w["due_time"] for w in payload["windows"]} == {
|
||||
"morning": None,
|
||||
"evening": "19:30",
|
||||
}
|
||||
|
||||
# Database proof through the app's core copy: the definition, its
|
||||
# decoded rule, its assignees and its windows all exist as posted.
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
definition_id
|
||||
]
|
||||
assert stored.definition.title == "Tidy the den"
|
||||
assert stored.definition.description == (
|
||||
"Put everything back where it belongs."
|
||||
)
|
||||
assert stored.definition.icon == "toy-box"
|
||||
assert stored.definition.is_active is True
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
assert sorted(child.id for child in stored.assignees) == [ada, bo]
|
||||
assert {(w.window, w.due_time) for w in stored.windows} == {
|
||||
("morning", None),
|
||||
("evening", "19:30"),
|
||||
}
|
||||
|
||||
|
||||
async def test_create_with_required_fields_only_uses_core_defaults(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Omitted optional fields default to NULL through the core layer."""
|
||||
client = admin_questdefs.client
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Water the plants",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": [ada],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
payload = response.json()
|
||||
assert payload["description"] is None
|
||||
assert payload["icon"] is None
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
payload["id"]
|
||||
]
|
||||
assert stored.definition.description is None
|
||||
assert stored.definition.icon is None
|
||||
|
||||
|
||||
async def test_create_without_required_fields_is_422(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A body missing any required field is 422 and creates nothing."""
|
||||
client = admin_questdefs.client
|
||||
bodies: list[dict[str, object]] = [
|
||||
{}, # everything missing
|
||||
{"title": "Tidy the den"}, # rule, assignees, windows missing
|
||||
{
|
||||
"title": "Tidy the den",
|
||||
"rule": _weekly_rule_body(),
|
||||
}, # assignees and windows missing
|
||||
{
|
||||
"title": "Tidy the den",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": [1],
|
||||
}, # windows missing
|
||||
]
|
||||
for body in bodies:
|
||||
response = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json=body,
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422, body
|
||||
assert await _definitions_by_id(admin_questdefs.database) == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"override",
|
||||
[
|
||||
{"title": ""}, # empty: the core rejects it
|
||||
{"title": " "}, # whitespace-only: empty to a parent
|
||||
{"assignee_child_ids": []}, # empty assignee list
|
||||
{"assignee_child_ids": [999, 1000]}, # unknown children: 422 not 404
|
||||
{"assignee_child_ids": ["Ada"]}, # wrong-typed ids
|
||||
{"windows": []}, # empty window set
|
||||
{"windows": "morning"}, # not a list
|
||||
{"windows": ["attic"]}, # not a const.QUEST_WINDOWS name
|
||||
{"windows": [["morning", "25:00"]]}, # not a 24-hour time
|
||||
{"windows": [["morning", "8:00"]]}, # not a strict HH:MM
|
||||
{"windows": [["morning", None], "morning"]}, # duplicate window
|
||||
{"rule": {"rule_type": "weekly"}}, # weekly without weekday_set
|
||||
{"rule": {"rule_type": "fortnightly"}}, # unknown rule_type
|
||||
{"rule": {"rule_type": "daily", "interval": 0}}, # interval < 1
|
||||
{"rule": {"rule_type": "weekly", "weekday_set": [7]}}, # out of range
|
||||
{"rule": {"rule_type": "monthly_day"}}, # missing day_of_month
|
||||
{"rule": {"rule_type": "daily", "day_of_month": 5}}, # forbidden field
|
||||
{"rule": {"rule_type": "daily", "start_date": "2026-13-01"}},
|
||||
{"rule": None}, # an explicit null rule is a supplied invalid value
|
||||
],
|
||||
)
|
||||
async def test_create_rejections_are_422_and_persist_nothing(
|
||||
admin_questdefs: SimpleNamespace, override: dict[str, object]
|
||||
) -> None:
|
||||
"""Rejected bodies are 422 and never create a definition."""
|
||||
client = admin_questdefs.client
|
||||
ids = await _create_children(client, ("Ada", "Bo"))
|
||||
body: dict[str, object] = {
|
||||
"title": "Tidy the den",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": ids,
|
||||
"windows": ["morning", ["evening", "19:30"]],
|
||||
}
|
||||
body.update(override)
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json=body,
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422, override
|
||||
assert await _definitions_by_id(admin_questdefs.database) == {}
|
||||
|
||||
|
||||
# --- edit -------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_edit_persists_supplied_fields_and_leaves_omitted_untouched(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""PATCH persists exactly the supplied fields (exclude_unset)."""
|
||||
client = admin_questdefs.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
created = await _create_definition(client, [ada, bo])
|
||||
assert created.status_code == 201
|
||||
definition_id = created.json()["id"]
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
|
||||
original_rule_id = (await dao.get(definition_id)).schedule_rule_id
|
||||
|
||||
# Only title and windows: description, icon, rule and assignees
|
||||
# must survive; the supplied windows REPLACE the whole set.
|
||||
edited = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"title": "Clear the den", "windows": ["afternoon"]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert edited.status_code == 200
|
||||
assert edited.json()["title"] == "Clear the den"
|
||||
assert {w["window"]: w["due_time"] for w in edited.json()["windows"]} == {
|
||||
"afternoon": None
|
||||
}
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
definition_id
|
||||
]
|
||||
assert stored.definition.title == "Clear the den"
|
||||
assert {(w.window, w.due_time) for w in stored.windows} == {
|
||||
("afternoon", None)
|
||||
}
|
||||
# Omitted fields were never passed to the core: unchanged.
|
||||
assert stored.definition.description == (
|
||||
"Put everything back where it belongs."
|
||||
)
|
||||
assert stored.definition.icon == "toy-box"
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
# Assignment is not settable through the edit route: untouched.
|
||||
assert sorted(child.id for child in stored.assignees) == [ada, bo]
|
||||
|
||||
# A second edit touching only the rule leaves the metadata and the
|
||||
# windows alone, and updates the rule row IN PLACE (same rule id).
|
||||
rescheduled = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"rule": {"rule_type": "daily", "start_date": "2026-04-01"}},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert rescheduled.status_code == 200
|
||||
assert rescheduled.json()["rule"]["rule_type"] == "daily"
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
definition_id
|
||||
]
|
||||
assert stored.definition.title == "Clear the den"
|
||||
assert {(w.window, w.due_time) for w in stored.windows} == {
|
||||
("afternoon", None)
|
||||
}
|
||||
assert stored.rule.to_dict() == {
|
||||
"rule_type": "daily",
|
||||
"interval": 1,
|
||||
"weekday_set": None,
|
||||
"day_of_month": None,
|
||||
"nth_weekday": None,
|
||||
"nth_weekday_weekday": None,
|
||||
"month": None,
|
||||
"start_date": "2026-04-01",
|
||||
"end_date": None,
|
||||
}
|
||||
record = await dao.get(definition_id)
|
||||
assert record.schedule_rule_id == original_rule_id
|
||||
rule_row = await _dao_rules.ScheduleRulesDao(
|
||||
admin_questdefs.database
|
||||
).get(record.schedule_rule_id)
|
||||
assert rule_row.rule_type == "daily"
|
||||
assert rule_row.start_date == "2026-04-01"
|
||||
|
||||
|
||||
async def test_edit_can_clear_description_and_icon_explicitly(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An explicit null description/icon clears them (the core's edit path).
|
||||
|
||||
Unlike the children edit, the definition edit supports clearing: a
|
||||
supplied null IS forwarded and writes NULL; everything omitted
|
||||
stays untouched.
|
||||
"""
|
||||
client = admin_questdefs.client
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
created = await _create_definition(client, [ada])
|
||||
definition_id = created.json()["id"]
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"description": None, "icon": None},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["description"] is None
|
||||
assert response.json()["icon"] is None
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
definition_id
|
||||
]
|
||||
assert stored.definition.description is None
|
||||
assert stored.definition.icon is None
|
||||
assert stored.definition.title == "Tidy the den"
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
|
||||
|
||||
async def test_edit_unknown_definition_is_404(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An edit naming a definition that does not exist is 404."""
|
||||
response = await admin_questdefs.client.patch(
|
||||
"/api/v1/admin/quest-definitions/999",
|
||||
json={"title": "Ghost chore"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Quest definition not found"
|
||||
|
||||
|
||||
async def test_edit_rejections_are_422_and_change_nothing(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A rejected rule or window set is 422; the stored state survives.
|
||||
|
||||
The core rejects BEFORE any write (and the DAO edit runs in one
|
||||
transaction), so a 422 never leaves a half-applied change behind.
|
||||
"""
|
||||
client = admin_questdefs.client
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
created = await _create_definition(client, [ada])
|
||||
definition_id = created.json()["id"]
|
||||
|
||||
rejected_bodies: list[dict[str, object]] = [
|
||||
{"rule": {"rule_type": "weekly"}}, # weekly without weekday_set
|
||||
{"rule": None}, # an explicit null rule is rejected by the core
|
||||
{"windows": ["attic"]}, # unknown window name
|
||||
{"windows": None}, # an explicit null window set is rejected
|
||||
{"title": ""}, # the core rejects an empty title
|
||||
]
|
||||
for body in rejected_bodies:
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json=body,
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422, body
|
||||
|
||||
# Database proof: nothing changed for any rejected call.
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
definition_id
|
||||
]
|
||||
assert stored.definition.title == "Tidy the den"
|
||||
assert stored.definition.description == (
|
||||
"Put everything back where it belongs."
|
||||
)
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
assert {(w.window, w.due_time) for w in stored.windows} == {
|
||||
("morning", None),
|
||||
("evening", "19:30"),
|
||||
}
|
||||
|
||||
|
||||
# --- set_active -------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_set_active_toggles_the_flag_and_the_definition_survives(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""is_active=false deactivates; the definition STILL exists intact.
|
||||
|
||||
Deactivation is the only removal path: the row, rule, assignees and
|
||||
windows all survive, and reactivation through the same route
|
||||
restores the flag without anything having been lost.
|
||||
"""
|
||||
client = admin_questdefs.client
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
created = await _create_definition(client, [ada])
|
||||
assert created.status_code == 201
|
||||
definition_id = created.json()["id"]
|
||||
|
||||
deactivated = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
payload = deactivated.json()
|
||||
assert payload["is_active"] is False
|
||||
# Everything else came back unchanged: deactivation only flips a flag.
|
||||
assert payload["title"] == "Tidy the den"
|
||||
assert payload["rule"] == _weekly_rule_dict()
|
||||
assert [a["id"] for a in payload["assignees"]] == [ada]
|
||||
assert {w["window"]: w["due_time"] for w in payload["windows"]} == {
|
||||
"morning": None,
|
||||
"evening": "19:30",
|
||||
}
|
||||
|
||||
# Database proof: the definition left the ACTIVE list but its row,
|
||||
# rule, assignees and windows were NOT hard-deleted.
|
||||
assert definition_id not in await _definitions_by_id(
|
||||
admin_questdefs.database
|
||||
)
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
|
||||
record = await dao.get(definition_id)
|
||||
assert record is not None
|
||||
assert record.is_active is False
|
||||
assert record.title == "Tidy the den"
|
||||
assert await dao.list_assignees(definition_id) != []
|
||||
assert await dao.list_windows(definition_id) != []
|
||||
|
||||
# Reactivation goes through the same route: the flag is set, not
|
||||
# one-way, and the surviving rule is still the one that was stored.
|
||||
reactivated = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}/active",
|
||||
json={"is_active": True},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert reactivated.status_code == 200
|
||||
assert reactivated.json()["is_active"] is True
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
definition_id
|
||||
]
|
||||
assert stored.definition.is_active is True
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
assert [child.id for child in stored.assignees] == [ada]
|
||||
|
||||
|
||||
async def test_set_active_unknown_definition_is_404(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A set_active naming a definition that does not exist is 404."""
|
||||
response = await admin_questdefs.client.patch(
|
||||
"/api/v1/admin/quest-definitions/999/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Quest definition not found"
|
||||
|
||||
|
||||
async def test_set_active_malformed_body_is_422(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-bool is_active is rejected by the body model (422)."""
|
||||
response = await admin_questdefs.client.patch(
|
||||
"/api/v1/admin/quest-definitions/1/active",
|
||||
json={"is_active": "maybe"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -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,694 +0,0 @@
|
||||
"""Admin-plane uncomplete and regenerate route tests (task da0226b3).
|
||||
|
||||
These tests exercise the admin plane's completion-reversal and
|
||||
re-materialization routes against the ASGI app with httpx, reusing the
|
||||
shared local-RSA-key/stubbed-JWKS harness
|
||||
(:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely
|
||||
authenticates — signature, issuer, audience, expiry and the
|
||||
nestquest-admins group — with NO network access. The household is
|
||||
seeded with :func:`tests.test_api_panel._seed_household` through the
|
||||
SAME core copy the app uses (``nestquest_core.*``; the two-copy caveat
|
||||
in api/nestquest_core.py), and the route's ONE clock read
|
||||
(:func:`api.routes_admin._local_now`) is pinned the same way the panel
|
||||
route's is, so seeded rows and route stamps cannot straddle midnight.
|
||||
|
||||
Every done-condition case is proven against the DATABASE, read through
|
||||
that same core copy:
|
||||
|
||||
- Authentication runs through the router's ONE ``require_admin``
|
||||
dependency: an admin JWT passes, a JWT without the group is 403, an
|
||||
absent credential is 401 — and nothing is written.
|
||||
- ``POST /api/v1/admin/instances/{id}/uncomplete``: the completion
|
||||
events log for the instance holds the ORIGINAL ``completed`` row
|
||||
UNTOUCHED plus ONE appended ``uncompleted`` row attributed to the
|
||||
verified token's ``sub`` (actor_source ``user``), and the instance's
|
||||
DERIVED state is ``open`` again; a second uncomplete is a no-op that
|
||||
appends nothing.
|
||||
- On an ACTUAL reversal the ``nestquest_quest_uncompleted`` transition
|
||||
arrives on the SSE stream exactly once (subscribed live as
|
||||
:mod:`tests.test_api_sse` does); a no-op uncomplete publishes
|
||||
nothing.
|
||||
- ``POST /api/v1/admin/regenerate`` re-materializes without
|
||||
duplicating: running it twice (household, definition and child
|
||||
scopes) leaves the instance count stable, and the scoped variants
|
||||
rebuild only their own scope's rows.
|
||||
|
||||
Error mapping (documented in api/routes_admin.py): an unknown
|
||||
instance is 404 (the core's ``instance_id:`` prefix, the same key the
|
||||
panel complete route uses); unknown definition/child scope ids are 404
|
||||
from the route's own existence reads; malformed input — a bad scope,
|
||||
a scope/id mismatch, a zero id, a non-integer path id — is 422 before
|
||||
any handler code runs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from api import routes_admin
|
||||
from api.app import create_app
|
||||
from api.auth import JwksCache
|
||||
from api.config import ApiConfig
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
AUDIENCE,
|
||||
ISSUER,
|
||||
JWKS_URL,
|
||||
KID,
|
||||
AdminRunner,
|
||||
StubbedFetch,
|
||||
SUBJECT,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
from tests.test_api_panel import PANEL_TOKEN, _seed_household
|
||||
from tests.test_api_sse import _EventStream, _Server, _stream
|
||||
|
||||
# The completion and instance-DAO layers through the SAME core copy the
|
||||
# app uses (nestquest_core.*, NOT the conftest-stubbed
|
||||
# custom_components copy) — used for the tests' database assertions
|
||||
# only; the routes reach them through api.nestquest_core.
|
||||
_completion = importlib.import_module("nestquest_core.completion")
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
|
||||
#: The documented quest-uncompleted event name (core const, §3).
|
||||
EVENT_QUEST_UNCOMPLETED = importlib.import_module(
|
||||
"nestquest_core.const"
|
||||
).EVENT_QUEST_UNCOMPLETED
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _pinned_noon() -> tuple[datetime.date, datetime.datetime]:
|
||||
"""One host clock read pinned to 12:00 UTC that day.
|
||||
|
||||
The seed's completion and the route's clock both anchor to this
|
||||
instant, so nothing can straddle midnight mid-test.
|
||||
"""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
return today, datetime.datetime(
|
||||
today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC")
|
||||
)
|
||||
|
||||
|
||||
async def _instance_count(database: object) -> int:
|
||||
"""The whole ``quest_instances`` row count (the sanctioned probe)."""
|
||||
row = await database.fetch_one("SELECT COUNT(*) FROM quest_instances")
|
||||
return row[0]
|
||||
|
||||
|
||||
async def _events_for_instance(
|
||||
database: object, instance_id: int
|
||||
) -> list:
|
||||
"""The instance's completion events, in append order."""
|
||||
dao = _dao_instances.CompletionEventsDao(database)
|
||||
return await dao.list_by_instance(instance_id)
|
||||
|
||||
|
||||
async def _derived_state(
|
||||
database: object, instance_id: int, today: datetime.date
|
||||
) -> str:
|
||||
"""The instance's derived state through the app's core copy."""
|
||||
dao = _dao_instances.QuestInstancesDao(database)
|
||||
instance = await dao.get_by_id(instance_id)
|
||||
assert instance is not None
|
||||
latest = await _dao_instances.CompletionEventsDao(
|
||||
database
|
||||
).get_latest_for_instance(instance_id)
|
||||
return _completion.derive_state(instance, latest, today)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_api(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database.
|
||||
|
||||
The same runner the auth and children tests use (stubbed JWKS, no
|
||||
network); the fixture exposes the app's ONE database connection so
|
||||
tests assert the routes' effects on the stored rows themselves.
|
||||
"""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
database=runner.app.state.db.database,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_household(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
||||
"""A seeded household served by the admin-plane app, clock pinned.
|
||||
|
||||
``_seed_household`` completes Bo's today instance (17:00 due, 12:00
|
||||
pinned — on time), which is the completion the uncomplete tests
|
||||
reverse. The route's ``_local_now`` is pinned to the same instant.
|
||||
"""
|
||||
today, pinned_now = _pinned_noon()
|
||||
seed = await _seed_household(temp_db_path, today, pinned_now)
|
||||
monkeypatch.setattr(routes_admin, "_local_now", lambda: pinned_now)
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
seed=seed,
|
||||
database=runner.app.state.db.database,
|
||||
today=today,
|
||||
)
|
||||
|
||||
|
||||
class _AdminServer(_Server):
|
||||
""":mod:`tests.test_api_sse`'s live-uvicorn server, admin-enabled.
|
||||
|
||||
Same ephemeral-port uvicorn thread (httpx's ASGI transport buffers
|
||||
SSE bodies, so a live server is the only way to stream), but the
|
||||
app factory swaps the JWKS cache for one built around the harness's
|
||||
stub fetcher, so a real admin JWT verifies with NO network access.
|
||||
"""
|
||||
|
||||
def _make_app_factory(self, db_path: str):
|
||||
"""The uvicorn app factory with the stubbed JWKS cache."""
|
||||
|
||||
def factory():
|
||||
app = create_app(
|
||||
ApiConfig(
|
||||
db_path=db_path,
|
||||
panel_token=PANEL_TOKEN,
|
||||
oidc_issuer=ISSUER,
|
||||
oidc_audience=AUDIENCE,
|
||||
oidc_jwks_url=JWKS_URL,
|
||||
)
|
||||
)
|
||||
app.state.jwks_cache = JwksCache(
|
||||
JWKS_URL, fetcher=StubbedFetch(jwks_for(ADMIN_KEY, KID))
|
||||
)
|
||||
self._server_loop = asyncio.get_running_loop()
|
||||
self.app = app
|
||||
return app
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_sse(temp_db_path: str) -> SimpleNamespace:
|
||||
"""A seeded household served by a live uvicorn app, clock pinned.
|
||||
|
||||
Same pinning discipline as the panel tests, applied to
|
||||
:func:`api.routes_admin._local_now` BEFORE the server starts (the
|
||||
app is created inside uvicorn's factory thread).
|
||||
"""
|
||||
today, pinned_now = _pinned_noon()
|
||||
seed = await _seed_household(temp_db_path, today, pinned_now)
|
||||
original = routes_admin._local_now
|
||||
routes_admin._local_now = lambda: pinned_now
|
||||
try:
|
||||
with _AdminServer(temp_db_path) as server:
|
||||
client = httpx.AsyncClient(base_url=server.url(""))
|
||||
async with client:
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
seed=seed,
|
||||
server=server,
|
||||
database=server.app.state.db.database,
|
||||
today=today,
|
||||
)
|
||||
finally:
|
||||
routes_admin._local_now = original
|
||||
|
||||
|
||||
# --- authentication: the ONE require_admin dependency guards every route ---
|
||||
|
||||
|
||||
async def test_uncomplete_and_regenerate_refuse_non_admin_and_absent(
|
||||
admin_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-admin JWT is 403, an absent credential is 401: nothing written."""
|
||||
client = admin_household.client
|
||||
database = admin_household.database
|
||||
brush = admin_household.seed.brush_teeth_instance
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
events_before = await _events_for_instance(database, brush.id)
|
||||
instances_before = await _instance_count(database)
|
||||
|
||||
uncomplete_url = f"/api/v1/admin/instances/{brush.id}/uncomplete"
|
||||
forbidden = await client.post(
|
||||
uncomplete_url,
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
assert forbidden.json()["detail"] == (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
unauthenticated = await client.post(uncomplete_url)
|
||||
assert unauthenticated.status_code == 401
|
||||
|
||||
regenerate_bodies = (
|
||||
{},
|
||||
{"scope": "household"},
|
||||
{"scope": "child", "child_id": 1},
|
||||
)
|
||||
for body in regenerate_bodies:
|
||||
forbidden = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden.status_code == 403, body
|
||||
unauthenticated = await client.post(
|
||||
"/api/v1/admin/regenerate", json=body
|
||||
)
|
||||
assert unauthenticated.status_code == 401, body
|
||||
|
||||
# No handler ran: the seeded completion is still the ONLY event for
|
||||
# the instance and the instance table is untouched.
|
||||
assert (
|
||||
await _events_for_instance(database, brush.id) == events_before
|
||||
)
|
||||
assert await _instance_count(database) == instances_before
|
||||
|
||||
|
||||
# --- uncomplete: the append-only reversal -----------------------------------
|
||||
|
||||
|
||||
async def test_uncomplete_appends_reversal_and_derived_state_is_open(
|
||||
admin_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A reversal APPENDS an uncompleted event; the instance is open again.
|
||||
|
||||
The original completed row is proven byte-identical after the call
|
||||
(frozen-dataclass equality), the new row carries the verified
|
||||
token's ``sub`` as the user actor, and the DERIVED state —
|
||||
recomputed from the latest event — is ``open`` again.
|
||||
"""
|
||||
client = admin_household.client
|
||||
database = admin_household.database
|
||||
brush = admin_household.seed.brush_teeth_instance
|
||||
today = admin_household.today
|
||||
|
||||
before = await _events_for_instance(database, brush.id)
|
||||
assert [event.event_type for event in before] == ["completed"]
|
||||
assert await _derived_state(database, brush.id, today) == "done"
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/admin/instances/{brush.id}/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body == {
|
||||
"instance_id": brush.id,
|
||||
"state": "open",
|
||||
"appended": True,
|
||||
}
|
||||
|
||||
events = await _events_for_instance(database, brush.id)
|
||||
assert len(events) == 2
|
||||
# The original completed row is UNTOUCHED — never edited, never
|
||||
# deleted (append-only completion_events).
|
||||
assert events[0] == before[0]
|
||||
assert events[0].event_type == "completed"
|
||||
assert events[0].actor_source == "panel"
|
||||
# The reversal is the NEW row, attributed to the verified token.
|
||||
reversal = events[1]
|
||||
assert reversal.event_type == "uncompleted"
|
||||
assert reversal.instance_id == brush.id
|
||||
assert reversal.child_id == brush.child_id
|
||||
assert reversal.actor_source == "user"
|
||||
assert reversal.actor_user_id == SUBJECT
|
||||
assert reversal.actor_child_id is None
|
||||
assert reversal.was_on_time is None
|
||||
# The derived state is recomputed from the latest event: open again.
|
||||
assert await _derived_state(database, brush.id, today) == "open"
|
||||
|
||||
|
||||
async def test_second_uncomplete_is_a_noop_that_appends_nothing(
|
||||
admin_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A repeat uncomplete appends NOTHING and still succeeds."""
|
||||
client = admin_household.client
|
||||
database = admin_household.database
|
||||
brush = admin_household.seed.brush_teeth_instance
|
||||
today = admin_household.today
|
||||
|
||||
first = await client.post(
|
||||
f"/api/v1/admin/instances/{brush.id}/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert first.json()["appended"] is True
|
||||
|
||||
second = await client.post(
|
||||
f"/api/v1/admin/instances/{brush.id}/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert second.status_code == 200
|
||||
body = second.json()
|
||||
assert body["appended"] is False
|
||||
assert body["state"] == "open"
|
||||
|
||||
events = await _events_for_instance(database, brush.id)
|
||||
assert len(events) == 2
|
||||
assert [event.event_type for event in events] == [
|
||||
"completed",
|
||||
"uncompleted",
|
||||
]
|
||||
assert await _derived_state(database, brush.id, today) == "open"
|
||||
|
||||
|
||||
async def test_uncomplete_unknown_instance_is_404_and_malformed_is_422(
|
||||
admin_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An unknown instance is 404; a non-integer path id is 422."""
|
||||
client = admin_household.client
|
||||
brush = admin_household.seed.brush_teeth_instance
|
||||
before = await _events_for_instance(
|
||||
admin_household.database, brush.id
|
||||
)
|
||||
|
||||
unknown = await client.post(
|
||||
"/api/v1/admin/instances/999/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert unknown.status_code == 404
|
||||
assert unknown.json()["detail"] == "Quest instance not found"
|
||||
|
||||
malformed = await client.post(
|
||||
"/api/v1/admin/instances/not-an-int/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert malformed.status_code == 422
|
||||
|
||||
# Neither rejection wrote anything.
|
||||
assert (
|
||||
await _events_for_instance(admin_household.database, brush.id)
|
||||
== before
|
||||
)
|
||||
|
||||
|
||||
async def test_uncomplete_already_open_past_due_reports_missed(
|
||||
admin_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A no-op uncomplete still reports the honest DERIVED state.
|
||||
|
||||
The seeded stale instance is open and past due: uncompleting it is
|
||||
a no-op (nothing appended) and the derived state is ``missed`` —
|
||||
open-but-past-due, never an event type.
|
||||
"""
|
||||
client = admin_household.client
|
||||
database = admin_household.database
|
||||
stale = admin_household.seed.stale_instance
|
||||
today = admin_household.today
|
||||
assert await _derived_state(database, stale.id, today) == "missed"
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/admin/instances/{stale.id}/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["appended"] is False
|
||||
assert body["state"] == "missed"
|
||||
|
||||
# The no-op appended nothing: the instance still has NO events.
|
||||
assert await _events_for_instance(database, stale.id) == []
|
||||
|
||||
|
||||
# --- the SSE transition on an actual reversal --------------------------------
|
||||
|
||||
|
||||
async def test_uncomplete_publishes_exactly_one_uncompleted_transition(
|
||||
admin_sse: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An actual reversal streams ONE quest-uncompleted event; a no-op none.
|
||||
|
||||
The stream is subscribed FIRST (test_api_sse's live-server reader),
|
||||
the completed instance is reversed through the admin route, and the
|
||||
documented payload fields are asserted; the no-op second call adds
|
||||
no frame.
|
||||
"""
|
||||
seed = admin_sse.seed
|
||||
brush = seed.brush_teeth_instance
|
||||
stream = _stream(admin_sse.server.url("/api/v1/panel/events"))
|
||||
async with stream:
|
||||
first = await admin_sse.client.post(
|
||||
f"/api/v1/admin/instances/{brush.id}/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert first.json()["appended"] is True
|
||||
|
||||
event_type, payload = await stream.next_event()
|
||||
assert event_type == EVENT_QUEST_UNCOMPLETED
|
||||
assert event_type == "nestquest_quest_uncompleted"
|
||||
assert payload["child_id"] == seed.bo.id
|
||||
assert payload["child_name"] == "Bo"
|
||||
assert payload["instance_id"] == brush.id
|
||||
assert payload["quest_title"] == "Brush teeth"
|
||||
assert payload["window"] == "morning"
|
||||
assert payload["due_date"] == brush.due_date
|
||||
assert payload["due_time"] == "17:00"
|
||||
assert payload["occurred_at"] # strict UTC ISO-8601 stamp
|
||||
assert "was_on_time" not in payload
|
||||
|
||||
# The no-op second call publishes nothing: the stream stays
|
||||
# silent past the window.
|
||||
second = await admin_sse.client.post(
|
||||
f"/api/v1/admin/instances/{brush.id}/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["appended"] is False
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(stream.queue.get(), timeout=0.3)
|
||||
|
||||
|
||||
# --- regenerate: re-materialize without duplicating --------------------------
|
||||
|
||||
|
||||
async def _create_child(client: object, name: str) -> int:
|
||||
"""Create one child through the admin children route."""
|
||||
created = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": name},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
return created.json()["id"]
|
||||
|
||||
|
||||
async def _create_daily_definition(
|
||||
client: object, assignee_ids: list[int]
|
||||
) -> int:
|
||||
"""Create one daily one-window definition for the given assignees."""
|
||||
created = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Tidy the den",
|
||||
"rule": {"rule_type": "daily", "start_date": "2026-03-02"},
|
||||
"assignee_child_ids": assignee_ids,
|
||||
"windows": ["morning"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
return created.json()["id"]
|
||||
|
||||
|
||||
async def test_regenerate_household_never_duplicates(
|
||||
admin_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Two household regenerations leave the instance count stable.
|
||||
|
||||
The first call re-materializes the rolling horizon
|
||||
``[today, today + DEFAULT_HORIZON_DAYS]``; the second walks the
|
||||
SAME tuples and upserts them in place — the row count never grows
|
||||
and the core reports the same upsert count both times.
|
||||
"""
|
||||
client = admin_household.client
|
||||
database = admin_household.database
|
||||
|
||||
before = await _instance_count(database)
|
||||
assert before > 0 # the seed already materialized a few days
|
||||
|
||||
first = await client.post(
|
||||
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
|
||||
)
|
||||
assert first.status_code == 200
|
||||
body = first.json()
|
||||
assert body["scope"] == "household"
|
||||
assert body["definition_id"] is None
|
||||
assert body["child_id"] is None
|
||||
assert body["count"] >= before
|
||||
after_first = await _instance_count(database)
|
||||
assert after_first >= before
|
||||
|
||||
second = await client.post(
|
||||
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["count"] == body["count"]
|
||||
# The idempotent walk: the second run's row count is EXACTLY the
|
||||
# first's — no duplicated instances.
|
||||
assert await _instance_count(database) == after_first
|
||||
|
||||
|
||||
async def test_regenerate_definition_scope_rematerializes(
|
||||
admin_api: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The definition scope rebuilds that definition's horizon, twice, stably.
|
||||
|
||||
Creating a definition through the admin route materializes NOTHING
|
||||
(the core's create path does not regenerate); the scoped regenerate
|
||||
call is what materializes its instances, and re-running it leaves
|
||||
the row count exactly where the first run left it.
|
||||
"""
|
||||
client = admin_api.client
|
||||
database = admin_api.database
|
||||
ada = await _create_child(client, "Ada")
|
||||
definition_id = await _create_daily_definition(client, [ada])
|
||||
|
||||
assert await _instance_count(database) == 0
|
||||
|
||||
first = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
json={"scope": "definition", "definition_id": definition_id},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
body = first.json()
|
||||
assert body["scope"] == "definition"
|
||||
assert body["definition_id"] == definition_id
|
||||
assert body["child_id"] is None
|
||||
assert body["count"] > 0
|
||||
after_first = await _instance_count(database)
|
||||
assert after_first == body["count"]
|
||||
|
||||
second = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
json={"scope": "definition", "definition_id": definition_id},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["count"] == body["count"]
|
||||
assert await _instance_count(database) == after_first
|
||||
|
||||
|
||||
async def test_regenerate_child_scope_touches_only_that_child(
|
||||
admin_api: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The child scope rebuilds that child's rows and no others'.
|
||||
|
||||
Two children share one daily definition; the household walk
|
||||
materializes both. A child-scoped regenerate for Ada re-runs her
|
||||
side of the walk (same count) while Bo's rows keep their identity,
|
||||
and re-running it never grows the table.
|
||||
"""
|
||||
client = admin_api.client
|
||||
database = admin_api.database
|
||||
instances = _dao_instances.QuestInstancesDao(database)
|
||||
ada = await _create_child(client, "Ada")
|
||||
bo = await _create_child(client, "Bo")
|
||||
# One daily definition assigned to BOTH children: the household walk
|
||||
# gives each child its own rows.
|
||||
await _create_daily_definition(client, [ada, bo])
|
||||
|
||||
# Materialize the household first so BOTH children have rows.
|
||||
seeded = await client.post(
|
||||
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
|
||||
)
|
||||
assert seeded.status_code == 200
|
||||
assert seeded.json()["scope"] == "household"
|
||||
|
||||
ada_rows = await instances.list_by_date_range(
|
||||
ada, "2026-03-02", "2100-01-01"
|
||||
)
|
||||
bo_rows = await instances.list_by_date_range(
|
||||
bo, "2026-03-02", "2100-01-01"
|
||||
)
|
||||
assert ada_rows and bo_rows
|
||||
total = await _instance_count(database)
|
||||
assert total == len(ada_rows) + len(bo_rows)
|
||||
|
||||
for expected_count in (len(ada_rows), len(ada_rows)):
|
||||
response = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
json={"scope": "child", "child_id": ada},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body == {
|
||||
"scope": "child",
|
||||
"definition_id": None,
|
||||
"child_id": ada,
|
||||
"count": expected_count,
|
||||
}
|
||||
# Ada's side re-materialized the same number of rows and Bo's
|
||||
# rows kept their identity; the table never grew.
|
||||
assert await instances.list_by_date_range(
|
||||
ada, "2026-03-02", "2100-01-01"
|
||||
)
|
||||
assert await instances.list_by_date_range(
|
||||
bo, "2026-03-02", "2100-01-01"
|
||||
) == bo_rows
|
||||
assert await _instance_count(database) == total
|
||||
|
||||
|
||||
async def test_regenerate_unknown_scope_ids_are_404(
|
||||
admin_api: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An unknown definition or child scope id is 404, nothing written."""
|
||||
client = admin_api.client
|
||||
|
||||
unknown_definition = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
json={"scope": "definition", "definition_id": 999},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert unknown_definition.status_code == 404
|
||||
assert unknown_definition.json()["detail"] == "Quest definition not found"
|
||||
|
||||
unknown_child = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
json={"scope": "child", "child_id": 999},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert unknown_child.status_code == 404
|
||||
assert unknown_child.json()["detail"] == "Child not found"
|
||||
|
||||
assert await _instance_count(admin_api.database) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
{"scope": "galaxy"}, # unknown scope
|
||||
{"scope": "definition"}, # scope without its id
|
||||
{"scope": "child"}, # scope without its id
|
||||
{"scope": "household", "child_id": 1}, # id without its scope
|
||||
{"scope": "household", "definition_id": 1}, # id without its scope
|
||||
{
|
||||
"scope": "definition",
|
||||
"definition_id": 1,
|
||||
"child_id": 2,
|
||||
}, # scope mismatch
|
||||
{"scope": "child", "child_id": 0}, # zero id
|
||||
{"scope": "child", "child_id": "seven"}, # wrong type
|
||||
],
|
||||
)
|
||||
async def test_regenerate_malformed_bodies_are_422(
|
||||
admin_api: SimpleNamespace, body: dict[str, object]
|
||||
) -> None:
|
||||
"""Malformed scope/id combinations are 422 before any core call."""
|
||||
response = await admin_api.client.post(
|
||||
"/api/v1/admin/regenerate", json=body, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422, body
|
||||
assert await _instance_count(admin_api.database) == 0
|
||||
@@ -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):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user