Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d74efd495 | ||
|
|
a105b2b160 | ||
|
|
d0c236b569 | ||
|
|
b8532bacde | ||
|
|
33d8e0d2ac | ||
|
|
dbe873eb2a | ||
|
|
4b39af4b67 | ||
|
|
706a7954d8 | ||
|
|
42ea5710d7 | ||
|
|
be4fd04754 | ||
|
|
4ff40f00a2 | ||
|
|
a808f89993 | ||
|
|
f476821e8e | ||
|
|
75ad9892b8 | ||
|
|
48d0c4ef86 | ||
|
|
22f1a6861d | ||
|
|
939556a38a | ||
|
|
8676bdc6ad | ||
|
|
0ed35ab85a | ||
|
|
c57d950101 | ||
|
|
70ee4ec111 | ||
|
|
415ababc8e | ||
|
|
0972761994 | ||
|
|
10f365ef9f |
+129
-21
@@ -1,27 +1,135 @@
|
||||
# NestQuest Release Notes
|
||||
|
||||
## Unreleased
|
||||
## Version 0.6.0 — 2026-09-23
|
||||
|
||||
- **Storage layer extraction (task d0b691d8):** the SQLite storage layer
|
||||
(schema, migrations, db, store, const, recurrence, DAOs) is bundled
|
||||
under `custom_components/nestquest/core/`, with the integration's
|
||||
original module names preserved as re-export shims.
|
||||
- **Logger namespace change (breaking for logging configuration):** the
|
||||
migrations runner now logs under
|
||||
`custom_components.nestquest.core.migrations` instead of
|
||||
`custom_components.nestquest.migrations`. Users who tuned the old
|
||||
logger name (e.g. in `logger.yaml` or `configuration.yaml`) must
|
||||
update the entry to keep that level, as the old name no longer
|
||||
receives migration log output.
|
||||
- **Additional logger namespace moves:** the children registry and the
|
||||
admin allowlist business layers now log under
|
||||
`custom_components.nestquest.core.children` and
|
||||
`custom_components.nestquest.core.admin_allowlist` respectively
|
||||
(moved with the engines/business-layer extraction, task 510c1f78).
|
||||
The parent `custom_components.nestquest` logger still catches both,
|
||||
so users who tune the parent rather than the leaf names are
|
||||
unaffected; only users who pinned a leaf logger name by its old
|
||||
spelling need to update.
|
||||
### Scope
|
||||
|
||||
Sixth release of NestQuest, promoting the re-imagined architecture
|
||||
(Features 15, 16, 18 and 20) and the foreground development-orchestration
|
||||
tooling from `dev` onto 0.5.0. This is a major architectural change: the
|
||||
Home Assistant integration becomes a thin client of a standalone NestQuest
|
||||
API service that owns the database.
|
||||
|
||||
- **Feature 15 — Domain Core Extraction:** all domain code (schema,
|
||||
migrations, database access, the DAOs, the pure recurrence/presence
|
||||
engines, materialization, completion, the SSE-free event payload
|
||||
builders, the panel snapshot builder, the children/definitions/allowlist
|
||||
business layers, and an explicit settings object) now lives in a
|
||||
Home-Assistant-free package bundled inside the integration at
|
||||
`custom_components/nestquest/core/`. The SQLite schema and migration
|
||||
versions are unchanged, so an existing `nestquest.db` opens without
|
||||
migration. Covered by a no-Home-Assistant end-to-end smoke test.
|
||||
(Tasks d0b691d8, 510c1f78, 4dd8f870, 628473ad.)
|
||||
- **Feature 16 — NestQuest API Service:** a standalone FastAPI service
|
||||
(`api/`) that imports `core/` and is the only writer of the database.
|
||||
Panel plane (`/api/v1/panel/*`) is authenticated by a static service
|
||||
token: household snapshot and an idempotent complete route. Admin plane
|
||||
(`/api/v1/admin/*`): children, quest definitions, presence
|
||||
schedule/override, uncomplete, regenerate, history query + CSV export,
|
||||
and settings — authenticated by an Authentik OIDC JWT (issuer, audience,
|
||||
JWKS signature and expiry) plus membership in `nestquest-admins`, with
|
||||
the panel service token rejected outright. A server-sent-events stream
|
||||
publishes the quest-completed, quest-uncompleted, quest-missed and
|
||||
child-day-complete transitions; the missed sweep runs in the API with a
|
||||
cross-process-safe watermark. `/health` and the OpenAPI document are
|
||||
served. (Tasks 042dfc5c, ae7e90ae, 1860ef76, 4d7bae00, 44c93cfd,
|
||||
e70e6bfb, 0f2afb29, e4845baa, b586a2f1, e4a9d31b, da0226b3, 2d2dda53,
|
||||
2b3de7e5, 9047a97b, 9a118da9, 058c7b69, a232c4f5.)
|
||||
- **Feature 18 — HA Integration as API Client:** the integration no longer
|
||||
owns a database. It polls `GET /api/v1/panel/snapshot` and builds the
|
||||
existing sensors (entity ids and per-child instance payload shape
|
||||
unchanged), subscribes to the SSE stream and re-fires the four HA bus
|
||||
events, proxies `nestquest.complete_quest` to the panel complete route,
|
||||
and caches the last-good snapshot so a brief API outage does not blank
|
||||
the panel. The HA admin services and the service permission gate are
|
||||
removed; only the panel completion path remains. (Tasks 37d5e806,
|
||||
6e70d166, b1ed9c45, 47c7c90e, d25b875f, d04f5bf3, ad67fc44.)
|
||||
- **Feature 20 — Zero-Config Panel Dashboard:** the panel installs with no
|
||||
hand-written card YAML and no manual dashboard creation. The cards
|
||||
auto-discover children from a household entity attribute (ordered by the
|
||||
API's `sort_order`), a Lovelace dashboard strategy generates the
|
||||
party-board view and one quest-log view per child at render time with
|
||||
computed navigation paths, and the integration registers the dashboard
|
||||
on first setup. Navigation and the panel transitions match the design
|
||||
mockups. (Tasks 1b6d91d2, c859af23, c2d4dd8e, 107b8fbe, 50067c1f,
|
||||
fc69c940, 67775be9.)
|
||||
- **Development orchestration tooling:** durable OpenCode controller and
|
||||
role launchers, the foreground terminal orchestrator, the independent
|
||||
review/approval gates, and the automatic release-preparation policy.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Minimum Home Assistant version: **2024.6.0**.
|
||||
- A running NestQuest API service, reachable at the base URL and panel
|
||||
service token configured in the integration options. The API service
|
||||
source ships in `api/`; there is no container image, compose file or
|
||||
deployment runbook in this release, and Feature 17 — the deployment
|
||||
(TLS reverse proxy, Authentik, network separation, backups) — is **not**
|
||||
part of this release and must be completed before the integration is
|
||||
functional. See `docs/ARCHITECTURE.md` for the target design.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- **The HA integration no longer owns a database.** It reads exclusively
|
||||
from the NestQuest API. The local `nestquest.db` is no longer opened,
|
||||
migrated or written by the integration (the file is left untouched on
|
||||
disk). Without a reachable API the entities become unavailable after the
|
||||
configured staleness threshold; the panel serves the last-good snapshot
|
||||
in the meantime.
|
||||
- **HA admin services are removed** (`uncomplete`, quest-definition
|
||||
create/update/set-active, presence pattern/override, CSV export,
|
||||
`manage_child`, `regenerate`) and the HA-side permission gate is gone.
|
||||
Admin operations move to the API, and — once Feature 19 lands — the
|
||||
admin PWA. Any automation that called an admin service must be
|
||||
repointed at the API.
|
||||
- **Logger namespaces moved** under `custom_components.nestquest.core.*`:
|
||||
the migrations runner now logs under
|
||||
`custom_components.nestquest.core.migrations` (previously
|
||||
`custom_components.nestquest.migrations`), and the children registry and
|
||||
admin allowlist log under `custom_components.nestquest.core.children` and
|
||||
`custom_components.nestquest.core.admin_allowlist`. The parent
|
||||
`custom_components.nestquest` logger still catches all three; only users
|
||||
who pinned a leaf logger name by its old spelling must update.
|
||||
- **The panel installs with no configuration.** `child_order`,
|
||||
`quest_log_path` and `board_path` are no longer required (an explicit
|
||||
value still wins). The integration registers a `custom:nestquest-party`
|
||||
dashboard on setup; point TouchHub at it.
|
||||
- **Schema is unchanged at version 7** — no migration runs on upgrade.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- **Feature 17 (API deployment and identity) is not in this release.** The
|
||||
API service source ships in `api/`, but no container image, compose
|
||||
file, TLS reverse proxy, Authentik application/provider/`nestquest-admins`
|
||||
group, proven network separation of the panel routes, or backup/restore
|
||||
is included. Feature 17's containerization groundwork was completed on
|
||||
its own feature branch, which is **not** merged into `dev` and therefore
|
||||
is not part of this release. Until that deployment is completed the
|
||||
integration has nothing to talk to.
|
||||
- **Feature 19 (admin PWA) is not in this release.** There is no admin UI
|
||||
in 0.6.0; admin operations are reachable only through the API directly.
|
||||
- Moving the household database from Home Assistant to the API box is a
|
||||
manual file copy (the schema is unchanged).
|
||||
- Single household; single API service.
|
||||
|
||||
### Rollback
|
||||
|
||||
Schema version is unchanged (7), so no migration is involved in either
|
||||
direction. Nevertheless **back up `nestquest.db` (plus `-wal`/`-shm`)
|
||||
before upgrading**: 0.6.0 never touches it, and a downgrade to 0.5.0
|
||||
resumes using exactly that file.
|
||||
|
||||
**HACS installs:**
|
||||
|
||||
1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.5.0).
|
||||
2. Restart Home Assistant.
|
||||
|
||||
**Manual installs:**
|
||||
|
||||
1. Replace `custom_components/nestquest/` with the 0.5.0 tree.
|
||||
2. Restart Home Assistant.
|
||||
|
||||
**Repository operators:** git-revert the 0.6.0 release merge on `main`.
|
||||
|
||||
## Version 0.5.0 — 2026-09-18
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# NestQuest Admin PWA — runtime configuration (Vite env).
|
||||
# Copy to .env (or .env.local) and fill in real values. NEVER commit .env.
|
||||
|
||||
# Authentik OIDC provider. Defaults to the issuer below when unset.
|
||||
VITE_AUTHENTIK_ISSUER=https://auth.cubecraftlabs.com/application/o/nestquest/
|
||||
|
||||
# REQUIRED. OAuth2/OIDC client id of the PUBLIC (PKCE) Authentik application.
|
||||
# No default: the app shows a configuration error screen when this is missing.
|
||||
# Never set a client_secret — this is a browser SPA using PKCE only.
|
||||
VITE_AUTHENTIK_CLIENT_ID=
|
||||
|
||||
# NestQuest API base URL.
|
||||
VITE_API_BASE_URL=https://nestquest.cubecraftlabs.com
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#3b3ab8" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<title>NestQuest Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3024
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "nestquest-admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"jsdom": "^30.1.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "NestQuest Admin",
|
||||
"short_name": "NestQuest",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f4f4f6",
|
||||
"theme_color": "#3b3ab8",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/* NestQuest Admin service worker — hand-written, served from the site root.
|
||||
Precaches the app shell, serves static assets cache-first, and falls back
|
||||
to the cached shell for navigations when offline. Only same-origin GET
|
||||
requests are handled; cross-origin requests (Authentik, the API host) are
|
||||
never intercepted or cached.
|
||||
The build (vite-sw-plugin.ts) replaces PRECACHE_ASSETS with the hashed
|
||||
bundles referenced by index.html and the revision marker with a content
|
||||
hash, so each release installs a new worker with fresh caches. */
|
||||
|
||||
const REVISION = "__NQ_BUILD_REVISION__";
|
||||
const CACHE_PREFIX = "nestquest-admin-";
|
||||
const SHELL_CACHE = `${CACHE_PREFIX}shell-${REVISION}`;
|
||||
const RUNTIME_CACHE = `${CACHE_PREFIX}runtime-${REVISION}`;
|
||||
const CURRENT_CACHES = [SHELL_CACHE, RUNTIME_CACHE];
|
||||
|
||||
const PRECACHE_ASSETS = [];
|
||||
|
||||
const SHELL_URLS = [
|
||||
...new Set([
|
||||
"/",
|
||||
"/index.html",
|
||||
"/manifest.webmanifest",
|
||||
"/icons/icon-192.png",
|
||||
"/icons/icon-512.png",
|
||||
"/icons/icon-maskable-512.png",
|
||||
...PRECACHE_ASSETS,
|
||||
]),
|
||||
];
|
||||
|
||||
// Request destinations treated as static assets (cache-first). Anything else
|
||||
// same-origin (e.g. API calls) goes straight to the network.
|
||||
const STATIC_DESTINATIONS = ["script", "style", "image", "font", "manifest"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(SHELL_CACHE)
|
||||
.then((cache) => cache.addAll(SHELL_URLS))
|
||||
.then(() => self.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((key) => key.startsWith(CACHE_PREFIX) && !CURRENT_CACHES.includes(key))
|
||||
.map((key) => caches.delete(key)),
|
||||
),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
if (request.method !== "GET") return;
|
||||
if (new URL(request.url).origin !== self.location.origin) return;
|
||||
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() =>
|
||||
caches.match("/index.html", { cacheName: SHELL_CACHE }).then(
|
||||
(cached) => cached || Response.error(),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (STATIC_DESTINATIONS.includes(request.destination)) {
|
||||
event.respondWith(cacheFirst(request));
|
||||
}
|
||||
});
|
||||
|
||||
async function cacheFirst(request) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
const response = await fetch(request);
|
||||
if (response.ok && response.type === "basic") {
|
||||
const cache = await caches.open(RUNTIME_CACHE);
|
||||
await cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Generates the committed PWA icons under public/icons/ as genuine PNGs.
|
||||
// Pure Node (zlib only) — no network, no image dependencies.
|
||||
// Run: node scripts/generate-icons.mjs
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Brand tokens (design/tokens/nestquest-admin-tokens.css).
|
||||
const BLUE = [0x3b, 0x3a, 0xb8];
|
||||
const PURPLE = [0xa0, 0x35, 0xcc];
|
||||
const WHITE = [0xff, 0xff, 0xff];
|
||||
|
||||
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
return c >>> 0;
|
||||
});
|
||||
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length);
|
||||
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(body));
|
||||
return Buffer.concat([len, body, crc]);
|
||||
}
|
||||
|
||||
function encodePng(size, pixel) {
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(size, 0);
|
||||
ihdr.writeUInt32BE(size, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 2; // colour type: RGB
|
||||
const raw = Buffer.alloc(size * (size * 3 + 1));
|
||||
for (let y = 0; y < size; y++) {
|
||||
const row = y * (size * 3 + 1);
|
||||
raw[row] = 0; // filter: none
|
||||
for (let x = 0; x < size; x++) raw.set(pixel(x, y), row + 1 + x * 3);
|
||||
}
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// Flat brand mark: diagonal blue→purple gradient with a white ring ("nest")
|
||||
// around a white dot ("quest"). `scale` shrinks the mark for the maskable
|
||||
// safe zone (inner 80% circle).
|
||||
function brandMark(size, scale) {
|
||||
const c = size / 2;
|
||||
const outer = size * 0.34 * scale;
|
||||
const inner = size * 0.24 * scale;
|
||||
const dot = size * 0.1 * scale;
|
||||
return (x, y) => {
|
||||
const d = Math.hypot(x + 0.5 - c, y + 0.5 - c);
|
||||
if ((d <= outer && d >= inner) || d <= dot) return WHITE;
|
||||
const t = (x + y) / (2 * (size - 1));
|
||||
return BLUE.map((b, i) => Math.round(b + (PURPLE[i] - b) * t));
|
||||
};
|
||||
}
|
||||
|
||||
const outDir = new URL("../public/icons/", import.meta.url);
|
||||
const icons = [
|
||||
["icon-192.png", 192, 1],
|
||||
["icon-512.png", 512, 1],
|
||||
["icon-maskable-512.png", 512, 0.75],
|
||||
];
|
||||
for (const [name, size, scale] of icons) {
|
||||
const path = fileURLToPath(new URL(name, outDir));
|
||||
writeFileSync(path, encodePng(size, brandMark(size, scale)));
|
||||
console.log(`wrote ${path}`);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/* Shell layout — mobile-first (390x844 reference), plain CubeCraft styling. */
|
||||
|
||||
.admin-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: var(--nq-a-surface);
|
||||
border-bottom: 1px solid var(--nq-a-border);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.admin-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.admin-subline {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.admin-panel {
|
||||
flex: 1;
|
||||
padding: 14px 16px 92px; /* bottom padding clears the 70px tab bar */
|
||||
}
|
||||
|
||||
/* A built screen supplies its own header and 92px-padded scroll column. */
|
||||
.admin-panel--screen {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.admin-placeholder {
|
||||
margin: 0;
|
||||
padding: 13px;
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: 8px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.admin-tabbar {
|
||||
position: fixed;
|
||||
inset: auto 0 0;
|
||||
display: flex;
|
||||
min-height: calc(var(--nq-a-tabbar-height) + 12px);
|
||||
padding: 8px 4px calc(12px + env(safe-area-inset-bottom));
|
||||
background: var(--nq-a-surface);
|
||||
border-top: 1px solid var(--nq-a-border);
|
||||
}
|
||||
|
||||
.admin-tab {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
cursor: pointer;
|
||||
padding: 6px 4px;
|
||||
}
|
||||
|
||||
.admin-tab.active {
|
||||
font-weight: 700;
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
/* Auth screens (login, errors, refusal). */
|
||||
|
||||
.admin-login,
|
||||
.admin-error,
|
||||
.admin-refusal,
|
||||
.admin-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-height: 100%;
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-login-button {
|
||||
margin-top: 8px;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--nq-a-accent, #2563eb);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-error,
|
||||
.admin-refusal {
|
||||
color: var(--nq-a-ink, inherit);
|
||||
}
|
||||
|
||||
.admin-refusal h1,
|
||||
.admin-error h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.admin-refusal p,
|
||||
.admin-error p {
|
||||
margin: 0;
|
||||
max-width: 34em;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import App from "./App";
|
||||
|
||||
describe("NestQuest Admin shell", () => {
|
||||
beforeEach(() => {
|
||||
// The Today tab fetches the admin snapshot; keep the shell tests offline.
|
||||
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders the title and all four tab labels", () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy();
|
||||
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
|
||||
expect(screen.getByRole("tab", { name: label })).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("starts on the Today screen and switches on tab click", () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "History" }));
|
||||
|
||||
expect(screen.getByRole("tabpanel").textContent).toContain(
|
||||
"History placeholder",
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: "History" }).getAttribute("aria-selected")).toBe(
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("the Tasks tab renders the definitions screen", () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Tasks" }));
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy();
|
||||
expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from "react";
|
||||
import "./App.css";
|
||||
import DefinitionsTab from "./definitions/DefinitionsTab";
|
||||
import TodayTab from "./today/TodayTab";
|
||||
|
||||
const TABS = [
|
||||
{ id: "today", label: "Today" },
|
||||
{ id: "tasks", label: "Tasks" },
|
||||
{ id: "schedule", label: "Schedule" },
|
||||
{ id: "history", label: "History" },
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof TABS)[number]["id"];
|
||||
|
||||
export default function App() {
|
||||
const [active, setActive] = useState<TabId>("today");
|
||||
// Built screens own their header (ADMIN-SPEC §1); the generic shell header
|
||||
// stays for placeholder tabs and is visually hidden otherwise.
|
||||
const hasScreen = active === "today" || active === "tasks";
|
||||
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<header className={hasScreen ? "admin-header visually-hidden" : "admin-header"}>
|
||||
<h1 className="admin-title">NestQuest Admin</h1>
|
||||
<p className="admin-subline">Admin console</p>
|
||||
</header>
|
||||
<main
|
||||
className={hasScreen ? "admin-panel admin-panel--screen" : "admin-panel"}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`tab-${active}`}
|
||||
>
|
||||
{active === "today" ? (
|
||||
<TodayTab />
|
||||
) : active === "tasks" ? (
|
||||
<DefinitionsTab />
|
||||
) : (
|
||||
<p className="admin-placeholder">
|
||||
{TABS.find((tab) => tab.id === active)?.label} placeholder — screens
|
||||
arrive in later tasks.
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
<nav className="admin-tabbar" aria-label="Admin sections">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`tab-${tab.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab.id === active}
|
||||
className={tab.id === active ? "admin-tab active" : "admin-tab"}
|
||||
onClick={() => setActive(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import AppRoutes from "./AppRoutes";
|
||||
import { storeTokens } from "./auth/oidc";
|
||||
import { config } from "./config";
|
||||
|
||||
// The bundled config reads real env at import time; seed the shared module's
|
||||
// clientId directly so AppRoutes sees a configured app in these tests.
|
||||
(config as { clientId: string | undefined }).clientId = "test-client-id";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("AppRoutes auth gating", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
sessionStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
it("renders the login screen when unauthenticated at /", () => {
|
||||
render(<AppRoutes />);
|
||||
expect(screen.getByTestId("login-screen")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /sign in with authentik/i }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the four-tab shell after a successful authenticated probe", async () => {
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<AppRoutes />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy();
|
||||
});
|
||||
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
|
||||
expect(screen.getByRole("tab", { name: label })).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the explicit refusal when the API answers 403", async () => {
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<AppRoutes />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("refusal-screen")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText(/not in the nestquest-admins group/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the configuration error screen when the client id is missing", () => {
|
||||
const original = config.clientId;
|
||||
(config as { clientId: string | undefined }).clientId = undefined;
|
||||
try {
|
||||
render(<AppRoutes />);
|
||||
expect(screen.getByTestId("config-error-screen")).toBeTruthy();
|
||||
} finally {
|
||||
(config as { clientId: string | undefined }).clientId = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Minimal path-based routing without react-router-dom: the app only needs two
|
||||
* paths (`/` shell and `/auth/callback`), so a tiny window.location switch is
|
||||
* enough and keeps the bundle small. Change screens listen to popstate.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { ConfigError, config } from "./config";
|
||||
import {
|
||||
AuthError,
|
||||
buildAuthorizeUrl,
|
||||
clearAuth,
|
||||
handleCallback,
|
||||
isAuthenticated,
|
||||
} from "./auth/oidc";
|
||||
import { ApiForbiddenError, apiFetch } from "./api/client";
|
||||
import AdminShell from "./App";
|
||||
|
||||
type Screen =
|
||||
| { kind: "loading" }
|
||||
| { kind: "login" }
|
||||
| { kind: "config-error"; message: string }
|
||||
| { kind: "auth-error"; message: string }
|
||||
| { kind: "refusal"; message: string }
|
||||
| { kind: "app" };
|
||||
|
||||
function currentPath(): string {
|
||||
return window.location.pathname.replace(/\/+$/, "") || "/";
|
||||
}
|
||||
|
||||
async function probeApi(setScreen: (screen: Screen) => void) {
|
||||
// A cheap authenticated probe so a 403 surfaces as an explicit refusal
|
||||
// right after login instead of a silent empty shell.
|
||||
try {
|
||||
await apiFetch("/api/v1/children", {}, config);
|
||||
setScreen({ kind: "app" });
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) {
|
||||
setScreen({ kind: "config-error", message: error.message });
|
||||
return;
|
||||
}
|
||||
if (error instanceof ApiForbiddenError) {
|
||||
setScreen({ kind: "refusal", message: error.message });
|
||||
return;
|
||||
}
|
||||
// Network or other failures still allow the shell to render; API screens
|
||||
// handle their own errors in later tasks.
|
||||
setScreen({ kind: "app" });
|
||||
}
|
||||
}
|
||||
|
||||
function LoginScreen({ onLogin }: { onLogin: () => void }) {
|
||||
return (
|
||||
<div className="admin-login" data-testid="login-screen">
|
||||
<h1 className="admin-title">NestQuest Admin</h1>
|
||||
<p className="admin-subline">Sign in to continue</p>
|
||||
<button type="button" className="admin-login-button" onClick={onLogin}>
|
||||
Sign in with Authentik
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppRoutes() {
|
||||
const [screen, setScreen] = useState<Screen>({ kind: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
// Re-evaluate on navigation (popstate) — routing is path based.
|
||||
const onPop = () => setScreen({ kind: "loading" });
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const path = currentPath();
|
||||
|
||||
if (!config.clientId) {
|
||||
setScreen({
|
||||
kind: "config-error",
|
||||
message:
|
||||
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/auth/callback") {
|
||||
(async () => {
|
||||
try {
|
||||
await handleCallback(window.location.search, config);
|
||||
if (cancelled) return;
|
||||
window.history.replaceState({}, "", "/");
|
||||
await probeApi((next) => !cancelled && setScreen(next));
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
const message =
|
||||
error instanceof AuthError || error instanceof Error
|
||||
? error.message
|
||||
: "Authentication failed.";
|
||||
setScreen({ kind: "auth-error", message });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
if (isAuthenticated()) {
|
||||
void probeApi((next) => !cancelled && setScreen(next));
|
||||
} else {
|
||||
setScreen({ kind: "login" });
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const startLogin = async () => {
|
||||
try {
|
||||
const url = await buildAuthorizeUrl(config);
|
||||
window.location.assign(url);
|
||||
} catch (error) {
|
||||
setScreen({
|
||||
kind: "config-error",
|
||||
message: error instanceof Error ? error.message : "Login failed.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
switch (screen.kind) {
|
||||
case "loading":
|
||||
return (
|
||||
<div className="admin-loading" role="status" data-testid="loading-screen">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
case "login":
|
||||
return <LoginScreen onLogin={startLogin} />;
|
||||
case "config-error":
|
||||
return (
|
||||
<div className="admin-error" role="alert" data-testid="config-error-screen">
|
||||
<h1>Configuration error</h1>
|
||||
<p>{screen.message}</p>
|
||||
</div>
|
||||
);
|
||||
case "auth-error":
|
||||
return (
|
||||
<div className="admin-error" role="alert" data-testid="auth-error-screen">
|
||||
<h1>Sign-in failed</h1>
|
||||
<p>{screen.message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearAuth();
|
||||
window.history.replaceState({}, "", "/");
|
||||
setScreen({ kind: "loading" });
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
case "refusal":
|
||||
return (
|
||||
<div className="admin-refusal" role="alert" data-testid="refusal-screen">
|
||||
<h1>Access refused</h1>
|
||||
<p>{screen.message}</p>
|
||||
<button type="button" onClick={() => { clearAuth(); window.history.replaceState({}, "", "/"); setScreen({ kind: "login" }); }}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
case "app":
|
||||
return <AdminShell />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ApiForbiddenError,
|
||||
ApiUnauthorizedError,
|
||||
apiFetch,
|
||||
} from "./client";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
import type { AppConfig } from "../config";
|
||||
|
||||
const app: AppConfig = {
|
||||
issuer: "https://auth.example.com/application/o/nestquest",
|
||||
clientId: "test-client-id",
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
|
||||
tokenEndpoint: "https://auth.example.com/application/o/token/",
|
||||
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
|
||||
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
|
||||
redirectUri: "http://localhost:5173/auth/callback",
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("API client", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("attaches the Bearer token to API calls", async () => {
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
|
||||
|
||||
await apiFetch("/api/v1/children", {}, app, fetchMock as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://api.example.com/api/v1/children");
|
||||
expect(init.headers.Authorization).toBe("Bearer at-123");
|
||||
});
|
||||
|
||||
it("throws ApiForbiddenError on 403 (authenticated but refused)", async () => {
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 }));
|
||||
|
||||
await expect(
|
||||
apiFetch("/api/v1/children", {}, app, fetchMock as any),
|
||||
).rejects.toBeInstanceOf(ApiForbiddenError);
|
||||
});
|
||||
|
||||
it("triggers a login redirect on 401 with no refresh token", async () => {
|
||||
storeTokens({ access_token: "stale", expires_in: 600 });
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal("location", { ...window.location, assign });
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 401 }));
|
||||
|
||||
await expect(
|
||||
apiFetch("/api/v1/children", {}, app, fetchMock as any),
|
||||
).rejects.toBeInstanceOf(ApiUnauthorizedError);
|
||||
expect(assign).toHaveBeenCalledTimes(1);
|
||||
const url = new URL(assign.mock.calls[0][0] as string);
|
||||
expect(url.origin + url.pathname).toBe(
|
||||
"https://auth.example.com/application/o/authorize/",
|
||||
);
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
});
|
||||
|
||||
it("preserves non-401/403 statuses on the post-refresh retry (500 is not mislabeled)", async () => {
|
||||
storeTokens({ access_token: "stale", refresh_token: "rt-1", expires_in: 600 });
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("", { status: 401 })) // initial API call
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ access_token: "fresh", expires_in: 600 }), // refresh grant
|
||||
)
|
||||
.mockResolvedValueOnce(new Response("", { status: 500 })); // retry
|
||||
|
||||
const response = await apiFetch("/api/v1/children", {}, app, fetchMock as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(app.tokenEndpoint);
|
||||
expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe("Bearer fresh");
|
||||
// A server failure must surface as the real status, not ApiUnauthorizedError.
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Minimal fetch wrapper for the NestQuest API.
|
||||
*
|
||||
* - Attaches `Authorization: Bearer <access_token>` when a token is stored.
|
||||
* - 401: not authenticated (or token expired) -> trigger a new login.
|
||||
* - 403: authenticated but refused -> throws ApiForbiddenError so the UI can
|
||||
* render an explicit refusal message.
|
||||
*/
|
||||
import { config, type AppConfig } from "../config";
|
||||
import {
|
||||
buildAuthorizeUrl,
|
||||
getToken,
|
||||
isExpired,
|
||||
refreshAccessToken,
|
||||
} from "../auth/oidc";
|
||||
|
||||
export class ApiForbiddenError extends Error {
|
||||
constructor(message = "Your account is not in the nestquest-admins group.") {
|
||||
super(message);
|
||||
this.name = "ApiForbiddenError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiUnauthorizedError extends Error {
|
||||
constructor(message = "Not authenticated.") {
|
||||
super(message);
|
||||
this.name = "ApiUnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiRequestOptions extends Omit<RequestInit, "headers"> {
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect the browser to Authentik to start a new login.
|
||||
* Exported for testing injection.
|
||||
*/
|
||||
export async function triggerLogin(app: AppConfig = config): Promise<void> {
|
||||
const url = await buildAuthorizeUrl(app);
|
||||
window.location.assign(url);
|
||||
}
|
||||
|
||||
export async function apiFetch(
|
||||
path: string,
|
||||
options: ApiRequestOptions = {},
|
||||
app: AppConfig = config,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<Response> {
|
||||
if (isExpired() && (await refreshAccessToken(app, fetchImpl))) {
|
||||
// token renewed; fall through and use the fresh one
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
const headers = {
|
||||
...(options.headers ?? {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
|
||||
const response = await fetchImpl(`${app.apiBaseUrl}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
// Not authenticated: either no token or an unusable one.
|
||||
if (await refreshAccessToken(app, fetchImpl)) {
|
||||
const retryToken = getToken();
|
||||
const retryResponse = await fetchImpl(`${app.apiBaseUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers ?? {}),
|
||||
...(retryToken ? { Authorization: `Bearer ${retryToken}` } : {}),
|
||||
},
|
||||
});
|
||||
if (retryResponse.status === 403) throw new ApiForbiddenError();
|
||||
if (retryResponse.ok) return retryResponse;
|
||||
// Only 401 means "unauthorized"; preserve every other status (e.g. a
|
||||
// 500 from the API) so callers can react to the real response.
|
||||
if (retryResponse.status === 401) throw new ApiUnauthorizedError();
|
||||
return retryResponse;
|
||||
}
|
||||
await triggerLogin(app);
|
||||
throw new ApiUnauthorizedError();
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
// Authenticated, but Authentik's token does not belong to nestquest-admins.
|
||||
throw new ApiForbiddenError();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Typed client for the admin quest-definition routes and the children list
|
||||
* the assignee picker reads (/api/v1/admin/quest-definitions, /children).
|
||||
*/
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type WindowName = "morning" | "afternoon" | "evening";
|
||||
|
||||
/** Model rule names, as the admin plane serializes ScheduleRule.to_dict. */
|
||||
export type RuleType =
|
||||
| "daily"
|
||||
| "weekly"
|
||||
| "monthly_day"
|
||||
| "monthly_weekday"
|
||||
| "yearly"
|
||||
| "custom_days";
|
||||
|
||||
export interface DefinitionRule {
|
||||
rule_type: RuleType;
|
||||
interval: number;
|
||||
/** 0 = Monday .. 6 = Sunday. */
|
||||
weekday_set: number[] | null;
|
||||
day_of_month: number | null;
|
||||
nth_weekday: number | null;
|
||||
nth_weekday_weekday: number | null;
|
||||
month: number | null;
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
}
|
||||
|
||||
export interface DefinitionAssignee {
|
||||
id: number;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface DefinitionWindow {
|
||||
window: WindowName;
|
||||
/** Local wall-clock "HH:MM", or null. */
|
||||
due_time: string | null;
|
||||
}
|
||||
|
||||
export interface QuestDefinition {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
is_active: boolean;
|
||||
skip_on_away: boolean;
|
||||
rule: DefinitionRule;
|
||||
assignees: DefinitionAssignee[];
|
||||
windows: DefinitionWindow[];
|
||||
}
|
||||
|
||||
export interface AdminChild {
|
||||
id: number;
|
||||
display_name: string;
|
||||
colour: string | null;
|
||||
avatar_ref: string | null;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
/** A window declaration as the create/edit bodies accept it. */
|
||||
export type WindowEntry = WindowName | [WindowName, string | null];
|
||||
|
||||
export interface DefinitionCreateBody {
|
||||
title: string;
|
||||
rule: DefinitionRule;
|
||||
assignee_child_ids: number[];
|
||||
windows: WindowEntry[];
|
||||
description?: string | null;
|
||||
icon?: string | null;
|
||||
skip_on_away?: boolean;
|
||||
}
|
||||
|
||||
export type DefinitionEditBody = Partial<DefinitionCreateBody>;
|
||||
|
||||
export const DEFINITIONS_PATH = "/api/v1/admin/quest-definitions";
|
||||
export const CHILDREN_PATH = "/api/v1/admin/children";
|
||||
|
||||
/** A non-2xx save response; `detail` is the API's message when it sent one. */
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly detail: string | null,
|
||||
) {
|
||||
super(detail ?? `Request failed (HTTP ${status}).`);
|
||||
this.name = "ApiRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
/** FastAPI sends `detail` as a string (core errors) or a list of {msg} (body validation). */
|
||||
function readDetail(body: unknown): string | null {
|
||||
if (!body || typeof body !== "object" || !("detail" in body)) return null;
|
||||
const { detail } = body as { detail: unknown };
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
const messages = detail
|
||||
.map((item) => (item && typeof item === "object" ? (item as { msg?: unknown }).msg : null))
|
||||
.filter((msg): msg is string => typeof msg === "string");
|
||||
return messages.length > 0 ? messages.join("; ") : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
// Non-JSON error body: report the status alone.
|
||||
}
|
||||
throw new ApiRequestError(response.status, readDetail(body));
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function fetchDefinitions(): Promise<QuestDefinition[]> {
|
||||
const body = await readJson<{ definitions: QuestDefinition[] }>(
|
||||
await apiFetch(DEFINITIONS_PATH),
|
||||
);
|
||||
return body.definitions;
|
||||
}
|
||||
|
||||
export async function fetchChildren(): Promise<AdminChild[]> {
|
||||
const body = await readJson<{ children: AdminChild[] }>(await apiFetch(CHILDREN_PATH));
|
||||
return body.children;
|
||||
}
|
||||
|
||||
function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise<Response> {
|
||||
return apiFetch(path, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createDefinition(body: DefinitionCreateBody): Promise<QuestDefinition> {
|
||||
return readJson<QuestDefinition>(await sendJson(DEFINITIONS_PATH, "POST", body));
|
||||
}
|
||||
|
||||
export async function updateDefinition(
|
||||
id: number,
|
||||
body: DefinitionEditBody,
|
||||
): Promise<QuestDefinition> {
|
||||
return readJson<QuestDefinition>(await sendJson(`${DEFINITIONS_PATH}/${id}`, "PATCH", body));
|
||||
}
|
||||
|
||||
export const OCCURRENCES_PREVIEW_PATH = `${DEFINITIONS_PATH}/occurrences-preview`;
|
||||
|
||||
export interface OccurrencesPreviewOptions {
|
||||
/** "YYYY-MM-DD"; the API defaults to its host's today. */
|
||||
startDate?: string;
|
||||
/** 1..50; the API defaults to 10. */
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The next occurrence dates ("YYYY-MM-DD", ascending) for `rule`, computed by
|
||||
* the same backend recurrence engine the materializer uses.
|
||||
*/
|
||||
export async function previewOccurrences(
|
||||
rule: DefinitionRule,
|
||||
options: OccurrencesPreviewOptions = {},
|
||||
): Promise<string[]> {
|
||||
const body: { rule: DefinitionRule; start_date?: string; count?: number } = { rule };
|
||||
if (options.startDate !== undefined) body.start_date = options.startDate;
|
||||
if (options.count !== undefined) body.count = options.count;
|
||||
const response = await readJson<{ dates: string[] }>(
|
||||
await sendJson(OCCURRENCES_PREVIEW_PATH, "POST", body),
|
||||
);
|
||||
return response.dates;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Typed client for GET /api/v1/admin/snapshot — today's household snapshot
|
||||
* that drives the admin Today tab.
|
||||
*/
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type InstanceWindow = "morning" | "afternoon" | "evening";
|
||||
export type InstanceState = "open" | "completed" | "missed";
|
||||
|
||||
export interface SnapshotInstance {
|
||||
id: number;
|
||||
definition_id: number;
|
||||
child_id: number;
|
||||
title: string;
|
||||
icon: string | null;
|
||||
window: InstanceWindow;
|
||||
/** Local wall-clock "HH:MM", or null when the definition has no due time. */
|
||||
due_time: string | null;
|
||||
state: InstanceState;
|
||||
overdue: boolean;
|
||||
/** UTC ISO timestamp of the completion event, when completed. */
|
||||
completed_at: string | null;
|
||||
on_time: boolean | null;
|
||||
}
|
||||
|
||||
export interface SnapshotChild {
|
||||
child_id: number;
|
||||
child_name: string;
|
||||
present: boolean;
|
||||
/** "YYYY-MM-DD" of the next present day, or null. */
|
||||
next_present: string | null;
|
||||
due_today: number;
|
||||
completed_today: number;
|
||||
remaining_today: number;
|
||||
completion_pct: number;
|
||||
instances: SnapshotInstance[];
|
||||
}
|
||||
|
||||
export interface AdminSnapshot {
|
||||
/** "YYYY-MM-DD" in the household's local zone. */
|
||||
today_iso: string;
|
||||
/** 1-based day in the custody cycle. */
|
||||
cycle_day: number;
|
||||
children: SnapshotChild[];
|
||||
}
|
||||
|
||||
export const SNAPSHOT_PATH = "/api/v1/admin/snapshot";
|
||||
|
||||
export async function fetchSnapshot(): Promise<AdminSnapshot> {
|
||||
const response = await apiFetch(SNAPSHOT_PATH);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Snapshot request failed (HTTP ${response.status}).`);
|
||||
}
|
||||
return (await response.json()) as AdminSnapshot;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildAuthorizeUrl,
|
||||
clearAuth,
|
||||
getToken,
|
||||
handleCallback,
|
||||
logout,
|
||||
storeTokens,
|
||||
} from "./oidc";
|
||||
import type { AppConfig } from "../config";
|
||||
|
||||
function testApp(): AppConfig {
|
||||
return {
|
||||
issuer: "https://auth.example.com/application/o/nestquest",
|
||||
clientId: "test-client-id",
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
|
||||
tokenEndpoint: "https://auth.example.com/application/o/token/",
|
||||
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
|
||||
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
|
||||
redirectUri: "http://localhost:5173/auth/callback",
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("OIDC flow", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("buildAuthorizeUrl carries all required params and stores state/verifier", async () => {
|
||||
const app = testApp();
|
||||
const url = new URL(await buildAuthorizeUrl(app));
|
||||
|
||||
expect(url.origin + url.pathname).toBe(app.authorizeEndpoint);
|
||||
expect(url.searchParams.get("response_type")).toBe("code");
|
||||
expect(url.searchParams.get("client_id")).toBe("test-client-id");
|
||||
expect(url.searchParams.get("redirect_uri")).toBe(app.redirectUri);
|
||||
expect(url.searchParams.get("scope")).toBe("openid email profile");
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(url.searchParams.get("state")).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
|
||||
expect(sessionStorage.getItem("nestquest.oidc_state")).toBe(
|
||||
url.searchParams.get("state"),
|
||||
);
|
||||
expect(sessionStorage.getItem("nestquest.code_verifier")).toMatch(/^[A-Za-z0-9-._~]{43,128}$/);
|
||||
});
|
||||
|
||||
it("handleCallback rejects a mismatched state", async () => {
|
||||
await buildAuthorizeUrl(testApp());
|
||||
sessionStorage.setItem("nestquest.oidc_state", "different-state");
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
await expect(
|
||||
handleCallback("?code=abc&state=tampered", testApp(), fetchMock as any),
|
||||
).rejects.toThrow(/state mismatch/i);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(getToken()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handleCallback exchanges the code with the correct form body and stores the token", async () => {
|
||||
await buildAuthorizeUrl(testApp());
|
||||
const verifier = sessionStorage.getItem("nestquest.code_verifier")!;
|
||||
const state = sessionStorage.getItem("nestquest.oidc_state")!;
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
access_token: "at-123",
|
||||
refresh_token: "rt-456",
|
||||
id_token: "id-789",
|
||||
expires_in: 300,
|
||||
}),
|
||||
);
|
||||
await handleCallback(`?code=abc&state=${state}`, testApp(), fetchMock as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://auth.example.com/application/o/token/");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
|
||||
|
||||
const body = new URLSearchParams(init.body);
|
||||
expect(body.get("grant_type")).toBe("authorization_code");
|
||||
expect(body.get("code")).toBe("abc");
|
||||
expect(body.get("redirect_uri")).toBe(testApp().redirectUri);
|
||||
expect(body.get("client_id")).toBe("test-client-id");
|
||||
expect(body.get("code_verifier")).toBe(verifier);
|
||||
expect(body.has("client_secret")).toBe(false);
|
||||
|
||||
expect(getToken()).toBe("at-123");
|
||||
// one-time values consumed
|
||||
expect(sessionStorage.getItem("nestquest.oidc_state")).toBeNull();
|
||||
expect(sessionStorage.getItem("nestquest.code_verifier")).toBeNull();
|
||||
});
|
||||
|
||||
it("handleCallback surfaces provider errors", async () => {
|
||||
await expect(
|
||||
handleCallback("?error=access_denied&error_description=nope", testApp(), vi.fn() as any),
|
||||
).rejects.toThrow(/nope|access_denied/);
|
||||
});
|
||||
|
||||
it("clearAuth removes all stored auth data", () => {
|
||||
storeTokens({ access_token: "a", refresh_token: "r", expires_in: 60 });
|
||||
clearAuth();
|
||||
expect(getToken()).toBeUndefined();
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
});
|
||||
|
||||
it("logout clears auth and redirects to the end-session endpoint", async () => {
|
||||
storeTokens({ access_token: "a", id_token: "idt", expires_in: 60 });
|
||||
const assign = vi.fn();
|
||||
const original = window.location;
|
||||
vi.stubGlobal("location", { ...original, assign, origin: "http://localhost:5173" });
|
||||
|
||||
await logout(testApp());
|
||||
|
||||
expect(assign).toHaveBeenCalledTimes(1);
|
||||
const url = new URL(assign.mock.calls[0][0] as string);
|
||||
expect(url.toString()).toBe(
|
||||
"https://auth.example.com/application/o/nestquest/end-session/?id_token_hint=idt&client_id=test-client-id&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A5173",
|
||||
);
|
||||
expect(getToken()).toBeUndefined();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("buildAuthorizeUrl resolves the authorize endpoint via discovery for a non-default issuer", async () => {
|
||||
const issuer = "https://sso.discovery.example/application/o/discovered";
|
||||
const discoveryDoc = {
|
||||
issuer,
|
||||
authorization_endpoint: "https://sso.discovery.example/application/o/authorize/",
|
||||
token_endpoint: "https://sso.discovery.example/application/o/token/",
|
||||
userinfo_endpoint: "https://sso.discovery.example/application/o/userinfo/",
|
||||
end_session_endpoint: `${issuer}/end-session/`,
|
||||
jwks_uri: `${issuer}/jwks/`,
|
||||
};
|
||||
const fetchMock = vi.fn().mockImplementation((url: string) =>
|
||||
Promise.resolve(
|
||||
String(url).endsWith("/.well-known/openid-configuration")
|
||||
? jsonResponse(discoveryDoc)
|
||||
: jsonResponse({}),
|
||||
),
|
||||
);
|
||||
const app: AppConfig = {
|
||||
issuer,
|
||||
clientId: "test-client-id",
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
redirectUri: "http://localhost:5173/auth/callback",
|
||||
};
|
||||
|
||||
const url = new URL(await buildAuthorizeUrl(app, fetchMock as any));
|
||||
|
||||
// The authorize endpoint comes from the discovery document — Authentik
|
||||
// serves it outside the app-specific issuer path, so it is NOT
|
||||
// `${issuer}/authorize/` and must never be a hardcoded production host.
|
||||
expect(url.origin + url.pathname).toBe(discoveryDoc.authorization_endpoint);
|
||||
expect(url.searchParams.get("client_id")).toBe("test-client-id");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
||||
`${issuer}/.well-known/openid-configuration`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* OIDC authorization-code + PKCE flow for a PUBLIC client.
|
||||
*
|
||||
* Never sends a client_secret anywhere. Tokens are stored in sessionStorage
|
||||
* (per-tab, cleared on tab close) — never localStorage.
|
||||
*/
|
||||
import { config, resolveEndpoints, type AppConfig, type ResolvedAppConfig } from "../config";
|
||||
import {
|
||||
computeCodeChallenge,
|
||||
generateCodeVerifier,
|
||||
generateState,
|
||||
} from "./pkce";
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
accessToken: "nestquest.access_token",
|
||||
refreshToken: "nestquest.refresh_token",
|
||||
expiresAt: "nestquest.expires_at",
|
||||
idToken: "nestquest.id_token",
|
||||
state: "nestquest.oidc_state",
|
||||
codeVerifier: "nestquest.code_verifier",
|
||||
} as const;
|
||||
|
||||
export class AuthError extends Error {}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
expires_in?: number;
|
||||
token_type?: string;
|
||||
}
|
||||
|
||||
function makeScopes(): string {
|
||||
return "openid email profile";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Authentik authorization redirect URL, storing state + verifier in
|
||||
* sessionStorage so the callback can validate them. Endpoints are resolved
|
||||
* from the issuer's OIDC discovery document when not set on the config.
|
||||
*/
|
||||
export async function buildAuthorizeUrl(
|
||||
app: AppConfig = config,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<string> {
|
||||
if (!app.clientId) {
|
||||
throw new AuthError(
|
||||
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
|
||||
);
|
||||
}
|
||||
const resolved = await resolveEndpoints(app, fetchImpl);
|
||||
const state = generateState();
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await computeCodeChallenge(codeVerifier);
|
||||
|
||||
sessionStorage.setItem(STORAGE_KEYS.state, state);
|
||||
sessionStorage.setItem(STORAGE_KEYS.codeVerifier, codeVerifier);
|
||||
|
||||
const url = new URL(resolved.authorizeEndpoint);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("client_id", app.clientId);
|
||||
url.searchParams.set("redirect_uri", app.redirectUri);
|
||||
url.searchParams.set("scope", makeScopes());
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("code_challenge", codeChallenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the OAuth callback: validate state, exchange the code at the token
|
||||
* endpoint (PKCE, form-urlencoded, no client_secret), store tokens.
|
||||
*/
|
||||
export async function handleCallback(
|
||||
query: URLSearchParams | string = window.location.search,
|
||||
app: AppConfig = config,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const params =
|
||||
typeof query === "string" ? new URLSearchParams(query) : query;
|
||||
|
||||
const providerError = params.get("error");
|
||||
if (providerError) {
|
||||
throw new AuthError(
|
||||
params.get("error_description") || `Provider returned error: ${providerError}`,
|
||||
);
|
||||
}
|
||||
|
||||
const code = params.get("code");
|
||||
const state = params.get("state");
|
||||
if (!code || !state) {
|
||||
throw new AuthError("Callback is missing the code or state parameter.");
|
||||
}
|
||||
|
||||
const storedState = sessionStorage.getItem(STORAGE_KEYS.state);
|
||||
const codeVerifier = sessionStorage.getItem(STORAGE_KEYS.codeVerifier);
|
||||
if (!storedState || storedState !== state || !codeVerifier) {
|
||||
throw new AuthError("State mismatch — possible CSRF; refusing to log in.");
|
||||
}
|
||||
|
||||
if (!app.clientId) {
|
||||
throw new AuthError(
|
||||
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
|
||||
);
|
||||
}
|
||||
|
||||
const resolved: ResolvedAppConfig = await resolveEndpoints(app, fetchImpl);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: app.redirectUri,
|
||||
client_id: app.clientId,
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
|
||||
const response = await fetchImpl(resolved.tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new AuthError(`Token exchange failed with HTTP ${response.status}`);
|
||||
}
|
||||
const tokens = (await response.json()) as TokenResponse;
|
||||
if (!tokens.access_token) {
|
||||
throw new AuthError("Token response did not include an access token.");
|
||||
}
|
||||
|
||||
storeTokens(tokens);
|
||||
// One-time use values are consumed on success.
|
||||
sessionStorage.removeItem(STORAGE_KEYS.state);
|
||||
sessionStorage.removeItem(STORAGE_KEYS.codeVerifier);
|
||||
}
|
||||
|
||||
export function storeTokens(tokens: TokenResponse): void {
|
||||
sessionStorage.setItem(STORAGE_KEYS.accessToken, tokens.access_token!);
|
||||
if (tokens.refresh_token) {
|
||||
sessionStorage.setItem(STORAGE_KEYS.refreshToken, tokens.refresh_token);
|
||||
}
|
||||
if (tokens.id_token) {
|
||||
sessionStorage.setItem(STORAGE_KEYS.idToken, tokens.id_token);
|
||||
}
|
||||
if (typeof tokens.expires_in === "number") {
|
||||
sessionStorage.setItem(
|
||||
STORAGE_KEYS.expiresAt,
|
||||
String(Date.now() + tokens.expires_in * 1000),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken(): string | undefined {
|
||||
return sessionStorage.getItem(STORAGE_KEYS.accessToken) ?? undefined;
|
||||
}
|
||||
|
||||
export function getRefreshToken(): string | undefined {
|
||||
return sessionStorage.getItem(STORAGE_KEYS.refreshToken) ?? undefined;
|
||||
}
|
||||
|
||||
export function getIdToken(): string | undefined {
|
||||
return sessionStorage.getItem(STORAGE_KEYS.idToken) ?? undefined;
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return Boolean(getToken());
|
||||
}
|
||||
|
||||
/** True once the stored access token has passed its (approximate) expiry. */
|
||||
export function isExpired(): boolean {
|
||||
const expiresAt = sessionStorage.getItem(STORAGE_KEYS.expiresAt);
|
||||
if (!expiresAt) return false;
|
||||
return Date.now() >= Number(expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the access token with the refresh token grant. Returns false when
|
||||
* no refresh token is stored or the grant fails (caller should re-login).
|
||||
*/
|
||||
export async function refreshAccessToken(
|
||||
app: AppConfig = config,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<boolean> {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken || !app.clientId) return false;
|
||||
|
||||
const resolved = await resolveEndpoints(app, fetchImpl);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: app.clientId,
|
||||
});
|
||||
|
||||
const response = await fetchImpl(resolved.tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const tokens = (await response.json()) as TokenResponse;
|
||||
if (!tokens.access_token) return false;
|
||||
storeTokens(tokens);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clearAuth(): void {
|
||||
for (const key of Object.values(STORAGE_KEYS)) {
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sign out: clear local auth, then send the browser to the end-session endpoint. */
|
||||
export async function logout(
|
||||
app: AppConfig = config,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const resolved = await resolveEndpoints(app, fetchImpl);
|
||||
const idTokenHint = getIdToken();
|
||||
clearAuth();
|
||||
const params = new URLSearchParams();
|
||||
if (idTokenHint) params.set("id_token_hint", idTokenHint);
|
||||
if (app.clientId) params.set("client_id", app.clientId);
|
||||
params.set("post_logout_redirect_uri", window.location.origin);
|
||||
window.location.assign(`${resolved.endSessionEndpoint}?${params.toString()}`);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
base64UrlEncode,
|
||||
computeCodeChallenge,
|
||||
generateCodeVerifier,
|
||||
generateState,
|
||||
isLikelyValidVerifier,
|
||||
} from "./pkce";
|
||||
|
||||
describe("PKCE helpers", () => {
|
||||
it("base64UrlEncode has no padding and url-safe characters", () => {
|
||||
// bytes chosen to force "+" and "/" and padding in plain base64
|
||||
const bytes = new Uint8Array([0xfb, 0xff, 0xbf, 0xff]);
|
||||
const encoded = base64UrlEncode(bytes);
|
||||
expect(encoded).not.toMatch(/[+/=]/);
|
||||
});
|
||||
|
||||
it("computes the RFC 7636 appendix-B S256 vector", async () => {
|
||||
const verifier =
|
||||
"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
|
||||
const challenge = await computeCodeChallenge(verifier);
|
||||
expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
|
||||
});
|
||||
|
||||
it("generates verifiers with valid charset and length", () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const verifier = generateCodeVerifier();
|
||||
expect(isLikelyValidVerifier(verifier)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects verifiers outside 43-128 chars or with reserved chars", () => {
|
||||
expect(isLikelyValidVerifier("short")).toBe(false);
|
||||
expect(isLikelyValidVerifier("a".repeat(129))).toBe(false);
|
||||
expect(isLikelyValidVerifier("a".repeat(43) + "+")).toBe(false);
|
||||
});
|
||||
|
||||
it("generates distinct random states", () => {
|
||||
expect(generateState()).not.toBe(generateState());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* PKCE (RFC 7636) helpers for a public SPA client.
|
||||
*
|
||||
* Uses the Web Crypto API, which requires a SECURE CONTEXT: the app must be
|
||||
* served over https or http://localhost, otherwise crypto.subtle is
|
||||
* unavailable and login cannot work.
|
||||
*/
|
||||
|
||||
const VERIFIER_CHARSET =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
||||
|
||||
/** base64url-encode bytes with NO padding. */
|
||||
export function base64UrlEncode(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function randomBase64Url(byteLength: number): string {
|
||||
const bytes = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(bytes);
|
||||
return base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Random code_verifier: 43-128 chars, unreserved charset only.
|
||||
* 32 random bytes -> 43 base64url chars, which is the minimum allowed length.
|
||||
*/
|
||||
export function generateCodeVerifier(): string {
|
||||
return randomBase64Url(32);
|
||||
}
|
||||
|
||||
/** S256 code_challenge = base64url(SHA-256(verifier)), no padding. */
|
||||
export async function computeCodeChallenge(verifier: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(verifier),
|
||||
);
|
||||
return base64UrlEncode(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
/** Random opaque state value for CSRF protection. */
|
||||
export function generateState(): string {
|
||||
return randomBase64Url(16);
|
||||
}
|
||||
|
||||
export function isLikelyValidVerifier(verifier: string): boolean {
|
||||
return (
|
||||
verifier.length >= 43 &&
|
||||
verifier.length <= 128 &&
|
||||
[...verifier].every((char) => VERIFIER_CHARSET.includes(char))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ConfigError,
|
||||
discoveryUrlForIssuer,
|
||||
readConfig,
|
||||
resolveEndpoints,
|
||||
resolveEndpointsFromDiscovery,
|
||||
type AppConfig,
|
||||
} from "./config";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Mirrors Authentik's layout: authorize/token/userinfo live OUTSIDE the
|
||||
// application-specific issuer path, end-session/jwks under it.
|
||||
function discoveryDocument(issuer: string) {
|
||||
const normalized = issuer.replace(/\/+$/, "");
|
||||
const origin = new URL(normalized).origin;
|
||||
return {
|
||||
issuer,
|
||||
authorization_endpoint: `${origin}/application/o/authorize/`,
|
||||
token_endpoint: `${origin}/application/o/token/`,
|
||||
userinfo_endpoint: `${origin}/application/o/userinfo/`,
|
||||
end_session_endpoint: `${normalized}/end-session/`,
|
||||
jwks_uri: `${normalized}/jwks/`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("config", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("readConfig keeps the production issuer as the default", () => {
|
||||
const cfg = readConfig({});
|
||||
expect(cfg.issuer).toBe("https://auth.cubecraftlabs.com/application/o/nestquest");
|
||||
expect(cfg.clientId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("readConfig normalizes a trailing slash off a custom issuer", () => {
|
||||
expect(
|
||||
readConfig({ VITE_AUTHENTIK_ISSUER: "https://sso.custom.example/application/o/custom/" })
|
||||
.issuer,
|
||||
).toBe("https://sso.custom.example/application/o/custom");
|
||||
});
|
||||
|
||||
it("readConfig hardcodes no OIDC endpoints — they come from discovery", () => {
|
||||
const cfg = readConfig({});
|
||||
expect(cfg.authorizeEndpoint).toBeUndefined();
|
||||
expect(cfg.tokenEndpoint).toBeUndefined();
|
||||
expect(cfg.userinfoEndpoint).toBeUndefined();
|
||||
expect(cfg.endSessionEndpoint).toBeUndefined();
|
||||
expect(cfg.jwksUri).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds the discovery URL for issuers with and without a trailing slash", () => {
|
||||
expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app/")).toBe(
|
||||
"https://sso.example.com/application/o/app/.well-known/openid-configuration",
|
||||
);
|
||||
expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app")).toBe(
|
||||
"https://sso.example.com/application/o/app/.well-known/openid-configuration",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves every endpoint from the discovery document of a non-default issuer", async () => {
|
||||
const issuer = "https://sso.other-host.example/application/o/other-app";
|
||||
const doc = discoveryDocument(issuer);
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
|
||||
const app: AppConfig = {
|
||||
issuer,
|
||||
clientId: "client-id",
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
redirectUri: "http://localhost:5173/auth/callback",
|
||||
};
|
||||
|
||||
const resolved = await resolveEndpoints(app, fetchMock as unknown as typeof fetch);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://sso.other-host.example/application/o/other-app/.well-known/openid-configuration",
|
||||
);
|
||||
// Every endpoint comes verbatim from the (non-default) discovery document.
|
||||
expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint);
|
||||
expect(resolved.tokenEndpoint).toBe(doc.token_endpoint);
|
||||
expect(resolved.userinfoEndpoint).toBe(doc.userinfo_endpoint);
|
||||
expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint);
|
||||
expect(resolved.jwksUri).toBe(doc.jwks_uri);
|
||||
// Proves no hardcoded production host leaked into the resolved endpoints.
|
||||
for (const endpoint of [
|
||||
resolved.authorizeEndpoint,
|
||||
resolved.tokenEndpoint,
|
||||
resolved.userinfoEndpoint,
|
||||
resolved.endSessionEndpoint,
|
||||
resolved.jwksUri,
|
||||
]) {
|
||||
expect(endpoint).not.toMatch(/cubecraftlabs/);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves discovery for an issuer configured with a trailing slash", async () => {
|
||||
const issuer = "https://sso.slashy.example/application/o/slashy/";
|
||||
const doc = discoveryDocument(issuer);
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
|
||||
|
||||
const resolved = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://sso.slashy.example/application/o/slashy/.well-known/openid-configuration",
|
||||
);
|
||||
expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint);
|
||||
expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint);
|
||||
});
|
||||
|
||||
it("caches discovery per issuer for the session", async () => {
|
||||
const issuer = "https://sso.cached.example/application/o/cached";
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer)));
|
||||
|
||||
const first = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
|
||||
const second = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("surfaces a clear configuration error when discovery returns an HTTP error", async () => {
|
||||
const issuer = "https://sso.http-error.example/application/o/broken";
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
|
||||
|
||||
const error = await resolveEndpointsFromDiscovery(issuer, fetchMock as any).then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigError);
|
||||
expect((error as Error).message).toContain(issuer);
|
||||
expect((error as Error).message).toContain("HTTP 500");
|
||||
});
|
||||
|
||||
it("surfaces a clear configuration error when discovery is unreachable", async () => {
|
||||
const issuer = "https://sso.unreachable.example/application/o/unreachable";
|
||||
const fetchMock = vi.fn().mockRejectedValue(new TypeError("network down"));
|
||||
|
||||
await expect(
|
||||
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
|
||||
).rejects.toBeInstanceOf(ConfigError);
|
||||
});
|
||||
|
||||
it("surfaces a clear configuration error when the discovery document is incomplete", async () => {
|
||||
const issuer = "https://sso.incomplete.example/application/o/incomplete";
|
||||
const doc = discoveryDocument(issuer) as Record<string, string>;
|
||||
delete doc.end_session_endpoint;
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
|
||||
|
||||
await expect(
|
||||
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
|
||||
).rejects.toThrow(/end_session_endpoint/);
|
||||
});
|
||||
|
||||
it("rejects a discovery document advertising a different issuer", async () => {
|
||||
const issuer = "https://sso.mismatch.example/application/o/mismatch";
|
||||
const doc = {
|
||||
...discoveryDocument(issuer),
|
||||
issuer: "https://sso.elsewhere.example/application/o/elsewhere",
|
||||
};
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
|
||||
|
||||
await expect(
|
||||
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
|
||||
).rejects.toThrow(/issuer mismatch/i);
|
||||
});
|
||||
|
||||
it("the bundled config resolves its endpoints from discovery at runtime", async () => {
|
||||
const issuer = "https://sso.bundled.example/application/o/bundled";
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer)));
|
||||
const bundled = readConfig({ VITE_AUTHENTIK_ISSUER: `${issuer}/`, VITE_AUTHENTIK_CLIENT_ID: "bundled-client" });
|
||||
expect(bundled.authorizeEndpoint).toBeUndefined();
|
||||
|
||||
const resolved = await resolveEndpoints(bundled, fetchMock as any);
|
||||
|
||||
expect(resolved.authorizeEndpoint).toBe(
|
||||
"https://sso.bundled.example/application/o/authorize/",
|
||||
);
|
||||
expect(resolved.tokenEndpoint).toBe("https://sso.bundled.example/application/o/token/");
|
||||
expect(resolved.userinfoEndpoint).toBe(
|
||||
"https://sso.bundled.example/application/o/userinfo/",
|
||||
);
|
||||
expect(resolved.endSessionEndpoint).toBe(
|
||||
"https://sso.bundled.example/application/o/bundled/end-session/",
|
||||
);
|
||||
expect(resolved.jwksUri).toBe("https://sso.bundled.example/application/o/bundled/jwks/");
|
||||
});
|
||||
|
||||
it("does not re-fetch discovery when the config already carries endpoints", async () => {
|
||||
const app: AppConfig = {
|
||||
issuer: "https://auth.example.com/application/o/nestquest",
|
||||
clientId: "client-id",
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
|
||||
tokenEndpoint: "https://auth.example.com/application/o/token/",
|
||||
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
|
||||
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
|
||||
redirectUri: "http://localhost:5173/auth/callback",
|
||||
};
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
const resolved = await resolveEndpoints(app, fetchMock as any);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(resolved).toBe(app);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Runtime configuration, read from Vite env (import.meta.env).
|
||||
* See .env.example for the variables and their defaults.
|
||||
*
|
||||
* OIDC endpoints are NOT hardcoded here. They are resolved lazily from the
|
||||
* issuer's OIDC discovery document (see resolveEndpoints), because Authentik
|
||||
* serves authorize/token/userinfo OUTSIDE the application-specific issuer
|
||||
* path (issuer is /application/o/<slug>/, authorize is /application/o/authorize/).
|
||||
* Both hardcoding the production host and naively concatenating onto the
|
||||
* issuer would break non-default issuers; discovery is the only correct way.
|
||||
*/
|
||||
|
||||
export interface AppConfig {
|
||||
issuer: string;
|
||||
clientId: string | undefined;
|
||||
apiBaseUrl: string;
|
||||
/** Resolved from the issuer's OIDC discovery document — see resolveEndpoints(). */
|
||||
authorizeEndpoint?: string;
|
||||
tokenEndpoint?: string;
|
||||
userinfoEndpoint?: string;
|
||||
endSessionEndpoint?: string;
|
||||
jwksUri?: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
/** AppConfig whose OIDC endpoints have been filled in by resolveEndpoints(). */
|
||||
export type ResolvedAppConfig = AppConfig &
|
||||
Required<
|
||||
Pick<
|
||||
AppConfig,
|
||||
"authorizeEndpoint" | "tokenEndpoint" | "userinfoEndpoint" | "endSessionEndpoint"
|
||||
>
|
||||
>;
|
||||
|
||||
export interface DiscoveredEndpoints {
|
||||
issuer: string;
|
||||
authorizeEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
userinfoEndpoint: string;
|
||||
endSessionEndpoint: string;
|
||||
jwksUri: string;
|
||||
}
|
||||
|
||||
/** Raised when OIDC discovery fails or the configuration is unusable. */
|
||||
export class ConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ConfigError";
|
||||
}
|
||||
}
|
||||
|
||||
const REQUIRED_ENDPOINT_NAMES = [
|
||||
"authorizeEndpoint",
|
||||
"tokenEndpoint",
|
||||
"userinfoEndpoint",
|
||||
"endSessionEndpoint",
|
||||
] as const;
|
||||
|
||||
export function readConfig(env: Record<string, string | undefined> = import.meta.env as any): AppConfig {
|
||||
const issuer = (
|
||||
env.VITE_AUTHENTIK_ISSUER || "https://auth.cubecraftlabs.com/application/o/nestquest/"
|
||||
).replace(/\/$/, "");
|
||||
const clientId = env.VITE_AUTHENTIK_CLIENT_ID;
|
||||
const apiBaseUrl = env.VITE_API_BASE_URL || "https://nestquest.cubecraftlabs.com";
|
||||
|
||||
return {
|
||||
issuer,
|
||||
// No default: a missing client id must surface as a configuration error
|
||||
// screen, not a broken redirect to Authentik.
|
||||
clientId: clientId || undefined,
|
||||
apiBaseUrl,
|
||||
// authorize/token/userinfo/end-session endpoints are resolved from the
|
||||
// issuer's discovery document at runtime — never hardcoded here.
|
||||
redirectUri: `${window.location.origin}/auth/callback`,
|
||||
};
|
||||
}
|
||||
|
||||
export const config: AppConfig = readConfig();
|
||||
|
||||
interface DiscoveryDocument {
|
||||
issuer?: unknown;
|
||||
authorization_endpoint?: unknown;
|
||||
token_endpoint?: unknown;
|
||||
userinfo_endpoint?: unknown;
|
||||
end_session_endpoint?: unknown;
|
||||
jwks_uri?: unknown;
|
||||
}
|
||||
|
||||
function normalizeIssuer(issuer: string): string {
|
||||
return issuer.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/** Discovery document location for an issuer, tolerant of a trailing slash. */
|
||||
export function discoveryUrlForIssuer(issuer: string): string {
|
||||
return `${normalizeIssuer(issuer)}/.well-known/openid-configuration`;
|
||||
}
|
||||
|
||||
async function fetchDiscoveredEndpoints(
|
||||
issuer: string,
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<DiscoveredEndpoints> {
|
||||
const normalizedIssuer = normalizeIssuer(issuer);
|
||||
const discoveryUrl = `${normalizedIssuer}/.well-known/openid-configuration`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(discoveryUrl);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new ConfigError(
|
||||
`OIDC discovery request to ${discoveryUrl} failed (${reason}). ` +
|
||||
"Check VITE_AUTHENTIK_ISSUER and network reachability.",
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ConfigError(
|
||||
`OIDC discovery failed: ${discoveryUrl} returned HTTP ${response.status}. ` +
|
||||
"Check VITE_AUTHENTIK_ISSUER.",
|
||||
);
|
||||
}
|
||||
|
||||
let doc: DiscoveryDocument;
|
||||
try {
|
||||
doc = (await response.json()) as DiscoveryDocument;
|
||||
} catch {
|
||||
throw new ConfigError(
|
||||
`OIDC discovery document at ${discoveryUrl} is not valid JSON. Check VITE_AUTHENTIK_ISSUER.`,
|
||||
);
|
||||
}
|
||||
|
||||
const authorizationEndpoint = nonEmptyString(doc.authorization_endpoint);
|
||||
const tokenEndpoint = nonEmptyString(doc.token_endpoint);
|
||||
const userinfoEndpoint = nonEmptyString(doc.userinfo_endpoint);
|
||||
const endSessionEndpoint = nonEmptyString(doc.end_session_endpoint);
|
||||
const jwksUri = nonEmptyString(doc.jwks_uri);
|
||||
const discoveredIssuer = nonEmptyString(doc.issuer);
|
||||
|
||||
const endpointFields: Array<[string, string | undefined]> = [
|
||||
["authorization_endpoint", authorizationEndpoint],
|
||||
["token_endpoint", tokenEndpoint],
|
||||
["userinfo_endpoint", userinfoEndpoint],
|
||||
["end_session_endpoint", endSessionEndpoint],
|
||||
["jwks_uri", jwksUri],
|
||||
];
|
||||
const missing = endpointFields.filter(([, value]) => !value).map(([name]) => name);
|
||||
if (missing.length > 0) {
|
||||
throw new ConfigError(
|
||||
`OIDC discovery document at ${discoveryUrl} is missing: ${missing.join(", ")}. ` +
|
||||
"The issuer must point at an OIDC provider that publishes these endpoints.",
|
||||
);
|
||||
}
|
||||
|
||||
if (discoveredIssuer && normalizeIssuer(discoveredIssuer) !== normalizedIssuer) {
|
||||
throw new ConfigError(
|
||||
`OIDC discovery issuer mismatch: ${discoveryUrl} advertises "${discoveredIssuer}" ` +
|
||||
`but VITE_AUTHENTIK_ISSUER is "${normalizedIssuer}".`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
issuer: normalizedIssuer,
|
||||
authorizeEndpoint: authorizationEndpoint!,
|
||||
tokenEndpoint: tokenEndpoint!,
|
||||
userinfoEndpoint: userinfoEndpoint!,
|
||||
endSessionEndpoint: endSessionEndpoint!,
|
||||
jwksUri: jwksUri!,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Session cache: one discovery fetch per issuer per SPA session. Failed
|
||||
* lookups are evicted so a transient network error can be retried instead of
|
||||
* poisoning the whole session.
|
||||
*/
|
||||
const discoveryCache = new Map<string, Promise<DiscoveredEndpoints>>();
|
||||
|
||||
export function resolveEndpointsFromDiscovery(
|
||||
issuer: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<DiscoveredEndpoints> {
|
||||
const normalizedIssuer = normalizeIssuer(issuer);
|
||||
const cached = discoveryCache.get(normalizedIssuer);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = fetchDiscoveredEndpoints(normalizedIssuer, fetchImpl);
|
||||
discoveryCache.set(normalizedIssuer, pending);
|
||||
pending.catch(() => {
|
||||
discoveryCache.delete(normalizedIssuer);
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `app` with all OIDC endpoints filled in. When the config already
|
||||
* carries them (pre-resolved config, tests) this is a no-op; otherwise the
|
||||
* issuer's discovery document is fetched (cached for the session) and its
|
||||
* authorization/token/userinfo/end-session/jwks endpoints are used.
|
||||
*/
|
||||
export async function resolveEndpoints(
|
||||
app: AppConfig,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ResolvedAppConfig> {
|
||||
const missing = REQUIRED_ENDPOINT_NAMES.filter((name) => !app[name]);
|
||||
if (missing.length === 0) {
|
||||
return app as ResolvedAppConfig;
|
||||
}
|
||||
|
||||
const discovered = await resolveEndpointsFromDiscovery(app.issuer, fetchImpl);
|
||||
return {
|
||||
...app,
|
||||
authorizeEndpoint: discovered.authorizeEndpoint,
|
||||
tokenEndpoint: discovered.tokenEndpoint,
|
||||
userinfoEndpoint: discovered.userinfoEndpoint,
|
||||
endSessionEndpoint: discovered.endSessionEndpoint,
|
||||
jwksUri: discovered.jwksUri,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
/* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2).
|
||||
Mobile-first at the 390x844 reference; the column is capped and centred wider. */
|
||||
|
||||
.defs-screen {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* §3.1 header */
|
||||
.defs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--nq-a-surface);
|
||||
border-bottom: 1px solid var(--nq-a-border);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.defs-title {
|
||||
margin: 0;
|
||||
font-size: var(--nq-a-size-screen);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.defs-sub {
|
||||
margin: 2px 0 0;
|
||||
min-height: 1.4em;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-new {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-new:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
|
||||
.defs-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nq-a-density-gap);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
/* Filter chips */
|
||||
.defs-chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.defs-chip {
|
||||
flex: none;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-chip--active {
|
||||
border-color: #18181d;
|
||||
background: #18181d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Rows */
|
||||
.defs-card {
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.defs-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.defs-list > li + li {
|
||||
border-top: 1px solid var(--nq-a-divider);
|
||||
}
|
||||
|
||||
.defs-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: var(--nq-a-density-row-pad);
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-row-glyph {
|
||||
flex: none;
|
||||
display: flex;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-row-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.defs-row-title {
|
||||
font-size: var(--nq-a-size-row);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-row-meta {
|
||||
font-size: var(--nq-a-size-meta);
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-row-chevron {
|
||||
flex: none;
|
||||
display: flex;
|
||||
color: var(--nq-a-ink-tertiary);
|
||||
}
|
||||
|
||||
/* An inactive definition renders the whole row tertiary (never hard-deleted). */
|
||||
.defs-row--inactive,
|
||||
.defs-row--inactive .defs-row-glyph,
|
||||
.defs-row--inactive .defs-row-meta {
|
||||
color: var(--nq-a-ink-tertiary);
|
||||
}
|
||||
|
||||
.defs-row--editing {
|
||||
background: var(--nq-a-selected);
|
||||
}
|
||||
|
||||
.defs-row--editing .defs-row-glyph {
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
|
||||
.defs-empty {
|
||||
padding: var(--nq-a-density-card-pad);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
/* Loading / error */
|
||||
.defs-status {
|
||||
padding: 24px 13px;
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
text-align: center;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-status--error {
|
||||
color: var(--nq-a-danger-strong);
|
||||
}
|
||||
|
||||
.defs-status--error p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.defs-retry {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* §3.2 edit sheet */
|
||||
.defs-sheet-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.defs-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(9, 9, 11, 0.4);
|
||||
animation: defs-fade var(--nq-dur-modal) var(--nq-ease-out);
|
||||
}
|
||||
|
||||
.defs-sheet {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 92%;
|
||||
margin: 0 auto;
|
||||
overflow-y: auto;
|
||||
padding: 14px 18px 24px;
|
||||
border-radius: var(--nq-a-radius-sheet);
|
||||
background: var(--nq-a-surface);
|
||||
box-shadow: var(--nq-a-sheet-shadow);
|
||||
animation: defs-rise var(--nq-dur-modal) var(--nq-ease-out);
|
||||
}
|
||||
|
||||
@keyframes defs-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes defs-rise {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.defs-scrim,
|
||||
.defs-sheet {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.defs-grabber {
|
||||
width: 38px;
|
||||
height: 4px;
|
||||
margin: 0 auto 12px;
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
background: var(--nq-a-border);
|
||||
}
|
||||
|
||||
.defs-sheet-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.defs-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.defs-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.defs-label {
|
||||
font-size: var(--nq-a-size-label);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--nq-a-track-label);
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-input {
|
||||
width: 100%;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: 0 11px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.defs-select-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: 0 11px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-picker {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
}
|
||||
|
||||
.defs-picker > li + li {
|
||||
border-top: 1px solid var(--nq-a-divider);
|
||||
}
|
||||
|
||||
.defs-picker-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: 0 11px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-picker-check {
|
||||
display: flex;
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
|
||||
.defs-picker-option--locked {
|
||||
color: var(--nq-a-ink-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.defs-picker-note {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.defs-repeats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.defs-interval {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-interval .defs-input {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.defs-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.defs-day,
|
||||
.defs-segment {
|
||||
min-height: var(--nq-a-tap-min);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-day--on,
|
||||
.defs-segment--on {
|
||||
border-color: transparent;
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.defs-segmented {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.defs-due-times {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.defs-due {
|
||||
display: grid;
|
||||
grid-template-columns: 84px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.defs-toggle-title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-toggle-sub {
|
||||
font-size: var(--nq-a-size-meta);
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-switch {
|
||||
flex: none;
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
background: var(--nq-a-border);
|
||||
cursor: pointer;
|
||||
transition: background var(--nq-dur-base) var(--nq-ease-out);
|
||||
}
|
||||
|
||||
.defs-switch--on {
|
||||
background: var(--nq-brand-gradient);
|
||||
}
|
||||
|
||||
.defs-switch-knob {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(9, 9, 11, 0.25);
|
||||
transition: transform var(--nq-dur-base) var(--nq-ease-out);
|
||||
}
|
||||
|
||||
.defs-switch--on .defs-switch-knob {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.defs-sheet-error {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-danger-bg);
|
||||
color: var(--nq-a-danger-strong);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-sheet-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.defs-confirm {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-danger-bg);
|
||||
color: var(--nq-a-danger-strong);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-confirm p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.defs-confirm .defs-sheet-footer {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.defs-cancel,
|
||||
.defs-save {
|
||||
padding: 13px 0;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-cancel {
|
||||
flex: 1;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
}
|
||||
|
||||
.defs-save {
|
||||
flex: 2;
|
||||
border: 0;
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.defs-cancel:disabled,
|
||||
.defs-save:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.defs-preview {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.defs-preview--error {
|
||||
color: var(--nq-a-ink-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -0,0 +1,920 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import DefinitionsTab from "./DefinitionsTab";
|
||||
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
|
||||
function child(id: number, name: string, overrides: Partial<AdminChild> = {}): AdminChild {
|
||||
return {
|
||||
id,
|
||||
display_name: name,
|
||||
colour: null,
|
||||
avatar_ref: null,
|
||||
sort_order: id,
|
||||
is_active: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const CHILDREN: AdminChild[] = [
|
||||
child(1, "Declan"),
|
||||
child(2, "Maeve"),
|
||||
child(3, "Rory"),
|
||||
child(4, "Old", { is_active: false }),
|
||||
];
|
||||
|
||||
function rule(overrides: Partial<DefinitionRule> = {}): DefinitionRule {
|
||||
return {
|
||||
rule_type: "daily",
|
||||
interval: 1,
|
||||
weekday_set: null,
|
||||
day_of_month: null,
|
||||
nth_weekday: null,
|
||||
nth_weekday_weekday: null,
|
||||
month: null,
|
||||
start_date: "2026-01-01",
|
||||
end_date: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const BRUSH: QuestDefinition = {
|
||||
id: 10,
|
||||
title: "Brush teeth",
|
||||
description: null,
|
||||
icon: "smile",
|
||||
is_active: true,
|
||||
skip_on_away: true,
|
||||
rule: rule(),
|
||||
assignees: [
|
||||
{ id: 1, display_name: "Declan" },
|
||||
{ id: 2, display_name: "Maeve" },
|
||||
{ id: 3, display_name: "Rory" },
|
||||
],
|
||||
windows: [
|
||||
{ window: "morning", due_time: "07:30" },
|
||||
{ window: "evening", due_time: null },
|
||||
],
|
||||
};
|
||||
|
||||
const BINS: QuestDefinition = {
|
||||
id: 11,
|
||||
title: "Take out bins",
|
||||
description: "Green bin",
|
||||
icon: null,
|
||||
is_active: true,
|
||||
skip_on_away: false,
|
||||
rule: rule({ rule_type: "weekly", interval: 2, weekday_set: [0, 3] }),
|
||||
assignees: [
|
||||
{ id: 1, display_name: "Declan" },
|
||||
{ id: 4, display_name: "Old" },
|
||||
],
|
||||
windows: [{ window: "afternoon", due_time: "16:00" }],
|
||||
};
|
||||
|
||||
const PIANO: QuestDefinition = {
|
||||
id: 12,
|
||||
title: "Piano practice",
|
||||
description: null,
|
||||
icon: null,
|
||||
is_active: false,
|
||||
skip_on_away: true,
|
||||
rule: rule({ rule_type: "monthly_day", day_of_month: 15 }),
|
||||
assignees: [{ id: 2, display_name: "Maeve" }],
|
||||
windows: [{ window: "afternoon", due_time: null }],
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
interface Call {
|
||||
method: string;
|
||||
path: string;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
const PREVIEW_PATH = "/api/v1/admin/quest-definitions/occurrences-preview";
|
||||
|
||||
/** Answers an occurrence-preview request; the default serves no dates. */
|
||||
type PreviewHandler = (body: { rule: DefinitionRule; count?: number }) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* A stub of the admin API: serves the list and children, records writes, and
|
||||
* lets a test queue a response for the next write. Occurrence previews are
|
||||
* reads (recorded, never counted as writes) answered by `setPreview`.
|
||||
*/
|
||||
function fakeApi(initial: QuestDefinition[]) {
|
||||
let definitions = [...initial];
|
||||
const calls: Call[] = [];
|
||||
let nextWrite: Response | null = null;
|
||||
let preview: PreviewHandler = async () => jsonResponse({ dates: [] });
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||
const path = new URL(String(input), "http://localhost").pathname;
|
||||
const method = init.method ?? "GET";
|
||||
const body = typeof init.body === "string" ? JSON.parse(init.body) : null;
|
||||
calls.push({ method, path, body });
|
||||
if (method === "POST" && path === PREVIEW_PATH) return preview(body);
|
||||
if (method === "GET" && path === "/api/v1/admin/quest-definitions") {
|
||||
return jsonResponse({ definitions });
|
||||
}
|
||||
if (method === "GET" && path === "/api/v1/admin/children") {
|
||||
return jsonResponse({ children: CHILDREN });
|
||||
}
|
||||
if (nextWrite) {
|
||||
const response = nextWrite;
|
||||
nextWrite = null;
|
||||
return response;
|
||||
}
|
||||
if (method === "POST" && path === "/api/v1/admin/quest-definitions") {
|
||||
const created: QuestDefinition = {
|
||||
id: 99,
|
||||
title: body.title,
|
||||
description: null,
|
||||
icon: null,
|
||||
is_active: true,
|
||||
skip_on_away: body.skip_on_away,
|
||||
rule: body.rule,
|
||||
assignees: CHILDREN.filter((c) => body.assignee_child_ids.includes(c.id)).map((c) => ({
|
||||
id: c.id,
|
||||
display_name: c.display_name,
|
||||
})),
|
||||
windows: body.windows.map(([window, due_time]: [string, string | null]) => ({
|
||||
window,
|
||||
due_time,
|
||||
})),
|
||||
};
|
||||
definitions = [...definitions, created];
|
||||
return jsonResponse(created, 201);
|
||||
}
|
||||
const match = /^\/api\/v1\/admin\/quest-definitions\/(\d+)$/.exec(path);
|
||||
if (method === "PATCH" && match) {
|
||||
const id = Number(match[1]);
|
||||
definitions = definitions.map((d) =>
|
||||
d.id === id
|
||||
? {
|
||||
...d,
|
||||
title: body.title ?? d.title,
|
||||
skip_on_away: body.skip_on_away ?? d.skip_on_away,
|
||||
rule: body.rule ?? d.rule,
|
||||
// Like the API: a sent list replaces the whole set; an absent one keeps it.
|
||||
assignees:
|
||||
"assignee_child_ids" in body
|
||||
? CHILDREN.filter((c) => body.assignee_child_ids.includes(c.id)).map((c) => ({
|
||||
id: c.id,
|
||||
display_name: c.display_name,
|
||||
}))
|
||||
: d.assignees,
|
||||
}
|
||||
: d,
|
||||
);
|
||||
return jsonResponse(definitions.find((d) => d.id === id));
|
||||
}
|
||||
return jsonResponse({ detail: "unexpected" }, 500);
|
||||
});
|
||||
return {
|
||||
fetchMock,
|
||||
calls,
|
||||
writes: () => calls.filter((c) => c.method !== "GET" && c.path !== PREVIEW_PATH),
|
||||
previews: () => calls.filter((c) => c.path === PREVIEW_PATH),
|
||||
setPreview: (handler: PreviewHandler) => {
|
||||
preview = handler;
|
||||
},
|
||||
listFetches: () =>
|
||||
calls.filter((c) => c.method === "GET" && c.path === "/api/v1/admin/quest-definitions")
|
||||
.length,
|
||||
failNextWrite: (response: Response) => {
|
||||
nextWrite = response;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let api: ReturnType<typeof fakeApi>;
|
||||
|
||||
async function renderReady(initial: QuestDefinition[] = [BRUSH, BINS, PIANO]) {
|
||||
api = fakeApi(initial);
|
||||
vi.stubGlobal("fetch", api.fetchMock);
|
||||
render(<DefinitionsTab />);
|
||||
await screen.findByText("Brush teeth");
|
||||
}
|
||||
|
||||
function meta(row: HTMLElement): string {
|
||||
return (row.querySelector(".defs-row-meta") as HTMLElement).textContent ?? "";
|
||||
}
|
||||
|
||||
function sheet(): HTMLElement {
|
||||
return screen.getByRole("dialog");
|
||||
}
|
||||
|
||||
describe("DefinitionsTab", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("header counts definitions and active children", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy();
|
||||
expect(screen.getByText("3 definitions · 3 children")).toBeTruthy();
|
||||
const [url, init] = api.fetchMock.mock.calls[0];
|
||||
expect(String(url)).toMatch(/\/api\/v1\/admin\/quest-definitions$/);
|
||||
expect((init as RequestInit & { headers: Record<string, string> }).headers.Authorization).toBe(
|
||||
"Bearer at-123",
|
||||
);
|
||||
});
|
||||
|
||||
it("rows show `<assignees> · <recurrence> · <windows>` meta", async () => {
|
||||
await renderReady();
|
||||
const brush = screen.getByTestId("definition-10");
|
||||
expect(within(brush).getByText("Brush teeth")).toBeTruthy();
|
||||
expect(meta(brush)).toBe("All three · Daily · Morning, Evening");
|
||||
expect(meta(screen.getByTestId("definition-11"))).toBe(
|
||||
"Declan, Old · Every 2 weeks on Mon, Thu · Afternoon",
|
||||
);
|
||||
expect(brush.className).not.toContain("defs-row--inactive");
|
||||
});
|
||||
|
||||
it("an inactive definition is styled inactive with `· inactive`", async () => {
|
||||
await renderReady();
|
||||
const piano = screen.getByTestId("definition-12");
|
||||
expect(meta(piano)).toBe("Maeve · Monthly on day 15 · Afternoon · inactive");
|
||||
expect(piano.className).toContain("defs-row--inactive");
|
||||
});
|
||||
|
||||
it("filter chips filter by child", async () => {
|
||||
await renderReady();
|
||||
const chips = screen.getByRole("group", { name: "Filter by child" });
|
||||
expect(
|
||||
within(chips)
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.textContent),
|
||||
).toEqual(["All", "Declan", "Maeve", "Rory"]);
|
||||
expect(within(chips).getByRole("button", { name: "All" }).className).toContain(
|
||||
"defs-chip--active",
|
||||
);
|
||||
|
||||
fireEvent.click(within(chips).getByRole("button", { name: "Maeve" }));
|
||||
expect(within(chips).getByRole("button", { name: "Maeve" }).getAttribute("aria-pressed")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(screen.queryByTestId("definition-10")).toBeTruthy();
|
||||
expect(screen.queryByTestId("definition-11")).toBeNull();
|
||||
expect(screen.queryByTestId("definition-12")).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(chips).getByRole("button", { name: "Rory" }));
|
||||
expect(screen.queryByTestId("definition-10")).toBeTruthy();
|
||||
expect(screen.queryByTestId("definition-12")).toBeNull();
|
||||
|
||||
fireEvent.click(within(chips).getByRole("button", { name: "All" }));
|
||||
expect(screen.getAllByRole("listitem")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("New opens the sheet and saving POSTs the exact body, then re-fetches", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date(2026, 8, 23, 9, 0)); // Wed 23 Sep 2026, local
|
||||
await renderReady();
|
||||
expect(api.listFetches()).toBe(1);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
const s = sheet();
|
||||
expect(within(s).getByRole("heading", { name: "New task" })).toBeTruthy();
|
||||
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: " Feed the cat " },
|
||||
});
|
||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
|
||||
expect(within(s).getByRole("button", { name: /Assigned children/ }).textContent).toContain(
|
||||
"Declan, Rory",
|
||||
);
|
||||
|
||||
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
|
||||
target: { value: "weekly" },
|
||||
});
|
||||
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
|
||||
target: { value: "2" },
|
||||
});
|
||||
// Today (Wednesday) is preselected; add Saturday.
|
||||
expect(within(s).getByRole("button", { name: "Wednesday" }).getAttribute("aria-pressed")).toBe(
|
||||
"true",
|
||||
);
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Saturday" }));
|
||||
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Evening" }));
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
|
||||
fireEvent.change(within(s).getByLabelText("Morning due time"), {
|
||||
target: { value: "07:15" },
|
||||
});
|
||||
|
||||
const toggle = within(s).getByRole("switch", { name: "Skip on away days" });
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("true");
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
|
||||
expect(api.writes()).toEqual([
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/admin/quest-definitions",
|
||||
body: {
|
||||
title: "Feed the cat",
|
||||
rule: {
|
||||
rule_type: "weekly",
|
||||
interval: 2,
|
||||
weekday_set: [2, 5],
|
||||
day_of_month: null,
|
||||
nth_weekday: null,
|
||||
nth_weekday_weekday: null,
|
||||
month: null,
|
||||
start_date: "2026-09-23",
|
||||
end_date: null,
|
||||
},
|
||||
assignee_child_ids: [1, 3],
|
||||
windows: [
|
||||
["morning", "07:15"],
|
||||
["evening", null],
|
||||
],
|
||||
skip_on_away: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const created = await screen.findByTestId("definition-99");
|
||||
expect(meta(created)).toBe("Declan, Rory · Every 2 weeks on Wed, Sat · Morning, Evening");
|
||||
expect(api.listFetches()).toBe(2);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(screen.getByText("4 definitions · 3 children")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("tapping a row opens the sheet in editing state and saving PATCHes", async () => {
|
||||
await renderReady();
|
||||
const row = screen.getByTestId("definition-11");
|
||||
fireEvent.click(row);
|
||||
|
||||
// §3.1 editing state on the row.
|
||||
expect(row.className).toContain("defs-row--editing");
|
||||
expect(meta(row)).toBe("Editing");
|
||||
expect(row.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
const s = sheet();
|
||||
expect(within(s).getByRole("heading", { name: "Edit task" })).toBeTruthy();
|
||||
expect((within(s).getByRole("textbox", { name: "Title" }) as HTMLInputElement).value).toBe(
|
||||
"Take out bins",
|
||||
);
|
||||
expect((within(s).getByRole("combobox", { name: "Repeats" }) as HTMLSelectElement).value).toBe(
|
||||
"weekly",
|
||||
);
|
||||
expect((within(s).getByLabelText("Afternoon due time") as HTMLInputElement).value).toBe(
|
||||
"16:00",
|
||||
);
|
||||
// The inactive child "Old" is shown locked, not offered as a checkbox.
|
||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||
expect(within(s).queryByRole("checkbox", { name: "Old" })).toBeNull();
|
||||
const locked = within(s).getByTestId("inactive-assignee-4");
|
||||
expect(locked.textContent).toContain("Old");
|
||||
expect(locked.textContent).toContain("Inactive");
|
||||
expect(locked.getAttribute("aria-disabled")).toBe("true");
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Maeve" }));
|
||||
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Take out the bins" },
|
||||
});
|
||||
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
|
||||
target: { value: "monthly" },
|
||||
});
|
||||
fireEvent.change(within(s).getByRole("spinbutton", { name: "Day of month" }), {
|
||||
target: { value: "3" },
|
||||
});
|
||||
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
|
||||
target: { value: "1" },
|
||||
});
|
||||
fireEvent.change(within(s).getByLabelText("Afternoon due time"), {
|
||||
target: { value: "" },
|
||||
});
|
||||
fireEvent.click(within(s).getByRole("switch", { name: "Skip on away days" }));
|
||||
|
||||
// Changing the selection would drop "Old": confirm before sending.
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Unassign and save" }));
|
||||
});
|
||||
|
||||
expect(api.writes()).toEqual([
|
||||
{
|
||||
method: "PATCH",
|
||||
path: "/api/v1/admin/quest-definitions/11",
|
||||
body: {
|
||||
title: "Take out the bins",
|
||||
rule: {
|
||||
rule_type: "monthly_day",
|
||||
interval: 1,
|
||||
weekday_set: null,
|
||||
day_of_month: 3,
|
||||
nth_weekday: null,
|
||||
nth_weekday_weekday: null,
|
||||
month: null,
|
||||
start_date: "2026-01-01",
|
||||
end_date: null,
|
||||
},
|
||||
assignee_child_ids: [1, 2],
|
||||
windows: [["afternoon", null]],
|
||||
skip_on_away: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
// The icon is never sent, so it is carried through unchanged.
|
||||
expect(api.writes()[0].body).not.toHaveProperty("icon");
|
||||
|
||||
await screen.findByText("Take out the bins");
|
||||
expect(api.listFetches()).toBe(2);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("editing only the title omits assignee_child_ids and keeps an inactive assignee", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
const s = sheet();
|
||||
expect(within(s).getByText(/Also assigned \(inactive\): Old\./)).toBeTruthy();
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Bins out" },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("unassign-confirm")).toBeNull();
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
const body = api.writes()[0].body as Record<string, unknown>;
|
||||
expect(api.writes()[0].method).toBe("PATCH");
|
||||
expect(body.title).toBe("Bins out");
|
||||
expect(body).not.toHaveProperty("assignee_child_ids");
|
||||
|
||||
await screen.findByText("Bins out");
|
||||
expect(meta(screen.getByTestId("definition-11"))).toBe(
|
||||
"Declan, Old · Every 2 weeks on Mon, Thu · Afternoon",
|
||||
);
|
||||
});
|
||||
|
||||
it("changing the active selection with an inactive assignee asks first", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
const s = sheet();
|
||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
|
||||
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
const confirm = within(s).getByTestId("unassign-confirm");
|
||||
expect(confirm.textContent).toContain("Saving will unassign Old.");
|
||||
expect(document.activeElement).toBe(confirm);
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
|
||||
// Going back sends nothing and restores the normal footer.
|
||||
fireEvent.click(within(confirm).getByRole("button", { name: "Go back" }));
|
||||
expect(within(s).queryByTestId("unassign-confirm")).toBeNull();
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Unassign and save" }));
|
||||
});
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect((api.writes()[0].body as { assignee_child_ids: number[] }).assignee_child_ids).toEqual([
|
||||
1, 3,
|
||||
]);
|
||||
});
|
||||
|
||||
it("reverting the selection to the original omits assignee_child_ids without asking", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
const s = sheet();
|
||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
expect(screen.queryByTestId("unassign-confirm")).toBeNull();
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect(api.writes()[0].body).not.toHaveProperty("assignee_child_ids");
|
||||
});
|
||||
|
||||
it("focus moves into the sheet on open and returns to the row on Escape", async () => {
|
||||
await renderReady();
|
||||
const row = screen.getByTestId("definition-10");
|
||||
row.focus();
|
||||
fireEvent.click(row);
|
||||
const s = sheet();
|
||||
expect(document.activeElement).toBe(within(s).getByRole("heading", { name: "Edit task" }));
|
||||
|
||||
fireEvent.keyDown(s, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(document.activeElement).toBe(row);
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("Tab and Shift+Tab wrap within the sheet", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
const s = sheet();
|
||||
const title = within(s).getByRole("textbox", { name: "Title" });
|
||||
const save = within(s).getByRole("button", { name: "Save changes" });
|
||||
|
||||
save.focus();
|
||||
fireEvent.keyDown(s, { key: "Tab" });
|
||||
expect(document.activeElement).toBe(title);
|
||||
|
||||
fireEvent.keyDown(s, { key: "Tab", shiftKey: true });
|
||||
expect(document.activeElement).toBe(save);
|
||||
});
|
||||
|
||||
it("Escape does not close the sheet while saving", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
let release: (response: Response) => void = () => {};
|
||||
api.fetchMock.mockImplementationOnce(
|
||||
() => new Promise<Response>((resolve) => (release = resolve)),
|
||||
);
|
||||
const s = sheet();
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
fireEvent.keyDown(s, { key: "Escape" });
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
release(jsonResponse(BRUSH));
|
||||
});
|
||||
await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("the skip-on-away toggle reflects and persists a false value", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
const s = sheet();
|
||||
const toggle = within(s).getByRole("switch", { name: "Skip on away days" });
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("false");
|
||||
expect(toggle.className).not.toContain("defs-switch--on");
|
||||
expect(within(s).getByText("No instance when the child is absent")).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect((api.writes()[0].body as { skip_on_away: boolean }).skip_on_away).toBe(false);
|
||||
});
|
||||
|
||||
it("a 422 from save shows the API's detail and keeps the sheet open", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
api.failNextWrite(jsonResponse({ detail: "interval must be >= 1, got 0" }, 422));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("sheet-error").textContent).toBe("interval must be >= 1, got 0");
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
expect(api.listFetches()).toBe(1);
|
||||
// Save is usable again after the failure.
|
||||
expect(
|
||||
(within(sheet()).getByRole("button", { name: "Save changes" }) as HTMLButtonElement).disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("a 403 from save shows the admin-group refusal", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
api.failNextWrite(jsonResponse({ detail: "Forbidden" }, 403));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
expect(screen.getByTestId("sheet-error").textContent).toBe(
|
||||
"Your account is not in the nestquest-admins group.",
|
||||
);
|
||||
});
|
||||
|
||||
it("guards against a double submit", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
const save = within(sheet()).getByRole("button", { name: "Save changes" });
|
||||
await act(async () => {
|
||||
fireEvent.click(save);
|
||||
fireEvent.click(save);
|
||||
});
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("an incomplete new task is not submitted", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
|
||||
expect(screen.getByTestId("sheet-error").textContent).toBe("Give the task a title.");
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("Cancel closes the sheet without writing", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
fireEvent.click(within(sheet()).getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(meta(screen.getByTestId("definition-10"))).toBe("All three · Daily · Morning, Evening");
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a 403 on load renders the refusal", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => jsonResponse({ detail: "Forbidden" }, 403)),
|
||||
);
|
||||
render(<DefinitionsTab />);
|
||||
expect((await screen.findByTestId("definitions-error")).textContent).toContain(
|
||||
"Your account is not in the nestquest-admins group.",
|
||||
);
|
||||
});
|
||||
|
||||
it("scroll column carries the 92px bottom padding", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByTestId("definitions-scroll").style.paddingBottom).toBe("92px");
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Occurrence preview ─────────────────────────────────────────────── */
|
||||
|
||||
/** Past the sheet's 300 ms preview debounce. */
|
||||
function settleDebounce(): Promise<void> {
|
||||
return act(() => new Promise<void>((resolve) => setTimeout(resolve, 400)));
|
||||
}
|
||||
|
||||
function previewText(): string | null {
|
||||
return screen.queryByTestId("occurrence-preview")?.textContent ?? null;
|
||||
}
|
||||
|
||||
/** BINS' rule (every 2 weeks on Mon, Thu) from 2026-10-05. */
|
||||
const BINS_DATES = ["2026-10-05", "2026-10-08", "2026-10-19", "2026-10-22", "2026-11-02"];
|
||||
/** The same rule on Tuesdays only. */
|
||||
const TUESDAY_DATES = ["2026-10-06", "2026-10-20", "2026-11-03", "2026-11-17", "2026-12-01"];
|
||||
|
||||
function deferred() {
|
||||
let resolve!: (response: Response) => void;
|
||||
const promise = new Promise<Response>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("DefinitionsTab occurrence preview", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("previews a weekly rule's next dates in plain language from the API", async () => {
|
||||
await renderReady();
|
||||
api.setPreview(async () => jsonResponse({ dates: BINS_DATES }));
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(previewText()).toBe(
|
||||
"Next: Mon 5 Oct · Thu 8 Oct · Mon 19 Oct · Thu 22 Oct · Mon 2 Nov · …",
|
||||
),
|
||||
);
|
||||
expect(api.previews()).toEqual([
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/admin/quest-definitions/occurrences-preview",
|
||||
body: { rule: BINS.rule, count: 5 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-requests once per rule change, debounced, and not for non-rule edits", async () => {
|
||||
await renderReady();
|
||||
api.setPreview(async ({ rule }) =>
|
||||
jsonResponse({ dates: rule.weekday_set?.includes(0) ? BINS_DATES : TUESDAY_DATES }),
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
const s = sheet();
|
||||
await waitFor(() => expect(previewText()).toContain("Mon 5 Oct"));
|
||||
|
||||
// A title edit leaves the rule alone: no new request.
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Bins" },
|
||||
});
|
||||
// Three rule edits in quick succession collapse into one request.
|
||||
const days = within(s).getByRole("group", { name: "Days" });
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Thursday" }));
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(previewText()).toBe(
|
||||
"Next: Tue 6 Oct · Tue 20 Oct · Tue 3 Nov · Tue 17 Nov · Tue 1 Dec · …",
|
||||
),
|
||||
);
|
||||
await settleDebounce();
|
||||
expect(api.previews()).toHaveLength(2);
|
||||
expect(api.previews()[1].body).toEqual({
|
||||
rule: { ...BINS.rule, weekday_set: [1] },
|
||||
count: 5,
|
||||
});
|
||||
|
||||
// An interval change is a rule change too.
|
||||
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
|
||||
target: { value: "1" },
|
||||
});
|
||||
await waitFor(() => expect(api.previews()).toHaveLength(3));
|
||||
expect((api.previews()[2].body as { rule: DefinitionRule }).rule.interval).toBe(1);
|
||||
});
|
||||
|
||||
it("window and assignee edits never request, and a rule edit waits out the debounce", async () => {
|
||||
await renderReady();
|
||||
api.setPreview(async () => jsonResponse({ dates: BINS_DATES }));
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
const s = sheet();
|
||||
await waitFor(() => expect(previewText()).toContain("Mon 5 Oct"));
|
||||
expect(api.previews()).toHaveLength(1);
|
||||
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
// Windows and assignees are not part of the rule.
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
|
||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Maeve" }));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(api.previews()).toHaveLength(1);
|
||||
expect(previewText()).toContain("Mon 5 Oct");
|
||||
|
||||
// A rule edit is not requested until the 300 ms debounce elapses.
|
||||
const days = within(s).getByRole("group", { name: "Days" });
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(299);
|
||||
});
|
||||
expect(api.previews()).toHaveLength(1);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
expect(api.previews()).toHaveLength(2);
|
||||
expect((api.previews()[1].body as { rule: DefinitionRule }).rule.weekday_set).toEqual([
|
||||
0, 1, 3,
|
||||
]);
|
||||
});
|
||||
|
||||
it("a rule edit hides the previous rule's dates until its own response arrives", async () => {
|
||||
await renderReady();
|
||||
const pending: ReturnType<typeof deferred>[] = [];
|
||||
api.setPreview(() => {
|
||||
const next = deferred();
|
||||
pending.push(next);
|
||||
return next.promise;
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
await waitFor(() => expect(pending).toHaveLength(1));
|
||||
await act(async () => {
|
||||
pending[0].resolve(jsonResponse({ dates: BINS_DATES }));
|
||||
});
|
||||
await waitFor(() => expect(previewText()).toContain("Mon 5 Oct"));
|
||||
|
||||
const days = within(sheet()).getByRole("group", { name: "Days" });
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
|
||||
// Within the debounce: the old dates are already gone.
|
||||
expect(previewText()).toBeNull();
|
||||
// Request in flight: still nothing shown for the new rule.
|
||||
await waitFor(() => expect(pending).toHaveLength(2));
|
||||
expect(previewText()).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
pending[1].resolve(jsonResponse({ dates: ["2026-10-08", "2026-10-22"] }));
|
||||
});
|
||||
await waitFor(() => expect(previewText()).toBe("Next: Thu 8 Oct · Thu 22 Oct"));
|
||||
});
|
||||
|
||||
it("a rule edit hides the previous rule's error until its own response arrives", async () => {
|
||||
await renderReady();
|
||||
const pending: ReturnType<typeof deferred>[] = [];
|
||||
api.setPreview(() => {
|
||||
const next = deferred();
|
||||
pending.push(next);
|
||||
return next.promise;
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
await waitFor(() => expect(pending).toHaveLength(1));
|
||||
await act(async () => {
|
||||
pending[0].resolve(jsonResponse({ detail: "rule rejected" }, 422));
|
||||
});
|
||||
await waitFor(() => expect(previewText()).toBe("Upcoming dates unavailable: rule rejected"));
|
||||
|
||||
const days = within(sheet()).getByRole("group", { name: "Days" });
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
|
||||
expect(previewText()).toBeNull();
|
||||
await waitFor(() => expect(pending).toHaveLength(2));
|
||||
expect(previewText()).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
pending[1].resolve(jsonResponse({ dates: ["2026-10-08"] }));
|
||||
});
|
||||
await waitFor(() => expect(previewText()).toBe("Next: Thu 8 Oct"));
|
||||
});
|
||||
|
||||
it("a stale response resolving after a newer one does not overwrite it", async () => {
|
||||
await renderReady();
|
||||
const pending: ReturnType<typeof deferred>[] = [];
|
||||
api.setPreview(() => {
|
||||
const next = deferred();
|
||||
pending.push(next);
|
||||
return next.promise;
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
await waitFor(() => expect(pending).toHaveLength(1));
|
||||
|
||||
const days = within(sheet()).getByRole("group", { name: "Days" });
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Thursday" }));
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
|
||||
await waitFor(() => expect(pending).toHaveLength(2));
|
||||
|
||||
await act(async () => {
|
||||
pending[1].resolve(jsonResponse({ dates: TUESDAY_DATES }));
|
||||
});
|
||||
await waitFor(() => expect(previewText()).toContain("Tue 6 Oct"));
|
||||
await act(async () => {
|
||||
pending[0].resolve(jsonResponse({ dates: BINS_DATES }));
|
||||
});
|
||||
await settleDebounce();
|
||||
expect(previewText()).toBe(
|
||||
"Next: Tue 6 Oct · Tue 20 Oct · Tue 3 Nov · Tue 17 Nov · Tue 1 Dec · …",
|
||||
);
|
||||
});
|
||||
|
||||
it("a failed preview shows a non-blocking message and the sheet still saves", async () => {
|
||||
await renderReady();
|
||||
api.setPreview(async () => jsonResponse({ detail: "rule rejected" }, 422));
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
|
||||
await waitFor(() => expect(previewText()).toBe("Upcoming dates unavailable: rule rejected"));
|
||||
expect(screen.queryByTestId("sheet-error")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect(api.writes()[0].method).toBe("PATCH");
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("an incomplete draft does not call the preview endpoint", async () => {
|
||||
await renderReady();
|
||||
api.setPreview(async () => jsonResponse({ dates: TUESDAY_DATES }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
const s = sheet();
|
||||
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
|
||||
target: { value: "weekly" },
|
||||
});
|
||||
// Clear the default weekday: a weekly rule with no days is incomplete.
|
||||
const days = within(s).getByRole("group", { name: "Days" });
|
||||
for (const day of within(days).getAllByRole("button")) {
|
||||
if (day.getAttribute("aria-pressed") === "true") fireEvent.click(day);
|
||||
}
|
||||
await settleDebounce();
|
||||
expect(api.previews()).toHaveLength(0);
|
||||
expect(previewText()).toBeNull();
|
||||
|
||||
// An unparseable interval is incomplete too.
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
|
||||
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
|
||||
target: { value: "" },
|
||||
});
|
||||
await settleDebounce();
|
||||
expect(api.previews()).toHaveLength(0);
|
||||
|
||||
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
|
||||
target: { value: "2" },
|
||||
});
|
||||
await waitFor(() => expect(previewText()).toContain("Tue 6 Oct"));
|
||||
expect(api.previews()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,935 @@
|
||||
/**
|
||||
* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2).
|
||||
*
|
||||
* The icon picker (§3.3) is a separate task: an existing definition's icon is
|
||||
* carried through unchanged by never sending it.
|
||||
*/
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { ApiForbiddenError } from "../api/client";
|
||||
import {
|
||||
ApiRequestError,
|
||||
createDefinition,
|
||||
fetchChildren,
|
||||
fetchDefinitions,
|
||||
previewOccurrences,
|
||||
updateDefinition,
|
||||
type AdminChild,
|
||||
type DefinitionCreateBody,
|
||||
type DefinitionEditBody,
|
||||
type DefinitionRule,
|
||||
type QuestDefinition,
|
||||
type WindowEntry,
|
||||
type WindowName,
|
||||
} from "../api/definitions";
|
||||
import {
|
||||
MONTH_SHORT,
|
||||
WEEKDAY_SHORT,
|
||||
WINDOW_LABELS,
|
||||
WINDOW_ORDER,
|
||||
assigneeSummary,
|
||||
occurrenceLabel,
|
||||
recurrenceSummary,
|
||||
windowSummary,
|
||||
} from "./format";
|
||||
import {
|
||||
CheckGlyph,
|
||||
ChevronDownGlyph,
|
||||
ChevronRightGlyph,
|
||||
PlusGlyph,
|
||||
TaskGlyph,
|
||||
} from "./glyphs";
|
||||
import "./DefinitionsTab.css";
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
export const SCROLL_BOTTOM_PADDING = "92px";
|
||||
|
||||
type Repeats = "daily" | "weekly" | "monthly" | "yearly" | "custom";
|
||||
|
||||
const REPEATS_OPTIONS: { value: Repeats; label: string }[] = [
|
||||
{ value: "daily", label: "Daily" },
|
||||
{ value: "weekly", label: "Weekly" },
|
||||
{ value: "monthly", label: "Monthly" },
|
||||
{ value: "yearly", label: "Yearly" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
];
|
||||
|
||||
const REPEATS_BY_RULE: Record<DefinitionRule["rule_type"], Repeats> = {
|
||||
daily: "daily",
|
||||
weekly: "weekly",
|
||||
monthly_day: "monthly",
|
||||
monthly_weekday: "monthly",
|
||||
yearly: "yearly",
|
||||
custom_days: "custom",
|
||||
};
|
||||
|
||||
/** How many upcoming dates the edit sheet previews. */
|
||||
const PREVIEW_COUNT = 5;
|
||||
|
||||
/** Quiet period after the last rule edit before the preview is requested. */
|
||||
const PREVIEW_DEBOUNCE_MS = 300;
|
||||
|
||||
const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||
|
||||
interface Draft {
|
||||
title: string;
|
||||
assigneeIds: number[];
|
||||
repeats: Repeats;
|
||||
interval: string;
|
||||
weekdays: number[];
|
||||
dayOfMonth: string;
|
||||
month: string;
|
||||
/** An existing "nth weekday" monthly rule, kept while Repeats stays Monthly. */
|
||||
monthlyWeekday: { nth: number; weekday: number } | null;
|
||||
windows: WindowName[];
|
||||
dueTimes: Record<WindowName, string>;
|
||||
skipOnAway: boolean;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
}
|
||||
|
||||
type LoadState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; definitions: QuestDefinition[]; children: AdminChild[] };
|
||||
|
||||
/** `null` = closed, `"new"` = creating, a definition = editing it. */
|
||||
type SheetTarget = null | "new" | QuestDefinition;
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function localIsoDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
}
|
||||
|
||||
function emptyDueTimes(): Record<WindowName, string> {
|
||||
return { morning: "", afternoon: "", evening: "" };
|
||||
}
|
||||
|
||||
function newDraft(activeChildren: AdminChild[]): Draft {
|
||||
const today = new Date();
|
||||
return {
|
||||
title: "",
|
||||
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
|
||||
repeats: "daily",
|
||||
interval: "1",
|
||||
weekdays: [(today.getDay() + 6) % 7],
|
||||
dayOfMonth: String(today.getDate()),
|
||||
month: String(today.getMonth() + 1),
|
||||
monthlyWeekday: null,
|
||||
windows: [],
|
||||
dueTimes: emptyDueTimes(),
|
||||
// Mirrors the API's create default: no instance on an away day.
|
||||
skipOnAway: true,
|
||||
startDate: localIsoDate(today),
|
||||
endDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): Draft {
|
||||
const { rule } = definition;
|
||||
const today = new Date();
|
||||
const active = new Set(activeChildren.map((child) => child.id));
|
||||
const dueTimes = emptyDueTimes();
|
||||
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
|
||||
return {
|
||||
title: definition.title,
|
||||
// Inactive children cannot be submitted (the API rejects them with 422).
|
||||
assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)),
|
||||
repeats: REPEATS_BY_RULE[rule.rule_type],
|
||||
interval: String(rule.interval),
|
||||
weekdays:
|
||||
rule.rule_type === "weekly" || rule.rule_type === "custom_days"
|
||||
? [...(rule.weekday_set ?? [])]
|
||||
: [(today.getDay() + 6) % 7],
|
||||
dayOfMonth: String(rule.day_of_month ?? today.getDate()),
|
||||
month: String(rule.month ?? today.getMonth() + 1),
|
||||
monthlyWeekday:
|
||||
rule.rule_type === "monthly_weekday"
|
||||
? { nth: rule.nth_weekday ?? 1, weekday: rule.nth_weekday_weekday ?? 0 }
|
||||
: null,
|
||||
windows: WINDOW_ORDER.filter((w) => definition.windows.some((d) => d.window === w)),
|
||||
dueTimes,
|
||||
skipOnAway: definition.skip_on_away,
|
||||
startDate: rule.start_date,
|
||||
endDate: rule.end_date,
|
||||
};
|
||||
}
|
||||
|
||||
function parseWhole(value: string, min: number, max: number): number | null {
|
||||
if (!/^\d+$/.test(value.trim())) return null;
|
||||
const n = Number(value);
|
||||
return n >= min && n <= max ? n : null;
|
||||
}
|
||||
|
||||
/** Build the request rule from the draft, or return a message naming what to fix. */
|
||||
function buildRule(draft: Draft): DefinitionRule | string {
|
||||
const interval = parseWhole(draft.interval, 1, 999);
|
||||
if (interval === null) return "Interval must be a whole number of at least 1.";
|
||||
const rule: DefinitionRule = {
|
||||
rule_type: "daily",
|
||||
interval,
|
||||
weekday_set: null,
|
||||
day_of_month: null,
|
||||
nth_weekday: null,
|
||||
nth_weekday_weekday: null,
|
||||
month: null,
|
||||
start_date: draft.startDate,
|
||||
end_date: draft.endDate,
|
||||
};
|
||||
switch (draft.repeats) {
|
||||
case "daily":
|
||||
return rule;
|
||||
case "weekly":
|
||||
case "custom":
|
||||
if (draft.weekdays.length === 0) return "Pick at least one day.";
|
||||
return {
|
||||
...rule,
|
||||
rule_type: draft.repeats === "weekly" ? "weekly" : "custom_days",
|
||||
weekday_set: [...draft.weekdays].sort((a, b) => a - b),
|
||||
};
|
||||
case "monthly": {
|
||||
if (draft.monthlyWeekday) {
|
||||
return {
|
||||
...rule,
|
||||
rule_type: "monthly_weekday",
|
||||
nth_weekday: draft.monthlyWeekday.nth,
|
||||
nth_weekday_weekday: draft.monthlyWeekday.weekday,
|
||||
};
|
||||
}
|
||||
const day = parseWhole(draft.dayOfMonth, 1, 31);
|
||||
if (day === null) return "Day of month must be 1 to 31.";
|
||||
return { ...rule, rule_type: "monthly_day", day_of_month: day };
|
||||
}
|
||||
case "yearly": {
|
||||
const month = parseWhole(draft.month, 1, 12);
|
||||
const day = parseWhole(draft.dayOfMonth, 1, 31);
|
||||
if (month === null || day === null) return "Pick a month and a day (1 to 31).";
|
||||
return { ...rule, rule_type: "yearly", month, day_of_month: day };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `keepsAssignees`: an edit whose active selection is unchanged sends no
|
||||
* assignee list, so an empty selection is fine when inactive children remain.
|
||||
*/
|
||||
function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody | string {
|
||||
const title = draft.title.trim();
|
||||
if (!title) return "Give the task a title.";
|
||||
if (draft.assigneeIds.length === 0 && !keepsAssignees) return "Assign at least one child.";
|
||||
if (draft.windows.length === 0) return "Pick at least one window.";
|
||||
const rule = buildRule(draft);
|
||||
if (typeof rule === "string") return rule;
|
||||
const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map(
|
||||
(w) => [w, draft.dueTimes[w] || null],
|
||||
);
|
||||
return {
|
||||
title,
|
||||
rule,
|
||||
assignee_child_ids: [...draft.assigneeIds],
|
||||
windows,
|
||||
skip_on_away: draft.skipOnAway,
|
||||
};
|
||||
}
|
||||
|
||||
function saveErrorMessage(error: unknown): string {
|
||||
if (error instanceof ApiForbiddenError) return error.message;
|
||||
if (error instanceof ApiRequestError && error.detail) return error.detail;
|
||||
return "The task could not be saved. Check your connection and try again.";
|
||||
}
|
||||
|
||||
function toggle<T>(list: T[], value: T): T[] {
|
||||
return list.includes(value) ? list.filter((item) => item !== value) : [...list, value];
|
||||
}
|
||||
|
||||
function sameIds(a: number[], b: number[]): boolean {
|
||||
return a.length === b.length && a.every((id) => b.includes(id));
|
||||
}
|
||||
|
||||
const FOCUSABLE =
|
||||
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/* ── List ───────────────────────────────────────────────────────────── */
|
||||
|
||||
function FilterChips({
|
||||
options,
|
||||
filter,
|
||||
onChange,
|
||||
}: {
|
||||
options: AdminChild[];
|
||||
filter: number | "all";
|
||||
onChange: (filter: number | "all") => void;
|
||||
}) {
|
||||
const chips: { key: number | "all"; label: string }[] = [
|
||||
{ key: "all", label: "All" },
|
||||
...options.map((child) => ({ key: child.id, label: child.display_name })),
|
||||
];
|
||||
return (
|
||||
<div className="defs-chips" role="group" aria-label="Filter by child">
|
||||
{chips.map((chip) => (
|
||||
<button
|
||||
key={chip.key}
|
||||
type="button"
|
||||
className={chip.key === filter ? "defs-chip defs-chip--active" : "defs-chip"}
|
||||
aria-pressed={chip.key === filter}
|
||||
onClick={() => onChange(chip.key)}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefinitionRow({
|
||||
definition,
|
||||
activeChildren,
|
||||
editing,
|
||||
onOpen,
|
||||
}: {
|
||||
definition: QuestDefinition;
|
||||
activeChildren: AdminChild[];
|
||||
editing: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
let meta = [
|
||||
assigneeSummary(definition.assignees, activeChildren),
|
||||
recurrenceSummary(definition.rule),
|
||||
windowSummary(definition.windows),
|
||||
].join(" · ");
|
||||
if (!definition.is_active) meta += " · inactive";
|
||||
let className = "defs-row";
|
||||
if (!definition.is_active) className += " defs-row--inactive";
|
||||
if (editing) className += " defs-row--editing";
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
data-testid={`definition-${definition.id}`}
|
||||
aria-expanded={editing}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<span className="defs-row-glyph">
|
||||
<TaskGlyph />
|
||||
</span>
|
||||
<span className="defs-row-text">
|
||||
<span className="defs-row-title">{definition.title}</span>
|
||||
<span className="defs-row-meta">{editing ? "Editing" : meta}</span>
|
||||
</span>
|
||||
<span className="defs-row-chevron">
|
||||
{editing ? <ChevronDownGlyph /> : <ChevronRightGlyph />}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Edit sheet ─────────────────────────────────────────────────────── */
|
||||
|
||||
type PreviewState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "ready"; ruleKey: string; dates: string[] }
|
||||
| { kind: "error"; ruleKey: string; message: string };
|
||||
|
||||
/**
|
||||
* The next few dates the backend would materialize for the draft's rule.
|
||||
* `ruleKey` is the built rule serialized (null while the draft is invalid), so
|
||||
* the request only goes out when the rule itself changes — debounced, and a
|
||||
* superseded request's late response is dropped by its effect's cleanup.
|
||||
* A result is tagged with the rule it answers and only shown while that rule
|
||||
* is still the draft's, so a changed rule never shows the previous dates.
|
||||
*/
|
||||
function OccurrencePreview({ ruleKey }: { ruleKey: string | null }) {
|
||||
const [preview, setPreview] = useState<PreviewState>({ kind: "idle" });
|
||||
|
||||
useEffect(() => {
|
||||
if (ruleKey === null) {
|
||||
setPreview({ kind: "idle" });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
previewOccurrences(JSON.parse(ruleKey) as DefinitionRule, { count: PREVIEW_COUNT })
|
||||
.then((dates) => {
|
||||
if (!cancelled) setPreview({ kind: "ready", ruleKey, dates });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
const detail =
|
||||
error instanceof ApiForbiddenError ||
|
||||
(error instanceof ApiRequestError && error.detail)
|
||||
? `: ${error.message}`
|
||||
: ".";
|
||||
setPreview({
|
||||
kind: "error",
|
||||
ruleKey,
|
||||
message: `Upcoming dates unavailable${detail}`,
|
||||
});
|
||||
});
|
||||
}, PREVIEW_DEBOUNCE_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [ruleKey]);
|
||||
|
||||
if (preview.kind === "idle" || preview.ruleKey !== ruleKey) return null;
|
||||
if (preview.kind === "error") {
|
||||
return (
|
||||
<p className="defs-hint defs-preview defs-preview--error" data-testid="occurrence-preview">
|
||||
{preview.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
let text: string;
|
||||
if (preview.dates.length === 0) {
|
||||
text = "No upcoming dates in the next year.";
|
||||
} else {
|
||||
const labels = preview.dates.map(occurrenceLabel);
|
||||
// A full page means the rule keeps going past the last shown date.
|
||||
if (preview.dates.length >= PREVIEW_COUNT) labels.push("…");
|
||||
text = `Next: ${labels.join(" · ")}`;
|
||||
}
|
||||
return (
|
||||
<p className="defs-hint defs-preview" data-testid="occurrence-preview" aria-live="polite">
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="defs-field" role="group" aria-labelledby={id}>
|
||||
<span className="defs-label" id={id}>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditSheet({
|
||||
target,
|
||||
activeChildren,
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: {
|
||||
target: "new" | QuestDefinition;
|
||||
activeChildren: AdminChild[];
|
||||
onCancel: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Draft>(() =>
|
||||
target === "new" ? newDraft(activeChildren) : draftFrom(target, activeChildren),
|
||||
);
|
||||
// The active selection as loaded: while it is unchanged, the PATCH omits
|
||||
// assignee_child_ids, because the API replaces the whole set and inactive
|
||||
// children cannot be sent back.
|
||||
const [originalActiveIds] = useState(() => draft.assigneeIds);
|
||||
const inactiveAssignees =
|
||||
target === "new"
|
||||
? []
|
||||
: target.assignees.filter((a) => !activeChildren.some((child) => child.id === a.id));
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// A ref, not state: two taps in one frame must not both see `saving === false`.
|
||||
const inFlight = useRef(false);
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const confirmRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Modal focus: move in on open, hand back to the trigger on close.
|
||||
useEffect(() => {
|
||||
const trigger = document.activeElement as HTMLElement | null;
|
||||
titleRef.current?.focus();
|
||||
return () => {
|
||||
if (trigger && trigger.isConnected) trigger.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (confirming) confirmRef.current?.focus();
|
||||
}, [confirming]);
|
||||
|
||||
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
if (!inFlight.current) onCancel();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab" || !sheetRef.current) return;
|
||||
const focusable = Array.from(sheetRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const current = document.activeElement;
|
||||
const outside = !focusable.includes(current as HTMLElement);
|
||||
if (event.shiftKey && (current === first || outside)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && (current === last || outside)) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const update = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
|
||||
|
||||
const selectedAssignees = activeChildren
|
||||
.filter((child) => draft.assigneeIds.includes(child.id))
|
||||
.map((child) => ({ id: child.id, display_name: child.display_name }));
|
||||
const orderedWindows = WINDOW_ORDER.filter((w) => draft.windows.includes(w));
|
||||
const previewRule = buildRule(draft);
|
||||
const previewKey = typeof previewRule === "string" ? null : JSON.stringify(previewRule);
|
||||
|
||||
async function save(confirmed = false) {
|
||||
if (inFlight.current) return;
|
||||
const keepsAssignees = target !== "new" && sameIds(draft.assigneeIds, originalActiveIds);
|
||||
const body = buildBody(draft, keepsAssignees);
|
||||
if (typeof body === "string") {
|
||||
setError(body);
|
||||
setConfirming(false);
|
||||
return;
|
||||
}
|
||||
if (target !== "new" && !keepsAssignees && inactiveAssignees.length > 0 && !confirmed) {
|
||||
setError(null);
|
||||
setConfirming(true);
|
||||
return;
|
||||
}
|
||||
setConfirming(false);
|
||||
inFlight.current = true;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (target === "new") {
|
||||
await createDefinition(body);
|
||||
} else {
|
||||
const edit: DefinitionEditBody = { ...body };
|
||||
if (keepsAssignees) delete edit.assignee_child_ids;
|
||||
await updateDefinition(target.id, edit);
|
||||
}
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(saveErrorMessage(err));
|
||||
inFlight.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="defs-sheet-layer">
|
||||
<div
|
||||
className="defs-scrim"
|
||||
data-testid="sheet-scrim"
|
||||
onClick={() => {
|
||||
if (!inFlight.current) onCancel();
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className="defs-sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="defs-sheet-title"
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<div className="defs-grabber" aria-hidden="true" />
|
||||
<h3 className="defs-sheet-title" id="defs-sheet-title" ref={titleRef} tabIndex={-1}>
|
||||
{target === "new" ? "New task" : "Edit task"}
|
||||
</h3>
|
||||
<div className="defs-fields">
|
||||
<Field label="Title" id="defs-label-title">
|
||||
<input
|
||||
className="defs-input"
|
||||
aria-labelledby="defs-label-title"
|
||||
value={draft.title}
|
||||
onChange={(e) => update({ title: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Assigned children" id="defs-label-children">
|
||||
<button
|
||||
type="button"
|
||||
className="defs-select-row"
|
||||
aria-expanded={pickerOpen}
|
||||
aria-labelledby="defs-label-children defs-children-summary"
|
||||
onClick={() => setPickerOpen((open) => !open)}
|
||||
>
|
||||
<span id="defs-children-summary">
|
||||
{selectedAssignees.length > 0
|
||||
? assigneeSummary(selectedAssignees, activeChildren)
|
||||
: "Choose children"}
|
||||
</span>
|
||||
{pickerOpen ? <ChevronDownGlyph /> : <ChevronRightGlyph />}
|
||||
</button>
|
||||
{pickerOpen ? (
|
||||
<ul className="defs-picker" aria-label="Children">
|
||||
{activeChildren.map((child) => {
|
||||
const checked = draft.assigneeIds.includes(child.id);
|
||||
return (
|
||||
<li key={child.id}>
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
className="defs-picker-option"
|
||||
onClick={() => update({ assigneeIds: toggle(draft.assigneeIds, child.id) })}
|
||||
>
|
||||
<span>{child.display_name}</span>
|
||||
<span className="defs-picker-check">{checked ? <CheckGlyph /> : null}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{inactiveAssignees.map((assignee) => (
|
||||
<li
|
||||
key={assignee.id}
|
||||
className="defs-picker-option defs-picker-option--locked"
|
||||
aria-disabled="true"
|
||||
data-testid={`inactive-assignee-${assignee.id}`}
|
||||
>
|
||||
<span>{assignee.display_name}</span>
|
||||
<span className="defs-picker-note">Inactive</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{inactiveAssignees.length > 0 ? (
|
||||
<p className="defs-hint">
|
||||
Also assigned (inactive): {inactiveAssignees.map((a) => a.display_name).join(", ")}.
|
||||
Kept unless you change the selection.
|
||||
</p>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field label="Repeats" id="defs-label-repeats">
|
||||
<div className="defs-repeats">
|
||||
<select
|
||||
className="defs-input"
|
||||
aria-label="Repeats"
|
||||
value={draft.repeats}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
repeats: e.target.value as Repeats,
|
||||
// Leaving Monthly drops a carried nth-weekday rule for good.
|
||||
monthlyWeekday: e.target.value === "monthly" ? draft.monthlyWeekday : null,
|
||||
})
|
||||
}
|
||||
>
|
||||
{REPEATS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="defs-interval">
|
||||
<span>Every</span>
|
||||
<input
|
||||
className="defs-input"
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label="Interval"
|
||||
value={draft.interval}
|
||||
onChange={(e) => update({ interval: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{draft.repeats === "weekly" || draft.repeats === "custom" ? (
|
||||
<div className="defs-weekdays" role="group" aria-label="Days">
|
||||
{WEEKDAY_SHORT.map((label, day) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
aria-pressed={draft.weekdays.includes(day)}
|
||||
aria-label={WEEKDAY_LONG[day]}
|
||||
className={
|
||||
draft.weekdays.includes(day) ? "defs-day defs-day--on" : "defs-day"
|
||||
}
|
||||
onClick={() => update({ weekdays: toggle(draft.weekdays, day) })}
|
||||
>
|
||||
{label.charAt(0)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{draft.repeats === "monthly" && draft.monthlyWeekday ? (
|
||||
<p className="defs-hint">
|
||||
{recurrenceSummary({
|
||||
rule_type: "monthly_weekday",
|
||||
interval: parseWhole(draft.interval, 1, 999) ?? 1,
|
||||
weekday_set: null,
|
||||
day_of_month: null,
|
||||
nth_weekday: draft.monthlyWeekday.nth,
|
||||
nth_weekday_weekday: draft.monthlyWeekday.weekday,
|
||||
month: null,
|
||||
start_date: draft.startDate,
|
||||
end_date: draft.endDate,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{draft.repeats === "yearly" ? (
|
||||
<select
|
||||
className="defs-input"
|
||||
aria-label="Month"
|
||||
value={draft.month}
|
||||
onChange={(e) => update({ month: e.target.value })}
|
||||
>
|
||||
{MONTH_SHORT.map((label, index) => (
|
||||
<option key={label} value={String(index + 1)}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
{(draft.repeats === "monthly" && !draft.monthlyWeekday) || draft.repeats === "yearly" ? (
|
||||
<input
|
||||
className="defs-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
aria-label="Day of month"
|
||||
value={draft.dayOfMonth}
|
||||
onChange={(e) => update({ dayOfMonth: e.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
<OccurrencePreview ruleKey={previewKey} />
|
||||
</Field>
|
||||
|
||||
<Field label="Due time" id="defs-label-due">
|
||||
{orderedWindows.length === 0 ? (
|
||||
<p className="defs-hint">Pick a window to set its due time.</p>
|
||||
) : (
|
||||
<div className="defs-due-times">
|
||||
{orderedWindows.map((w) => (
|
||||
<label key={w} className="defs-due">
|
||||
<span>{WINDOW_LABELS[w]}</span>
|
||||
<input
|
||||
className="defs-input"
|
||||
type="time"
|
||||
aria-label={`${WINDOW_LABELS[w]} due time`}
|
||||
value={draft.dueTimes[w]}
|
||||
onChange={(e) =>
|
||||
update({ dueTimes: { ...draft.dueTimes, [w]: e.target.value } })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Window" id="defs-label-window">
|
||||
<div className="defs-segmented">
|
||||
{WINDOW_ORDER.map((w) => (
|
||||
<button
|
||||
key={w}
|
||||
type="button"
|
||||
aria-pressed={draft.windows.includes(w)}
|
||||
className={
|
||||
draft.windows.includes(w) ? "defs-segment defs-segment--on" : "defs-segment"
|
||||
}
|
||||
onClick={() => update({ windows: toggle(draft.windows, w) })}
|
||||
>
|
||||
{WINDOW_LABELS[w]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="defs-toggle-row">
|
||||
<div>
|
||||
<div className="defs-toggle-title" id="defs-label-skip">
|
||||
Skip on away days
|
||||
</div>
|
||||
<div className="defs-toggle-sub">No instance when the child is absent</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={draft.skipOnAway}
|
||||
aria-labelledby="defs-label-skip"
|
||||
className={draft.skipOnAway ? "defs-switch defs-switch--on" : "defs-switch"}
|
||||
onClick={() => update({ skipOnAway: !draft.skipOnAway })}
|
||||
>
|
||||
<span className="defs-switch-knob" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="defs-sheet-error" role="alert" data-testid="sheet-error">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{confirming ? (
|
||||
<div
|
||||
className="defs-confirm"
|
||||
role="group"
|
||||
aria-labelledby="defs-confirm-text"
|
||||
data-testid="unassign-confirm"
|
||||
ref={confirmRef}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<p id="defs-confirm-text">
|
||||
Saving will unassign{" "}
|
||||
{inactiveAssignees.map((a) => a.display_name).join(", ")}. Inactive children can't be
|
||||
assigned again until they are reactivated.
|
||||
</p>
|
||||
<div className="defs-sheet-footer">
|
||||
<button type="button" className="defs-cancel" onClick={() => setConfirming(false)}>
|
||||
Go back
|
||||
</button>
|
||||
<button type="button" className="defs-save" onClick={() => save(true)}>
|
||||
Unassign and save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="defs-sheet-footer">
|
||||
<button type="button" className="defs-cancel" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="defs-save" onClick={() => save()} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Screen ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export default function DefinitionsTab() {
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [filter, setFilter] = useState<number | "all">("all");
|
||||
const [sheet, setSheet] = useState<SheetTarget>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// Keep the list on screen during a post-save re-fetch; spin only on first load.
|
||||
setState((current) => (current.kind === "ready" ? current : { kind: "loading" }));
|
||||
Promise.all([fetchDefinitions(), fetchChildren()])
|
||||
.then(([definitions, children]) => {
|
||||
if (!cancelled) setState({ kind: "ready", definitions, children });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message =
|
||||
error instanceof ApiForbiddenError
|
||||
? error.message
|
||||
: "Tasks could not be loaded. Check your connection and try again.";
|
||||
setState({ kind: "error", message });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [attempt]);
|
||||
|
||||
const activeChildren =
|
||||
state.kind === "ready"
|
||||
? [...state.children]
|
||||
.filter((child) => child.is_active)
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
: [];
|
||||
|
||||
let sub = "";
|
||||
let body;
|
||||
if (state.kind === "loading") {
|
||||
body = (
|
||||
<div className="defs-status" role="status" data-testid="definitions-loading">
|
||||
Loading tasks…
|
||||
</div>
|
||||
);
|
||||
} else if (state.kind === "error") {
|
||||
body = (
|
||||
<div className="defs-status defs-status--error" role="alert" data-testid="definitions-error">
|
||||
<p>{state.message}</p>
|
||||
<button type="button" className="defs-retry" onClick={() => setAttempt((n) => n + 1)}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const { definitions } = state;
|
||||
sub = `${definitions.length} definitions · ${activeChildren.length} children`;
|
||||
const shown =
|
||||
filter === "all"
|
||||
? definitions
|
||||
: definitions.filter((d) => d.assignees.some((a) => a.id === filter));
|
||||
const editingId = sheet !== null && sheet !== "new" ? sheet.id : null;
|
||||
body = (
|
||||
<>
|
||||
<FilterChips options={activeChildren} filter={filter} onChange={setFilter} />
|
||||
{shown.length > 0 ? (
|
||||
<ul className="defs-card defs-list" aria-label="Task definitions">
|
||||
{shown.map((definition) => (
|
||||
<DefinitionRow
|
||||
key={definition.id}
|
||||
definition={definition}
|
||||
activeChildren={activeChildren}
|
||||
editing={definition.id === editingId}
|
||||
onOpen={() => setSheet(definition)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="defs-card defs-empty">No tasks yet.</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="defs-screen">
|
||||
<header className="defs-header">
|
||||
<div>
|
||||
<h2 className="defs-title">Tasks</h2>
|
||||
<p className="defs-sub">{sub}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="defs-new"
|
||||
disabled={state.kind !== "ready"}
|
||||
onClick={() => setSheet("new")}
|
||||
>
|
||||
<PlusGlyph />
|
||||
New
|
||||
</button>
|
||||
</header>
|
||||
<div
|
||||
className="defs-scroll"
|
||||
data-testid="definitions-scroll"
|
||||
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
{sheet !== null && state.kind === "ready" ? (
|
||||
<EditSheet
|
||||
key={sheet === "new" ? "new" : sheet.id}
|
||||
target={sheet}
|
||||
activeChildren={activeChildren}
|
||||
onCancel={() => setSheet(null)}
|
||||
onSaved={() => {
|
||||
setSheet(null);
|
||||
setAttempt((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* List-row summaries for the Definitions tab (ADMIN-SPEC §3.1):
|
||||
* `<assignees> · <recurrence> · <windows>`.
|
||||
*/
|
||||
import type {
|
||||
AdminChild,
|
||||
DefinitionAssignee,
|
||||
DefinitionRule,
|
||||
DefinitionWindow,
|
||||
WindowName,
|
||||
} from "../api/definitions";
|
||||
|
||||
export const WINDOW_ORDER: WindowName[] = ["morning", "afternoon", "evening"];
|
||||
|
||||
export const WINDOW_LABELS: Record<WindowName, string> = {
|
||||
morning: "Morning",
|
||||
afternoon: "Afternoon",
|
||||
evening: "Evening",
|
||||
};
|
||||
|
||||
/** Index 0 = Monday .. 6 = Sunday, the core's weekday numbering. */
|
||||
export const WEEKDAY_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
export const MONTH_SHORT = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
const COUNT_WORDS = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"];
|
||||
|
||||
/**
|
||||
* `Declan`, `Declan, Maeve`, or `All three` when every active child (two or
|
||||
* more) is assigned.
|
||||
*/
|
||||
export function assigneeSummary(
|
||||
assignees: Pick<DefinitionAssignee, "id" | "display_name">[],
|
||||
activeChildren: AdminChild[],
|
||||
): string {
|
||||
if (assignees.length === 0) return "Unassigned";
|
||||
const assigned = new Set(assignees.map((a) => a.id));
|
||||
const everyone =
|
||||
activeChildren.length > 1 &&
|
||||
assigned.size === activeChildren.length &&
|
||||
activeChildren.every((child) => assigned.has(child.id));
|
||||
if (everyone) {
|
||||
const word = COUNT_WORDS[activeChildren.length] ?? String(activeChildren.length);
|
||||
return `All ${word}`;
|
||||
}
|
||||
return assignees.map((a) => a.display_name).join(", ");
|
||||
}
|
||||
|
||||
function weekdayList(days: number[] | null): string {
|
||||
return [...(days ?? [])]
|
||||
.sort((a, b) => a - b)
|
||||
.map((day) => WEEKDAY_SHORT[day])
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function ordinal(n: number): string {
|
||||
if (n === -1) return "last";
|
||||
const suffix = n === 1 ? "st" : n === 2 ? "nd" : n === 3 ? "rd" : "th";
|
||||
return `${n}${suffix}`;
|
||||
}
|
||||
|
||||
function every(interval: number, one: string, unit: string): string {
|
||||
return interval === 1 ? one : `Every ${interval} ${unit}`;
|
||||
}
|
||||
|
||||
export function recurrenceSummary(rule: DefinitionRule): string {
|
||||
const n = rule.interval;
|
||||
switch (rule.rule_type) {
|
||||
case "daily":
|
||||
return every(n, "Daily", "days");
|
||||
case "weekly":
|
||||
return `${every(n, "Weekly", "weeks")} on ${weekdayList(rule.weekday_set)}`;
|
||||
case "monthly_day":
|
||||
return `${every(n, "Monthly", "months")} on day ${rule.day_of_month}`;
|
||||
case "monthly_weekday":
|
||||
return `${every(n, "Monthly", "months")} on the ${ordinal(rule.nth_weekday ?? 1)} ${
|
||||
WEEKDAY_SHORT[rule.nth_weekday_weekday ?? 0]
|
||||
}`;
|
||||
case "yearly": {
|
||||
const month = MONTH_SHORT[(rule.month ?? 1) - 1];
|
||||
const when = rule.day_of_month ? `on ${month} ${rule.day_of_month}` : `in ${month}`;
|
||||
return `${every(n, "Yearly", "years")} ${when}`;
|
||||
}
|
||||
case "custom_days":
|
||||
return `Custom${n === 1 ? "" : ` every ${n} weeks`} (${weekdayList(rule.weekday_set)})`;
|
||||
}
|
||||
}
|
||||
|
||||
export function windowSummary(windows: Pick<DefinitionWindow, "window">[]): string {
|
||||
const names = new Set(windows.map((w) => w.window));
|
||||
return WINDOW_ORDER.filter((w) => names.has(w))
|
||||
.map((w) => WINDOW_LABELS[w])
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* "2026-10-06" -> "Tue 6 Oct". The calendar date is read by its components
|
||||
* and its weekday taken in UTC (as the Today tab does), so the printed day
|
||||
* never shifts with the viewer's offset — `new Date("YYYY-MM-DD")` would.
|
||||
*/
|
||||
export function occurrenceLabel(iso: string): string {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
||||
if (!match) throw new Error(`Invalid calendar date: ${iso}`);
|
||||
const [, year, month, day] = match.map(Number);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
// getUTCDay: 0 = Sunday; WEEKDAY_SHORT: 0 = Monday.
|
||||
return `${WEEKDAY_SHORT[(date.getUTCDay() + 6) % 7]} ${day} ${MONTH_SHORT[month - 1]}`;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Inline stroke glyphs for the Definitions tab (Lucide-style 24px grid),
|
||||
* CDN-free like the Today tab's. Definition icons arrive with the icon
|
||||
* picker task; until then every row carries the generic task glyph.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
function Glyph({ size, children }: { size: number; children: ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskGlyph({ size = 17 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" />
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
<path d="m9 14 2 2 4-4" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlusGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronRightGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronDownGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/* NestQuest Admin — global tokens + base.
|
||||
Plain CubeCraft styling, mobile-first, 390x844 reference
|
||||
(design/ADMIN-SPEC.md). Values mirror design/tokens/nestquest-admin-tokens.css,
|
||||
re-scoped to :root for the standalone PWA. */
|
||||
|
||||
:root {
|
||||
--nq-brand-blue: #3b3ab8;
|
||||
--nq-brand-purple: #a035cc;
|
||||
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
|
||||
--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));
|
||||
|
||||
--nq-a-page: #f4f4f6;
|
||||
--nq-a-surface: #ffffff;
|
||||
--nq-a-selected: #ebebf8;
|
||||
--nq-a-border: #e4e4ea;
|
||||
--nq-a-divider: #ededf1;
|
||||
|
||||
--nq-a-ink: #18181d;
|
||||
--nq-a-ink-secondary: #52525e;
|
||||
--nq-a-ink-tertiary: #70707e;
|
||||
|
||||
--nq-a-success: #15803d;
|
||||
--nq-a-danger: #dc2626;
|
||||
--nq-a-danger-strong: #b91c1c;
|
||||
--nq-a-danger-bg: #fee2e2;
|
||||
--nq-a-info-bg: #dbeafe;
|
||||
--nq-a-info-ink: #1e3a8a;
|
||||
--nq-a-info-icon: #1d4ed8;
|
||||
|
||||
--nq-a-font: Nunito, system-ui, sans-serif;
|
||||
--nq-a-size-screen: 24px;
|
||||
--nq-a-size-stat: 22px;
|
||||
--nq-a-size-row: 13.5px;
|
||||
--nq-a-size-meta: 11px;
|
||||
--nq-a-size-label: 10px;
|
||||
--nq-a-track-label: 0.1em;
|
||||
|
||||
--nq-a-density-gap: 10px;
|
||||
--nq-a-density-card-pad: 13px;
|
||||
--nq-a-density-row-pad: 11px 13px;
|
||||
--nq-a-density-radius: 8px;
|
||||
|
||||
--nq-a-tabbar-height: 70px;
|
||||
--nq-a-tap-min: 44px;
|
||||
--nq-a-radius-pill: 9999px;
|
||||
--nq-a-radius-sheet: 20px 20px 0 0;
|
||||
--nq-a-sheet-shadow: 0 -8px 28px rgba(9, 9, 11, 0.18);
|
||||
--nq-a-progress-height: 6px;
|
||||
|
||||
--nq-ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--nq-dur-base: 200ms;
|
||||
--nq-dur-modal: 350ms;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--nq-a-font);
|
||||
background: var(--nq-a-page);
|
||||
color: var(--nq-a-ink);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import AppRoutes from "./AppRoutes";
|
||||
import "./index.css";
|
||||
import { registerServiceWorker } from "./pwa/register";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<AppRoutes />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
void registerServiceWorker();
|
||||
@@ -0,0 +1,396 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
REVISION_MARKER,
|
||||
STATIC_PRECACHE_FILES,
|
||||
injectServiceWorker,
|
||||
} from "../../vite-sw-plugin";
|
||||
import { registerServiceWorker } from "./register";
|
||||
|
||||
const adminRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const adminPath = (rel: string) => resolve(adminRoot, rel);
|
||||
const publicPath = (urlPath: string) => adminPath(`public${urlPath}`);
|
||||
|
||||
interface ManifestIcon {
|
||||
src: string;
|
||||
sizes: string;
|
||||
type: string;
|
||||
purpose?: string;
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(readFileSync(publicPath("/manifest.webmanifest"), "utf8"));
|
||||
const icons: ManifestIcon[] = manifest.icons;
|
||||
const swSource = readFileSync(publicPath("/sw.js"), "utf8");
|
||||
|
||||
const sizeOf = (icon: ManifestIcon) => Number(icon.sizes.split("x")[0]);
|
||||
const purposes = (icon: ManifestIcon) => (icon.purpose ?? "any").split(/\s+/);
|
||||
|
||||
describe("web app manifest", () => {
|
||||
it("meets the installability criteria", () => {
|
||||
expect(manifest.name).toBe("NestQuest Admin");
|
||||
expect(manifest.short_name).toBe("NestQuest");
|
||||
expect(manifest.start_url).toBe("/");
|
||||
expect(manifest.scope).toBe("/");
|
||||
expect(manifest.display).toBe("standalone");
|
||||
expect(manifest.background_color).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
expect(manifest.theme_color).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
|
||||
const any = icons.filter((i) => purposes(i).includes("any"));
|
||||
expect(any.some((i) => sizeOf(i) >= 192)).toBe(true);
|
||||
expect(any.some((i) => sizeOf(i) >= 512)).toBe(true);
|
||||
expect(icons.some((i) => purposes(i).includes("maskable"))).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the admin design token colours", () => {
|
||||
const css = readFileSync(adminPath("src/index.css"), "utf8");
|
||||
expect(css).toContain(`--nq-brand-blue: ${manifest.theme_color};`);
|
||||
expect(css).toContain(`--nq-a-page: ${manifest.background_color};`);
|
||||
});
|
||||
|
||||
it.each(icons.map((i) => [i.src, i] as const))(
|
||||
"icon %s is a real PNG of its declared size",
|
||||
(_src, icon) => {
|
||||
const path = publicPath(icon.src);
|
||||
expect(existsSync(path)).toBe(true);
|
||||
const png = readFileSync(path);
|
||||
expect([...png.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
expect(png.toString("ascii", 12, 16)).toBe("IHDR");
|
||||
const [w, h] = icon.sizes.split("x").map(Number);
|
||||
expect(png.readUInt32BE(16)).toBe(w);
|
||||
expect(png.readUInt32BE(20)).toBe(h);
|
||||
expect(icon.type).toBe("image/png");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("index.html and entry point", () => {
|
||||
const html = readFileSync(adminPath("index.html"), "utf8");
|
||||
|
||||
it("links the manifest, theme colour and apple touch icon", () => {
|
||||
expect(html).toContain('<link rel="manifest" href="/manifest.webmanifest" />');
|
||||
expect(html).toContain(`<meta name="theme-color" content="${manifest.theme_color}" />`);
|
||||
expect(html).toContain('<link rel="apple-touch-icon" href="/icons/icon-192.png" />');
|
||||
});
|
||||
|
||||
it("main.tsx registers the service worker", () => {
|
||||
const main = readFileSync(adminPath("src/main.tsx"), "utf8");
|
||||
expect(main).toMatch(/import \{ registerServiceWorker \} from "\.\/pwa\/register"/);
|
||||
expect(main).toMatch(/registerServiceWorker\(\)/);
|
||||
});
|
||||
});
|
||||
|
||||
// Minimal service-worker global scope: runs public/sw.js against in-memory
|
||||
// caches and a stubbed network, so handler behaviour is checked without I/O.
|
||||
function loadServiceWorker(
|
||||
network: (url: string) => Promise<FakeResponse>,
|
||||
source: string = swSource,
|
||||
) {
|
||||
const listeners: Record<string, (event: any) => void> = {};
|
||||
const stores = new Map<string, Map<string, FakeResponse>>();
|
||||
const openStore = (name: string) => {
|
||||
if (!stores.has(name)) stores.set(name, new Map());
|
||||
return stores.get(name)!;
|
||||
};
|
||||
const keyOf = (req: string | { url: string }) =>
|
||||
new URL(typeof req === "string" ? req : req.url, "https://admin.test").href;
|
||||
const fetchFn = vi.fn((req: string | { url: string }) => network(keyOf(req)));
|
||||
|
||||
const caches = {
|
||||
open: async (name: string) => {
|
||||
const store = openStore(name);
|
||||
return {
|
||||
addAll: async (urls: string[]) => {
|
||||
for (const u of urls) store.set(keyOf(u), await network(keyOf(u)));
|
||||
},
|
||||
put: async (req: { url: string }, res: FakeResponse) => void store.set(keyOf(req), res),
|
||||
};
|
||||
},
|
||||
keys: async () => [...stores.keys()],
|
||||
delete: async (name: string) => stores.delete(name),
|
||||
match: async (req: string | { url: string }, opts?: { cacheName?: string }) => {
|
||||
const names = opts?.cacheName ? [opts.cacheName] : [...stores.keys()];
|
||||
for (const n of names) {
|
||||
const hit = stores.get(n)?.get(keyOf(req));
|
||||
if (hit) return hit;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
const self = {
|
||||
location: new URL("https://admin.test/"),
|
||||
addEventListener: (type: string, fn: (event: any) => void) => (listeners[type] = fn),
|
||||
skipWaiting: vi.fn(async () => undefined),
|
||||
clients: { claim: vi.fn(async () => undefined) },
|
||||
};
|
||||
const ResponseStub = { error: () => new FakeResponse("", false, "error") };
|
||||
new Function("self", "caches", "fetch", "Response", source)(self, caches, fetchFn, ResponseStub);
|
||||
|
||||
const dispatch = async (type: string, init: object = {}) => {
|
||||
let pending: Promise<unknown> | undefined;
|
||||
const event = {
|
||||
...init,
|
||||
waitUntil: (p: Promise<unknown>) => (pending = p),
|
||||
respondWith: (p: Promise<unknown>) => (pending = p),
|
||||
};
|
||||
listeners[type](event);
|
||||
return { responded: pending !== undefined, result: await pending };
|
||||
};
|
||||
return { listeners, stores, self, fetchFn, dispatch, openStore };
|
||||
}
|
||||
|
||||
class FakeResponse {
|
||||
constructor(
|
||||
readonly body: string,
|
||||
readonly ok = true,
|
||||
readonly type = "basic",
|
||||
) {}
|
||||
clone() {
|
||||
return new FakeResponse(this.body, this.ok, this.type);
|
||||
}
|
||||
}
|
||||
|
||||
const request = (url: string, extra: object = {}) => ({
|
||||
url: new URL(url, "https://admin.test").href,
|
||||
method: "GET",
|
||||
mode: "cors",
|
||||
destination: "",
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe("service worker (public/sw.js)", () => {
|
||||
it("is a static asset served from the site root", () => {
|
||||
expect(existsSync(publicPath("/sw.js"))).toBe(true);
|
||||
expect(swSource).toMatch(/const SHELL_CACHE = /);
|
||||
expect(swSource).toMatch(/addEventListener\("install"/);
|
||||
expect(swSource).toMatch(/addEventListener\("activate"/);
|
||||
expect(swSource).toMatch(/addEventListener\("fetch"/);
|
||||
});
|
||||
|
||||
it("precaches the shell on install, prunes stale caches on activate", async () => {
|
||||
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
|
||||
sw.openStore("nestquest-admin-shell-v0");
|
||||
|
||||
await sw.dispatch("install");
|
||||
expect(sw.self.skipWaiting).toHaveBeenCalled();
|
||||
const precached = [...sw.stores.values()].flatMap((s) => [...s.keys()]);
|
||||
for (const path of ["/", "/index.html", "/manifest.webmanifest", ...icons.map((i) => i.src)]) {
|
||||
expect(precached).toContain(`https://admin.test${path}`);
|
||||
}
|
||||
|
||||
await sw.dispatch("activate");
|
||||
expect(sw.stores.has("nestquest-admin-shell-v0")).toBe(false);
|
||||
expect(sw.self.clients.claim).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves the cached shell for navigations when offline", async () => {
|
||||
let online = true;
|
||||
const sw = loadServiceWorker(async (url) => {
|
||||
if (!online) throw new TypeError("Failed to fetch");
|
||||
return new FakeResponse(`net:${url}`);
|
||||
});
|
||||
await sw.dispatch("install");
|
||||
online = false;
|
||||
|
||||
const { result } = await sw.dispatch("fetch", {
|
||||
request: request("/children/42", { mode: "navigate", destination: "document" }),
|
||||
});
|
||||
expect((result as FakeResponse).body).toBe("net:https://admin.test/index.html");
|
||||
});
|
||||
|
||||
it("serves static assets cache-first and fills the runtime cache", async () => {
|
||||
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
|
||||
const asset = request("/assets/index-abc123.js", { destination: "script" });
|
||||
|
||||
const first = await sw.dispatch("fetch", { request: asset });
|
||||
expect((first.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js");
|
||||
expect(sw.fetchFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
const second = await sw.dispatch("fetch", { request: asset });
|
||||
expect((second.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js");
|
||||
expect(sw.fetchFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never handles cross-origin, non-GET or non-static requests", async () => {
|
||||
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
|
||||
const cases = [
|
||||
request("https://auth.example.com/logo.png", { destination: "image" }),
|
||||
request("/assets/app.js", { destination: "script", method: "POST" }),
|
||||
request("/api/admin/children"),
|
||||
];
|
||||
for (const req of cases) {
|
||||
const { responded } = await sw.dispatch("fetch", { request: req });
|
||||
expect(responded).toBe(false);
|
||||
}
|
||||
expect(sw.fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// A Vite-style built index.html referencing hashed bundles.
|
||||
const builtIndexHtml = (hash: string) => `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<script type="module" crossorigin src="/assets/index-${hash}.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-${hash}.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-${hash}.css">
|
||||
<link rel="preconnect" href="https://auth.example.com" />
|
||||
</head>
|
||||
<body><div id="root"></div></body>
|
||||
</html>`;
|
||||
|
||||
// Same-origin script/link URLs of an HTML document, parsed independently of
|
||||
// the build plugin so the regression check does not trust its own extractor.
|
||||
const referencedAssets = (html: string) =>
|
||||
[...new DOMParser().parseFromString(html, "text/html").querySelectorAll("script[src], link[href]")]
|
||||
.map((el) => new URL(el.getAttribute("src") ?? el.getAttribute("href")!, "https://admin.test/"))
|
||||
.filter((url) => url.origin === "https://admin.test")
|
||||
.map((url) => url.href);
|
||||
|
||||
// The unhashed files the build hashes into the revision, read from public/.
|
||||
const staticFiles = STATIC_PRECACHE_FILES.map((path) => ({
|
||||
path,
|
||||
bytes: readFileSync(publicPath(`/${path}`)),
|
||||
}));
|
||||
const revisionOf = (src: string) => /const REVISION = "([0-9a-f]+)";/.exec(src)?.[1];
|
||||
|
||||
describe("build-time service worker injection", () => {
|
||||
it("precaches every asset the cached index.html references on install (PWA-001)", async () => {
|
||||
const html = builtIndexHtml("Ab12Cd34");
|
||||
const built = injectServiceWorker(html, swSource, staticFiles);
|
||||
const sw = loadServiceWorker(async (url) =>
|
||||
new FakeResponse(url.endsWith("/index.html") ? html : `net:${url}`),
|
||||
built);
|
||||
|
||||
await sw.dispatch("install");
|
||||
const shell = [...sw.stores.entries()].find(([name]) => name.startsWith("nestquest-admin-shell-"));
|
||||
expect(shell).toBeDefined();
|
||||
const [, shellStore] = shell!;
|
||||
|
||||
const cachedHtml = shellStore.get("https://admin.test/index.html")!.body;
|
||||
const assets = referencedAssets(cachedHtml);
|
||||
expect(assets).toEqual(
|
||||
expect.arrayContaining([
|
||||
"https://admin.test/assets/index-Ab12Cd34.js",
|
||||
"https://admin.test/assets/vendor-Ab12Cd34.js",
|
||||
"https://admin.test/assets/index-Ab12Cd34.css",
|
||||
]),
|
||||
);
|
||||
for (const asset of assets) expect([...shellStore.keys()]).toContain(asset);
|
||||
expect([...shellStore.keys()]).not.toContain("https://auth.example.com/");
|
||||
});
|
||||
|
||||
it("derives a new revision for different index.html content (PWA-002)", () => {
|
||||
const a = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
|
||||
const b = injectServiceWorker(builtIndexHtml("bbbb2222"), swSource, staticFiles);
|
||||
|
||||
expect(a).not.toContain(REVISION_MARKER);
|
||||
expect(b).not.toContain(REVISION_MARKER);
|
||||
expect(revisionOf(a)).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(revisionOf(b)).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(revisionOf(a)).not.toBe(revisionOf(b));
|
||||
expect(injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles)).toBe(a);
|
||||
});
|
||||
|
||||
it("derives a new revision when only a precached static file changes (PWA-002)", () => {
|
||||
const html = builtIndexHtml("aaaa1111");
|
||||
const base = revisionOf(injectServiceWorker(html, swSource, staticFiles));
|
||||
const withChanged = (path: string) =>
|
||||
staticFiles.map((f) =>
|
||||
f.path === path ? { path, bytes: Buffer.concat([f.bytes, Buffer.from([0])]) } : f,
|
||||
);
|
||||
|
||||
expect(revisionOf(injectServiceWorker(html, swSource, [...staticFiles].reverse()))).toBe(base);
|
||||
for (const path of STATIC_PRECACHE_FILES) {
|
||||
const changed = revisionOf(injectServiceWorker(html, swSource, withChanged(path)));
|
||||
expect(changed, path).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(changed, path).not.toBe(base);
|
||||
}
|
||||
});
|
||||
|
||||
it("hashes every unhashed URL sw.js precaches (PWA-002)", () => {
|
||||
const shellUrls = [...swSource.matchAll(/^\s*"(\/[^"]*)",$/gm)].map((m) => m[1]);
|
||||
const fixed = shellUrls.filter((url) => url !== "/" && url !== "/index.html");
|
||||
expect(fixed.map((url) => url.slice(1)).sort()).toEqual([...STATIC_PRECACHE_FILES].sort());
|
||||
});
|
||||
|
||||
it("names both caches after the build revision (PWA-002)", async () => {
|
||||
const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
|
||||
const revision = /const REVISION = "([0-9a-f]+)";/.exec(built)![1];
|
||||
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built);
|
||||
|
||||
await sw.dispatch("install");
|
||||
await sw.dispatch("fetch", { request: request("/assets/late.js", { destination: "script" }) });
|
||||
expect([...sw.stores.keys()].sort()).toEqual([
|
||||
`nestquest-admin-runtime-${revision}`,
|
||||
`nestquest-admin-shell-${revision}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails fast when sw.js lacks the placeholders", () => {
|
||||
expect(() => injectServiceWorker(builtIndexHtml("x"), "const SHELL_URLS = [];", staticFiles)).toThrow();
|
||||
});
|
||||
|
||||
it("activation deletes only stale nestquest-admin caches (PWA-003)", async () => {
|
||||
const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
|
||||
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built);
|
||||
for (const name of [
|
||||
"nestquest-admin-shell-old",
|
||||
"nestquest-admin-runtime-old",
|
||||
"some-other-app-cache",
|
||||
]) {
|
||||
sw.openStore(name);
|
||||
}
|
||||
|
||||
await sw.dispatch("install");
|
||||
await sw.dispatch("activate");
|
||||
const names = [...sw.stores.keys()];
|
||||
expect(names).not.toContain("nestquest-admin-shell-old");
|
||||
expect(names).not.toContain("nestquest-admin-runtime-old");
|
||||
expect(names).toContain("some-other-app-cache");
|
||||
expect(names.some((n) => n.startsWith("nestquest-admin-shell-") && n !== "nestquest-admin-shell-old")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerServiceWorker", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const stubServiceWorker = (register: ReturnType<typeof vi.fn>) =>
|
||||
vi.stubGlobal("navigator", { ...navigator, serviceWorker: { register } });
|
||||
|
||||
it("does not register outside production builds", async () => {
|
||||
const register = vi.fn();
|
||||
stubServiceWorker(register);
|
||||
expect(import.meta.env.PROD).toBe(false);
|
||||
await registerServiceWorker();
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers /sw.js in production", async () => {
|
||||
vi.stubEnv("PROD", true);
|
||||
const register = vi.fn().mockResolvedValue({});
|
||||
stubServiceWorker(register);
|
||||
await registerServiceWorker();
|
||||
expect(register).toHaveBeenCalledWith("/sw.js", { scope: "/" });
|
||||
});
|
||||
|
||||
it("logs instead of throwing when registration fails", async () => {
|
||||
vi.stubEnv("PROD", true);
|
||||
stubServiceWorker(vi.fn().mockRejectedValue(new Error("boom")));
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
await expect(registerServiceWorker()).resolves.toBeUndefined();
|
||||
expect(error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when the browser lacks service workers", async () => {
|
||||
vi.stubEnv("PROD", true);
|
||||
vi.stubGlobal("navigator", {});
|
||||
await expect(registerServiceWorker()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Service worker registration for the installable admin PWA.
|
||||
* Registers /sw.js only in production builds, so dev servers and tests never
|
||||
* run a worker. Failures are logged, never thrown — the app works without it.
|
||||
*/
|
||||
export async function registerServiceWorker(): Promise<void> {
|
||||
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
|
||||
try {
|
||||
await navigator.serviceWorker.register("/sw.js", { scope: "/" });
|
||||
} catch (error) {
|
||||
console.error("Service worker registration failed:", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/* Today tab — compact density (design/ADMIN-SPEC.md §2). Mobile-first at the
|
||||
390x844 reference; on wider viewports the column is capped and centred. */
|
||||
|
||||
.today-screen {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* §2.1 header */
|
||||
.today-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--nq-a-surface);
|
||||
border-bottom: 1px solid var(--nq-a-border);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.today-title {
|
||||
margin: 0;
|
||||
font-size: var(--nq-a-size-screen);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.today-sub {
|
||||
margin: 2px 0 0;
|
||||
min-height: 1.4em;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.today-icon-button {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
|
||||
.today-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nq-a-density-gap);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.today-card {
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
}
|
||||
|
||||
.today-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.today-list > li + li {
|
||||
border-top: 1px solid var(--nq-a-divider);
|
||||
}
|
||||
|
||||
/* §2.2 stat row */
|
||||
.today-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.today-stat {
|
||||
padding: 12px;
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
}
|
||||
|
||||
.today-stat-value {
|
||||
font-size: var(--nq-a-size-stat);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.today-stat-value--done {
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
|
||||
.today-stat-value--remaining {
|
||||
color: var(--nq-a-ink);
|
||||
}
|
||||
|
||||
.today-stat-value--overdue {
|
||||
color: var(--nq-a-danger);
|
||||
}
|
||||
|
||||
.today-stat-label {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
/* §2.3 child rows */
|
||||
.today-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: var(--nq-a-density-row-pad);
|
||||
}
|
||||
|
||||
.today-avatar {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 7px;
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.today-child--away .today-avatar {
|
||||
background: #9898a8;
|
||||
}
|
||||
|
||||
.today-child-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.today-child-name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.today-child-meta {
|
||||
font-size: var(--nq-a-size-meta);
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.today-child-meta--done {
|
||||
color: var(--nq-a-success);
|
||||
}
|
||||
|
||||
.today-progress {
|
||||
flex: none;
|
||||
width: 82px;
|
||||
height: var(--nq-a-progress-height);
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
background: var(--nq-a-divider);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.today-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--nq-brand-gradient-h);
|
||||
}
|
||||
|
||||
/* §2.5 needs attention */
|
||||
.today-section-label {
|
||||
margin: 6px 0 -2px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.today-event {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: var(--nq-a-density-row-pad);
|
||||
}
|
||||
|
||||
.today-event-glyph {
|
||||
flex: none;
|
||||
display: flex;
|
||||
color: var(--nq-a-success);
|
||||
}
|
||||
|
||||
.today-event-glyph--overdue {
|
||||
color: var(--nq-a-danger);
|
||||
}
|
||||
|
||||
.today-event-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.today-event-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.today-event-meta {
|
||||
font-size: var(--nq-a-size-meta);
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.today-event-meta--overdue {
|
||||
color: var(--nq-a-danger);
|
||||
}
|
||||
|
||||
.today-action {
|
||||
flex: none;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: 0 4px;
|
||||
border: 0;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.today-action--done {
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
|
||||
.today-action--undo {
|
||||
color: var(--nq-brand-purple);
|
||||
}
|
||||
|
||||
.today-empty {
|
||||
padding: var(--nq-a-density-card-pad);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
/* §2.6 permission note */
|
||||
.today-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-info-bg);
|
||||
color: var(--nq-a-info-ink);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.today-note-glyph {
|
||||
flex: none;
|
||||
display: flex;
|
||||
color: var(--nq-a-info-icon);
|
||||
}
|
||||
|
||||
/* Loading / error */
|
||||
.today-status {
|
||||
padding: 24px 13px;
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
text-align: center;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.today-status--error {
|
||||
color: var(--nq-a-danger-strong);
|
||||
}
|
||||
|
||||
.today-status--error p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.today-retry {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import TodayTab, { PERMISSION_NOTE } from "./TodayTab";
|
||||
import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
|
||||
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
|
||||
return {
|
||||
id: 0,
|
||||
definition_id: 1,
|
||||
child_id: 1,
|
||||
title: "Quest",
|
||||
icon: null,
|
||||
window: "morning",
|
||||
due_time: null,
|
||||
state: "open",
|
||||
overdue: false,
|
||||
completed_at: null,
|
||||
on_time: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const SNAPSHOT: AdminSnapshot = {
|
||||
today_iso: "2026-09-23",
|
||||
cycle_day: 5,
|
||||
children: [
|
||||
{
|
||||
child_id: 1,
|
||||
child_name: "Declan",
|
||||
present: true,
|
||||
next_present: null,
|
||||
due_today: 7,
|
||||
completed_today: 3,
|
||||
remaining_today: 4,
|
||||
completion_pct: 43,
|
||||
instances: [
|
||||
instance({ id: 11, title: "Brush teeth", due_time: "07:30", overdue: true }),
|
||||
instance({
|
||||
id: 12,
|
||||
title: "Make bed",
|
||||
state: "completed",
|
||||
completed_at: "2026-09-23T11:04:00Z",
|
||||
on_time: true,
|
||||
}),
|
||||
instance({ id: 13, title: "Homework", window: "afternoon", due_time: "16:00" }),
|
||||
// Overdue but already completed: not counted as overdue.
|
||||
instance({
|
||||
id: 14,
|
||||
title: "Pack bag",
|
||||
state: "completed",
|
||||
overdue: true,
|
||||
completed_at: "2026-09-23T10:00:00Z",
|
||||
on_time: false,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
child_id: 2,
|
||||
child_name: "Maeve",
|
||||
present: true,
|
||||
next_present: null,
|
||||
due_today: 4,
|
||||
completed_today: 4,
|
||||
remaining_today: 0,
|
||||
completion_pct: 100,
|
||||
instances: [],
|
||||
},
|
||||
{
|
||||
child_id: 3,
|
||||
child_name: "Rory",
|
||||
present: false,
|
||||
next_present: "2026-10-01",
|
||||
due_today: 0,
|
||||
completed_today: 0,
|
||||
remaining_today: 0,
|
||||
completion_pct: 0,
|
||||
instances: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
async function renderReady() {
|
||||
fetchMock.mockResolvedValue(jsonResponse(SNAPSHOT));
|
||||
render(<TodayTab />);
|
||||
await screen.findByText("Declan");
|
||||
}
|
||||
|
||||
describe("TodayTab", () => {
|
||||
beforeAll(() => {
|
||||
// A negative-offset zone: `new Date("2026-09-23")` would read as Sep 22 here.
|
||||
vi.stubEnv("TZ", "America/Los_Angeles");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("fetches the admin snapshot with the Bearer token", async () => {
|
||||
await renderReady();
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toMatch(/\/api\/v1\/admin\/snapshot$/);
|
||||
expect(init.headers.Authorization).toBe("Bearer at-123");
|
||||
});
|
||||
|
||||
it("header shows the weekday, date and cycle day", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy();
|
||||
expect(screen.getByText("Wednesday, September 23 · cycle day 5 of 14")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stat row shows done, remaining and overdue totals", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByTestId("stat-done").textContent).toBe("7Done");
|
||||
expect(screen.getByTestId("stat-remaining").textContent).toBe("4Remaining");
|
||||
expect(screen.getByTestId("stat-overdue").textContent).toBe("1Overdue");
|
||||
});
|
||||
|
||||
it("present child row shows progress meta and fill", async () => {
|
||||
await renderReady();
|
||||
const row = screen.getByTestId("child-1");
|
||||
expect(within(row).getByText("3 of 7 · 1 overdue")).toBeTruthy();
|
||||
const bar = within(row).getByRole("progressbar");
|
||||
expect(bar.getAttribute("aria-valuenow")).toBe("43");
|
||||
expect((bar.firstElementChild as HTMLElement).style.width).toBe("43%");
|
||||
expect(row.className).not.toContain("today-child--away");
|
||||
});
|
||||
|
||||
it("all-done child renders the success state", async () => {
|
||||
await renderReady();
|
||||
const meta = within(screen.getByTestId("child-2")).getByText("4 of 4 · all done");
|
||||
expect(meta.className).toContain("today-child-meta--done");
|
||||
});
|
||||
|
||||
it("away child shows the return date and the flat avatar", async () => {
|
||||
await renderReady();
|
||||
const row = screen.getByTestId("child-3");
|
||||
expect(within(row).getByText("Away · returns Oct 1")).toBeTruthy();
|
||||
expect(row.className).toContain("today-child--away");
|
||||
expect(within(row).getByTestId("child-avatar").textContent).toBe("R");
|
||||
});
|
||||
|
||||
it("needs-attention lists overdue and completed rows with their actions", async () => {
|
||||
await renderReady();
|
||||
const list = screen.getByRole("list", { name: "Needs attention" });
|
||||
const overdue = within(list).getByTestId("attention-11");
|
||||
expect(within(overdue).getByText("Brush teeth · Declan")).toBeTruthy();
|
||||
expect(within(overdue).getByText("Overdue since 7:30 AM")).toBeTruthy();
|
||||
expect(within(overdue).getByRole("button", { name: "Mark done" })).toBeTruthy();
|
||||
|
||||
const completed = within(list).getByTestId("attention-12");
|
||||
expect(within(completed).getByText("Make bed · Declan")).toBeTruthy();
|
||||
// 11:04Z is 4:04 AM in Los Angeles (PDT).
|
||||
expect(within(completed).getByText("Completed 4:04 AM")).toBeTruthy();
|
||||
expect(within(completed).getByRole("button", { name: "Undo" })).toBeTruthy();
|
||||
|
||||
// Open, not-overdue instances do not need attention.
|
||||
expect(within(list).queryByTestId("attention-13")).toBeNull();
|
||||
// No actor in the payload, so no "from panel".
|
||||
expect(list.textContent).not.toContain("from panel");
|
||||
});
|
||||
|
||||
it("permission note carries the exact copy", async () => {
|
||||
await renderReady();
|
||||
expect(PERMISSION_NOTE).toBe(
|
||||
"Undo is admin-only. A panel tap can never reverse a completion.",
|
||||
);
|
||||
expect(screen.getByTestId("permission-note").textContent).toBe(PERMISSION_NOTE);
|
||||
});
|
||||
|
||||
it("scroll column ends with 92px bottom padding", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByTestId("today-scroll").style.paddingBottom).toBe("92px");
|
||||
});
|
||||
|
||||
it("shows a loading state while the snapshot is in flight", () => {
|
||||
fetchMock.mockReturnValue(new Promise<Response>(() => {}));
|
||||
render(<TodayTab />);
|
||||
expect(screen.getByTestId("today-loading").textContent).toContain("Loading");
|
||||
expect(screen.getByTestId("today-scroll").style.paddingBottom).toBe("92px");
|
||||
});
|
||||
|
||||
it("shows an error state on a failed fetch and retries", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("boom", { status: 500 }));
|
||||
render(<TodayTab />);
|
||||
const error = await screen.findByTestId("today-error");
|
||||
expect(error.getAttribute("role")).toBe("alert");
|
||||
expect(error.textContent).toContain("could not be loaded");
|
||||
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(SNAPSHOT));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
expect(await screen.findByText("Declan")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the refusal message when the API answers 403", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("", { status: 403 }));
|
||||
render(<TodayTab />);
|
||||
const error = await screen.findByTestId("today-error");
|
||||
expect(error.textContent).toContain("nestquest-admins");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Today tab — compact density (design/ADMIN-SPEC.md §2.1–2.3, §2.5–2.6).
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
fetchSnapshot,
|
||||
type AdminSnapshot,
|
||||
type SnapshotChild,
|
||||
type SnapshotInstance,
|
||||
} from "../api/snapshot";
|
||||
import { ApiForbiddenError } from "../api/client";
|
||||
import {
|
||||
formatClockTime,
|
||||
formatDueTime,
|
||||
formatLongDate,
|
||||
formatShortDate,
|
||||
} from "./format";
|
||||
import {
|
||||
AlertCircleGlyph,
|
||||
CheckCircleGlyph,
|
||||
InfoGlyph,
|
||||
SettingsGlyph,
|
||||
} from "./glyphs";
|
||||
import "./TodayTab.css";
|
||||
|
||||
const CYCLE_LENGTH_DAYS = 14;
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
export const SCROLL_BOTTOM_PADDING = "92px";
|
||||
|
||||
export const PERMISSION_NOTE =
|
||||
"Undo is admin-only. A panel tap can never reverse a completion.";
|
||||
|
||||
type LoadState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; snapshot: AdminSnapshot };
|
||||
|
||||
function isOverdueOpen(instance: SnapshotInstance): boolean {
|
||||
return instance.overdue && instance.state === "open";
|
||||
}
|
||||
|
||||
function ScreenHeader({ sub }: { sub: string }) {
|
||||
return (
|
||||
<header className="today-header">
|
||||
<div>
|
||||
<h2 className="today-title">Today</h2>
|
||||
<p className="today-sub">{sub}</p>
|
||||
</div>
|
||||
{/* Settings screen is a later task; the control is not wired yet. */}
|
||||
<button type="button" className="today-icon-button" aria-label="Settings">
|
||||
<SettingsGlyph />
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({ snapshot }: { snapshot: AdminSnapshot }) {
|
||||
const done = snapshot.children.reduce((sum, c) => sum + c.completed_today, 0);
|
||||
const remaining = snapshot.children.reduce((sum, c) => sum + c.remaining_today, 0);
|
||||
const overdue = snapshot.children.reduce(
|
||||
(sum, c) => sum + c.instances.filter(isOverdueOpen).length,
|
||||
0,
|
||||
);
|
||||
const stats = [
|
||||
{ key: "done", label: "Done", value: done },
|
||||
{ key: "remaining", label: "Remaining", value: remaining },
|
||||
{ key: "overdue", label: "Overdue", value: overdue },
|
||||
];
|
||||
return (
|
||||
<div className="today-stats">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.key} className="today-stat" data-testid={`stat-${stat.key}`}>
|
||||
<div className={`today-stat-value today-stat-value--${stat.key}`}>
|
||||
{stat.value}
|
||||
</div>
|
||||
<div className="today-stat-label">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChildRow({ child }: { child: SnapshotChild }) {
|
||||
const overdue = child.instances.filter(isOverdueOpen).length;
|
||||
const allDone = child.due_today > 0 && child.completed_today === child.due_today;
|
||||
let meta: string;
|
||||
let metaClass = "today-child-meta";
|
||||
if (!child.present) {
|
||||
meta = child.next_present
|
||||
? `Away · returns ${formatShortDate(child.next_present)}`
|
||||
: "Away";
|
||||
} else if (allDone) {
|
||||
meta = `${child.completed_today} of ${child.due_today} · all done`;
|
||||
metaClass += " today-child-meta--done";
|
||||
} else {
|
||||
meta = `${child.completed_today} of ${child.due_today} · ${overdue} overdue`;
|
||||
}
|
||||
const pct = Math.max(0, Math.min(100, child.completion_pct));
|
||||
// ADMIN-SPEC §2.3 also asks for a lowercase `override` pill when an absence
|
||||
// comes from a presence override. The snapshot carries no field saying
|
||||
// where an absence came from, so the pill is omitted rather than guessed.
|
||||
return (
|
||||
<li
|
||||
className={child.present ? "today-child" : "today-child today-child--away"}
|
||||
data-testid={`child-${child.child_id}`}
|
||||
>
|
||||
<span className="today-avatar" data-testid="child-avatar" aria-hidden="true">
|
||||
{child.child_name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<div className="today-child-text">
|
||||
<div className="today-child-name">{child.child_name}</div>
|
||||
<div className={metaClass}>{meta}</div>
|
||||
</div>
|
||||
<div
|
||||
className="today-progress"
|
||||
role="progressbar"
|
||||
aria-label={`${child.child_name} progress`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={pct}
|
||||
>
|
||||
<div className="today-progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface AttentionItem {
|
||||
instance: SnapshotInstance;
|
||||
childName: string;
|
||||
}
|
||||
|
||||
function attentionItems(snapshot: AdminSnapshot): AttentionItem[] {
|
||||
const all = snapshot.children.flatMap((child) =>
|
||||
child.instances.map((instance) => ({ instance, childName: child.child_name })),
|
||||
);
|
||||
const overdue = all.filter((item) => isOverdueOpen(item.instance));
|
||||
const completed = all
|
||||
.filter((item) => item.instance.state === "completed" && item.instance.completed_at)
|
||||
.sort((a, b) =>
|
||||
(b.instance.completed_at ?? "").localeCompare(a.instance.completed_at ?? ""),
|
||||
);
|
||||
return [...overdue, ...completed];
|
||||
}
|
||||
|
||||
function AttentionRow({ item }: { item: AttentionItem }) {
|
||||
const { instance, childName } = item;
|
||||
const overdue = isOverdueOpen(instance);
|
||||
// TODO(e57facc2): wire "Mark done" / "Undo" mutations and live updates.
|
||||
// The admin plane has no complete route yet, so both actions are inert here.
|
||||
return (
|
||||
<li className="today-event" data-testid={`attention-${instance.id}`}>
|
||||
<span
|
||||
className={overdue ? "today-event-glyph today-event-glyph--overdue" : "today-event-glyph"}
|
||||
>
|
||||
{overdue ? <AlertCircleGlyph /> : <CheckCircleGlyph />}
|
||||
</span>
|
||||
<div className="today-event-text">
|
||||
<div className="today-event-title">
|
||||
{instance.title} · {childName}
|
||||
</div>
|
||||
{overdue ? (
|
||||
<div className="today-event-meta today-event-meta--overdue">
|
||||
{instance.due_time
|
||||
? `Overdue since ${formatDueTime(instance.due_time)}`
|
||||
: "Overdue"}
|
||||
</div>
|
||||
) : (
|
||||
// The snapshot carries no completion actor, so "from panel" is not printed.
|
||||
<div className="today-event-meta">
|
||||
Completed {formatClockTime(instance.completed_at as string)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={overdue ? "today-action today-action--done" : "today-action today-action--undo"}
|
||||
>
|
||||
{overdue ? "Mark done" : "Undo"}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionNote() {
|
||||
return (
|
||||
<div className="today-note" data-testid="permission-note">
|
||||
<span className="today-note-glyph">
|
||||
<InfoGlyph />
|
||||
</span>
|
||||
<span>{PERMISSION_NOTE}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TodayTab() {
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ kind: "loading" });
|
||||
fetchSnapshot()
|
||||
.then((snapshot) => {
|
||||
if (!cancelled) setState({ kind: "ready", snapshot });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message =
|
||||
error instanceof ApiForbiddenError
|
||||
? error.message
|
||||
: "Today's snapshot could not be loaded. Check your connection and try again.";
|
||||
setState({ kind: "error", message });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [attempt]);
|
||||
|
||||
let sub = "";
|
||||
let body;
|
||||
if (state.kind === "loading") {
|
||||
body = (
|
||||
<div className="today-status" role="status" data-testid="today-loading">
|
||||
Loading today…
|
||||
</div>
|
||||
);
|
||||
} else if (state.kind === "error") {
|
||||
body = (
|
||||
<div className="today-status today-status--error" role="alert" data-testid="today-error">
|
||||
<p>{state.message}</p>
|
||||
<button type="button" className="today-retry" onClick={() => setAttempt((n) => n + 1)}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const { snapshot } = state;
|
||||
sub = `${formatLongDate(snapshot.today_iso)} · cycle day ${snapshot.cycle_day} of ${CYCLE_LENGTH_DAYS}`;
|
||||
const attention = attentionItems(snapshot);
|
||||
body = (
|
||||
<>
|
||||
<StatRow snapshot={snapshot} />
|
||||
<ul className="today-card today-list" aria-label="Children">
|
||||
{snapshot.children.map((child) => (
|
||||
<ChildRow key={child.child_id} child={child} />
|
||||
))}
|
||||
</ul>
|
||||
<h3 className="today-section-label">Needs attention</h3>
|
||||
{attention.length > 0 ? (
|
||||
<ul className="today-card today-list" aria-label="Needs attention">
|
||||
{attention.map((item) => (
|
||||
<AttentionRow key={item.instance.id} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="today-card today-empty">Nothing needs attention.</div>
|
||||
)}
|
||||
<PermissionNote />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="today-screen">
|
||||
<ScreenHeader sub={sub} />
|
||||
<div
|
||||
className="today-scroll"
|
||||
data-testid="today-scroll"
|
||||
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Date/time formatting for the Today tab.
|
||||
*
|
||||
* Calendar dates ("YYYY-MM-DD") are parsed by their components and formatted
|
||||
* in UTC, so the printed day never shifts with the viewer's offset — unlike
|
||||
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
|
||||
* day in negative-offset zones.
|
||||
*/
|
||||
|
||||
const LOCALE = "en-US";
|
||||
|
||||
function calendarDate(iso: string): Date {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
||||
if (!match) throw new Error(`Invalid calendar date: ${iso}`);
|
||||
const [, year, month, day] = match;
|
||||
return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
|
||||
}
|
||||
|
||||
function format(date: Date, options: Intl.DateTimeFormatOptions): string {
|
||||
// Newer ICU puts a narrow no-break space before AM/PM; normalise it.
|
||||
return new Intl.DateTimeFormat(LOCALE, options)
|
||||
.format(date)
|
||||
.replace(/ /g, " ");
|
||||
}
|
||||
|
||||
/** "Wednesday, September 23" */
|
||||
export function formatLongDate(iso: string): string {
|
||||
const date = calendarDate(iso);
|
||||
const weekday = format(date, { weekday: "long", timeZone: "UTC" });
|
||||
const monthDay = format(date, { month: "long", day: "numeric", timeZone: "UTC" });
|
||||
return `${weekday}, ${monthDay}`;
|
||||
}
|
||||
|
||||
/** "Sep 23" */
|
||||
export function formatShortDate(iso: string): string {
|
||||
return format(calendarDate(iso), { month: "short", day: "numeric", timeZone: "UTC" });
|
||||
}
|
||||
|
||||
/** "09:00" -> "9:00 AM" (a wall-clock time, no zone conversion). */
|
||||
export function formatDueTime(hhmm: string): string {
|
||||
const match = /^(\d{2}):(\d{2})/.exec(hhmm);
|
||||
if (!match) return hhmm;
|
||||
const date = new Date(Date.UTC(2000, 0, 1, Number(match[1]), Number(match[2])));
|
||||
return format(date, { hour: "numeric", minute: "2-digit", timeZone: "UTC" });
|
||||
}
|
||||
|
||||
/** A UTC ISO timestamp shown as the viewer's local time, "7:04 AM". */
|
||||
export function formatClockTime(isoTimestamp: string): string {
|
||||
return format(new Date(isoTimestamp), { hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Inline stroke glyphs for the Today tab (Lucide-style 24px grid). The
|
||||
* shipped Lucide subset is task b50b048f; these keep this screen CDN-free
|
||||
* until then.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
function Glyph({ size, children }: { size: number; children: ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsGlyph({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1 7 17M17 7l2.1-2.1" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4M12 8h.01" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertCircleGlyph({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckCircleGlyph({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"useDefineForClassFields": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/* Build-time service worker injection. After Vite writes the bundle, the
|
||||
hashed asset URLs referenced by dist/index.html and a content-derived
|
||||
revision are written into dist/sw.js, so the offline shell is complete right
|
||||
after install and every release ships a new worker with fresh caches.
|
||||
Build tooling only — never imported by the app bundle. */
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
export const REVISION_MARKER = "__NQ_BUILD_REVISION__";
|
||||
const PRECACHE_PLACEHOLDER = "const PRECACHE_ASSETS = [];";
|
||||
// Unhashed files sw.js precaches by fixed URL, relative to the build output.
|
||||
export const STATIC_PRECACHE_FILES = [
|
||||
"manifest.webmanifest",
|
||||
"icons/icon-192.png",
|
||||
"icons/icon-512.png",
|
||||
"icons/icon-maskable-512.png",
|
||||
];
|
||||
|
||||
export interface PrecacheFile {
|
||||
path: string;
|
||||
bytes: string | Uint8Array;
|
||||
}
|
||||
|
||||
// Every same-origin URL an index.html loads via <script src> or <link href>,
|
||||
// normalised to an absolute path, in document order and without duplicates.
|
||||
export function extractShellAssets(indexHtml: string): string[] {
|
||||
const tags = indexHtml.match(/<(?:script|link)\b[^>]*>/gi) ?? [];
|
||||
const urls = tags.flatMap((tag) => {
|
||||
const attr = /\b(?:src|href)\s*=\s*["']([^"']+)["']/i.exec(tag);
|
||||
if (!attr) return [];
|
||||
const url = new URL(attr[1], "https://same-origin.invalid/");
|
||||
return url.origin === "https://same-origin.invalid" ? [url.pathname] : [];
|
||||
});
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
// Hash of every precached file, sorted by path; each entry is length-prefixed
|
||||
// so no two different file sets hash the same concatenation.
|
||||
export function computeRevision(files: PrecacheFile[]): string {
|
||||
const hash = createHash("sha256");
|
||||
for (const { path, bytes } of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
|
||||
const data = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes;
|
||||
hash.update(`${path}\0${data.byteLength}\0`).update(data);
|
||||
}
|
||||
return hash.digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
// Returns swSource with the precache list and the build revision filled in.
|
||||
// The revision covers index.html (and so the hashed bundles it names), the
|
||||
// sw.js source and the bytes of every unhashed static file it precaches.
|
||||
export function injectServiceWorker(
|
||||
indexHtml: string,
|
||||
swSource: string,
|
||||
staticFiles: PrecacheFile[],
|
||||
): string {
|
||||
if (!swSource.includes(PRECACHE_PLACEHOLDER) || !swSource.includes(REVISION_MARKER)) {
|
||||
throw new Error("sw.js is missing the PRECACHE_ASSETS placeholder or revision marker");
|
||||
}
|
||||
const assets = extractShellAssets(indexHtml);
|
||||
const revision = computeRevision([
|
||||
{ path: "index.html", bytes: indexHtml },
|
||||
{ path: "sw.js", bytes: swSource },
|
||||
...staticFiles,
|
||||
]);
|
||||
return swSource
|
||||
.replace(PRECACHE_PLACEHOLDER, `const PRECACHE_ASSETS = ${JSON.stringify(assets)};`)
|
||||
.replaceAll(REVISION_MARKER, revision);
|
||||
}
|
||||
|
||||
export function serviceWorkerInjectPlugin(): Plugin {
|
||||
let outDir = "";
|
||||
return {
|
||||
name: "nestquest-sw-inject",
|
||||
apply: "build",
|
||||
configResolved(config) {
|
||||
outDir = resolve(config.root, config.build.outDir);
|
||||
},
|
||||
writeBundle() {
|
||||
const swPath = resolve(outDir, "sw.js");
|
||||
const indexHtml = readFileSync(resolve(outDir, "index.html"), "utf8");
|
||||
const staticFiles = STATIC_PRECACHE_FILES.map((path) => ({
|
||||
path,
|
||||
bytes: readFileSync(resolve(outDir, path)),
|
||||
}));
|
||||
writeFileSync(
|
||||
swPath,
|
||||
injectServiceWorker(indexHtml, readFileSync(swPath, "utf8"), staticFiles),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { serviceWorkerInjectPlugin } from "./vite-sw-plugin";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), serviceWorkerInjectPlugin()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
},
|
||||
});
|
||||
+527
-18
@@ -30,10 +30,10 @@ hand-rolled here), and the call goes to
|
||||
``set_quest_definition_active``. The title policy, the window-name
|
||||
and due-time policy, the assignee checks, the whole-set window
|
||||
replacement and the never-hard-delete policy (deactivation is the only
|
||||
removal path) all live in the core. Assignment is NOT settable
|
||||
through the API: the core's edit path takes no assignees. The edit
|
||||
route forwards only SUPPLIED fields (``exclude_unset``), like the
|
||||
children edit.
|
||||
removal path) all live in the core. The edit route forwards only
|
||||
SUPPLIED fields (``exclude_unset``), like the children edit; a supplied
|
||||
``assignee_child_ids`` list REPLACES the whole assignee set inside the
|
||||
core edit's one transaction (and its one regeneration).
|
||||
|
||||
The presence routes follow the same pattern over
|
||||
:mod:`nestquest_core.presence_management` — the ONE presence write
|
||||
@@ -58,6 +58,24 @@ layer the HA services share, so the two planes cannot drift:
|
||||
(children are never hard-deleted; overrides are deletable by
|
||||
design) and regenerates that override's child. It answers
|
||||
``{"status": "ok"}``.
|
||||
- ``POST /presence-overrides/consequence`` previews an override
|
||||
BEFORE it is saved: ONE ``preview_presence_override_consequence``
|
||||
call answers ``{removed: N}``, the number of the child's open
|
||||
upcoming instances the materialization walk would stop generating
|
||||
(instances with a completion event are never counted, D-005).
|
||||
Nothing is written; an unknown child is 404, a malformed or inverted
|
||||
range 422.
|
||||
- ``GET /children/{child_id}/presence-schedule`` reads the child's
|
||||
schedule through ONE ``get_presence_schedule`` call, wrapped as
|
||||
``{"schedule": ...}`` in the PUT response's shape — ``null`` when the
|
||||
child has none (present every day), never a 404; an unknown child is
|
||||
404.
|
||||
- ``GET /presence-overrides`` lists the household's overrides through
|
||||
ONE ``list_presence_overrides`` call, wrapped as
|
||||
``{"overrides": [...]}`` in the POST response's shape, ordered by
|
||||
start date, child id, id; the optional ``child_id``, ``start`` and
|
||||
``end`` query filters (strict dates, ``end >= start``) are the
|
||||
core's to validate.
|
||||
|
||||
The uncomplete and regenerate routes (task da0226b3) follow the same
|
||||
pattern over :mod:`nestquest_core.completion` and
|
||||
@@ -116,6 +134,22 @@ owns every rule so the two routes cannot drift:
|
||||
a ``Content-Disposition`` attachment filename and the documented,
|
||||
stable header row (Admin spec §5 event-row fields).
|
||||
|
||||
The snapshot route (task ef3f4877) is the admin-plane twin of the
|
||||
panel's ``GET /api/v1/panel/snapshot`` for the admin PWA's Today tab —
|
||||
the PWA is internet-facing and never holds the panel service token
|
||||
(D-012), so it reads the same snapshot here:
|
||||
|
||||
- ``GET /snapshot`` threads ONE clock read (:func:`_local_now`) into
|
||||
:func:`nestquest_core.snapshot.build_snapshot` and shapes each
|
||||
child's instances through
|
||||
:func:`nestquest_core.snapshot.instance_payload` with
|
||||
``include_missed=True`` — admin payloads KEEP missed instances with
|
||||
their ``missed`` state (D-009), where the panel omits them. The
|
||||
body is the panel's documented shape (``today_iso``, ``cycle_day``,
|
||||
``children`` with the per-child presence, rollup counts and
|
||||
``instances``); the builder, the state derivation and the shaping
|
||||
all live in the core.
|
||||
|
||||
The settings routes (task 2b3de7e5) follow the same pattern over
|
||||
:mod:`nestquest_core.settings_store` — the API's OWN durable settings
|
||||
store. The API now OWNS settings in the database (a validated JSON
|
||||
@@ -156,6 +190,33 @@ integration's rollover listener shares:
|
||||
read-only-over-the-domain-tables guarantee all live in the core; the
|
||||
response reports how many transitions were published.
|
||||
|
||||
The occurrence-preview route (task 5f843564) lets the admin PWA's
|
||||
Definitions edit sheet show the next dates a rule fires on WITHOUT
|
||||
reimplementing the recurrence rules client-side:
|
||||
|
||||
- ``POST /quest-definitions/occurrences-preview`` takes the SAME
|
||||
``rule`` object create/edit take (:class:`AdminRuleRequest`, built
|
||||
through the SAME :func:`_rule_from_request` / core ``from_dict``
|
||||
validation, so a rejected rule is 422 exactly as on create), an
|
||||
optional ``start_date`` (default: the API host's local ``today``,
|
||||
ONE :func:`_local_now` clock read) and an optional ``count`` (1..50,
|
||||
default 10). The dates come from ONE
|
||||
:func:`nestquest_core.recurrence.occurrences_between` call — the
|
||||
same engine the materializer walks — so the preview can only agree
|
||||
with what would be generated. The scan is BOUNDED server-side: the
|
||||
window is ``[start_date, start_date + 366 days]`` inclusive,
|
||||
whatever the caller sends, so every rule shape (a yearly rule
|
||||
included) shows at least its next occurrence, and a caller can never
|
||||
ask for an unbounded walk. Any strict ``start_date`` is accepted
|
||||
rather than clamped or range-limited — the fixed window already
|
||||
bounds the work — EXCEPT one whose window would reach 9999-12-31,
|
||||
which is 422 (the core's day walk cannot step past the calendar's
|
||||
last date). ``start_date`` strictness
|
||||
(``YYYY-MM-DD``) is the core's own date validator; a non-strict or
|
||||
unparseable value is 422. A ``count`` outside 1..50 is the body
|
||||
model's 422 before any handler code runs. Nothing is read or
|
||||
written: the route touches no database.
|
||||
|
||||
Error mapping (every children, quest-definition and presence route,
|
||||
deliberately narrow):
|
||||
|
||||
@@ -166,8 +227,9 @@ deliberately narrow):
|
||||
set_active on an unknown id) is mapped to 404.
|
||||
- A core ``ValueError`` reporting a NON-EXISTENT DEFINITION (edit or
|
||||
set_active on an unknown id — the core prefixes that error with
|
||||
``definition_id:``) is mapped to 404. Create's rejected-assignee
|
||||
error names a child from the BODY, not a path id, so it maps to 422.
|
||||
``definition_id:``) is mapped to 404. Create's and edit's
|
||||
rejected-assignee error names a child from the BODY, not a path id,
|
||||
so it maps to 422.
|
||||
- Every OTHER core ``ValueError`` — an empty display name, an explicit
|
||||
``null`` colour/avatar_ref (clearing is not supported), a reorder
|
||||
list that is not a complete permutation of the children (missing,
|
||||
@@ -228,6 +290,16 @@ an omitted field is never passed to
|
||||
:func:`nestquest_core.children.edit_child` and stays unchanged — while
|
||||
an explicit ``null`` IS passed and is rejected by the core rather than
|
||||
silently ignored.
|
||||
|
||||
The EVENTS route (``GET /events``) is the admin plane's live
|
||||
transition stream for the Admin PWA: the SAME four transitions
|
||||
(quest-completed, quest-uncompleted, quest-missed, child-day-complete)
|
||||
off the SAME in-process publisher as the panel plane, framed by the
|
||||
shared :func:`api.sse.transition_event_stream` helper. It inherits the
|
||||
router's ONE :func:`~api.auth.require_admin` check (the Authorization
|
||||
header — the PWA reads it with a fetch-based reader because
|
||||
``EventSource`` cannot set headers), so the panel service token is
|
||||
refused here exactly as on every other admin route.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -235,7 +307,14 @@ import datetime
|
||||
from typing import Annotated, Literal, NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
StrictBool,
|
||||
StrictInt,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
from api import transitions as api_transitions
|
||||
from api.auth import require_admin
|
||||
@@ -254,8 +333,10 @@ from api.nestquest_core import (
|
||||
core_recurrence,
|
||||
core_settings,
|
||||
core_settings_store,
|
||||
core_snapshot,
|
||||
core_sweep,
|
||||
)
|
||||
from api.sse import transition_event_stream
|
||||
|
||||
#: All admin-plane routes share this router; the ONE admin dependency
|
||||
#: (service-token refusal + JWT verification + nestquest-admins group
|
||||
@@ -346,11 +427,18 @@ class AdminQuestDefinitionResponse(BaseModel):
|
||||
description: str | None
|
||||
icon: str | None
|
||||
is_active: bool
|
||||
skip_on_away: bool
|
||||
rule: AdminRuleResponse
|
||||
assignees: list[AdminAssigneeResponse]
|
||||
windows: list[AdminDefinitionWindowResponse]
|
||||
|
||||
|
||||
class AdminQuestDefinitionListResponse(BaseModel):
|
||||
"""The definitions list: every definition, active AND inactive, by id."""
|
||||
|
||||
definitions: list[AdminQuestDefinitionResponse]
|
||||
|
||||
|
||||
class AdminChildCreateRequest(BaseModel):
|
||||
"""The body of ``POST /api/v1/admin/children``.
|
||||
|
||||
@@ -421,24 +509,67 @@ class AdminRuleRequest(BaseModel):
|
||||
end_date: str | None = None
|
||||
|
||||
|
||||
#: Upper bound on ``count`` for the occurrence-preview route.
|
||||
_PREVIEW_MAX_COUNT = 50
|
||||
|
||||
#: Days after ``start_date`` the occurrence-preview window spans
|
||||
#: (inclusive), so a yearly rule always shows its next occurrence.
|
||||
_PREVIEW_WINDOW_DAYS = 366
|
||||
|
||||
|
||||
class AdminOccurrencesPreviewRequest(BaseModel):
|
||||
"""The body of ``POST /api/v1/admin/quest-definitions/occurrences-preview``.
|
||||
|
||||
``rule`` is the SAME object create/edit take. ``start_date`` is an
|
||||
optional strict ``YYYY-MM-DD`` (validated by the core; default the
|
||||
API host's today). ``count`` is a strict JSON integer in
|
||||
1..``_PREVIEW_MAX_COUNT`` — anything else is 422 before any handler
|
||||
code runs.
|
||||
"""
|
||||
|
||||
rule: AdminRuleRequest
|
||||
start_date: str | None = None
|
||||
count: StrictInt = 10
|
||||
|
||||
@field_validator("count")
|
||||
@classmethod
|
||||
def _count_in_range(cls, value: int) -> int:
|
||||
"""Reject a count outside 1.._PREVIEW_MAX_COUNT."""
|
||||
if not 1 <= value <= _PREVIEW_MAX_COUNT:
|
||||
raise ValueError(
|
||||
f"count must be between 1 and {_PREVIEW_MAX_COUNT}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class AdminOccurrencesPreviewResponse(BaseModel):
|
||||
"""The next occurrence dates (``YYYY-MM-DD``, ascending)."""
|
||||
|
||||
dates: list[str]
|
||||
|
||||
|
||||
class AdminQuestDefinitionCreateRequest(BaseModel):
|
||||
"""The body of ``POST /api/v1/admin/quest-definitions``.
|
||||
|
||||
``title``, ``rule``, ``assignee_child_ids`` and ``windows`` are
|
||||
required (the core rejects an empty title, a non-list or empty
|
||||
assignee/window list, and unknown window names); ``description``
|
||||
and ``icon`` are optional and default to NULL through the core.
|
||||
``windows`` entries are a window name (``morning``) or a two-item
|
||||
and ``icon`` are optional and default to NULL through the core, and
|
||||
``skip_on_away`` is optional and defaults to true (no instance on a
|
||||
date the child is absent). ``windows`` entries are a window name (``morning``) or a two-item
|
||||
``[window, due_time]`` pair whose due time is a strict 24-hour
|
||||
HH:MM (or ``null`` for none) — both forms the core normalizes.
|
||||
Assignee ids are strict JSON integers: a boolean, float or numeric
|
||||
string is 422, never coerced to a child id.
|
||||
"""
|
||||
|
||||
title: str
|
||||
rule: AdminRuleRequest
|
||||
assignee_child_ids: list[int]
|
||||
assignee_child_ids: list[StrictInt]
|
||||
windows: list[str | tuple[str, str | None]]
|
||||
description: str | None = None
|
||||
icon: str | None = None
|
||||
skip_on_away: StrictBool = True
|
||||
|
||||
|
||||
class AdminQuestDefinitionEditRequest(BaseModel):
|
||||
@@ -450,10 +581,13 @@ class AdminQuestDefinitionEditRequest(BaseModel):
|
||||
field stays unchanged. An explicit ``null`` ``description``/
|
||||
``icon`` IS passed and CLEARS the stored value (the core supports
|
||||
clearing here, unlike the children edit); an explicit ``null``
|
||||
``rule`` or ``windows`` is passed and rejected by the core. A
|
||||
supplied ``windows`` list REPLACES the whole window set, and
|
||||
``assignees`` are deliberately absent — assignment is not settable
|
||||
through this route.
|
||||
``rule``, ``windows`` or ``assignee_child_ids`` is passed and
|
||||
rejected by the core. A supplied ``windows`` list REPLACES the
|
||||
whole window set, and a supplied ``assignee_child_ids`` list
|
||||
REPLACES the whole assignee set (non-empty, every id an existing
|
||||
active child — create's contract; a rejection changes nothing). An
|
||||
omitted (or ``null``) ``skip_on_away`` keeps the stored value.
|
||||
Assignee ids are strict JSON integers, as on create.
|
||||
"""
|
||||
|
||||
title: str | None = None
|
||||
@@ -461,6 +595,8 @@ class AdminQuestDefinitionEditRequest(BaseModel):
|
||||
icon: str | None = None
|
||||
rule: AdminRuleRequest | None = None
|
||||
windows: list[str | tuple[str, str | None]] | None = None
|
||||
skip_on_away: StrictBool | None = None
|
||||
assignee_child_ids: list[StrictInt] | None = None
|
||||
|
||||
|
||||
class AdminQuestDefinitionActiveRequest(BaseModel):
|
||||
@@ -533,6 +669,50 @@ class AdminPresenceOverrideResponse(BaseModel):
|
||||
note: str | None
|
||||
|
||||
|
||||
class AdminPresenceScheduleReadResponse(BaseModel):
|
||||
"""The ``GET /children/{child_id}/presence-schedule`` payload.
|
||||
|
||||
``schedule`` is ``None`` when the child has no schedule — the child
|
||||
is present every day (Feature 05); absence is state, not an error.
|
||||
"""
|
||||
|
||||
schedule: AdminPresenceScheduleResponse | None
|
||||
|
||||
|
||||
class AdminPresenceOverrideListResponse(BaseModel):
|
||||
"""The ``GET /presence-overrides`` payload: the matching overrides."""
|
||||
|
||||
overrides: list[AdminPresenceOverrideResponse]
|
||||
|
||||
|
||||
class AdminPresenceOverrideConsequenceRequest(BaseModel):
|
||||
"""The body of ``POST /api/v1/admin/presence-overrides/consequence``.
|
||||
|
||||
The create body's shape minus the note (which cannot change what is
|
||||
generated): the strict dates, their order and the real
|
||||
``is_present`` bool are enforced by the SAME
|
||||
:class:`~nestquest_core.presence.PresenceOverride` constructor the
|
||||
create route's core path builds; this model only guards the SHAPE.
|
||||
Strict fields: a JSON ``true`` is not child id 1, nor ``1`` a bool.
|
||||
"""
|
||||
|
||||
child_id: StrictInt
|
||||
start_date: str
|
||||
end_date: str
|
||||
is_present: StrictBool
|
||||
|
||||
|
||||
class AdminPresenceOverrideConsequenceResponse(BaseModel):
|
||||
"""The consequence preview: ``removed`` upcoming instances.
|
||||
|
||||
The number of open (no completion event) upcoming quest instances
|
||||
of the child that saving the override would remove — the "This
|
||||
removes N upcoming tasks" warning. Always 0 for a present override.
|
||||
"""
|
||||
|
||||
removed: int
|
||||
|
||||
|
||||
class AdminUncompleteResponse(BaseModel):
|
||||
"""The result of ``POST /api/v1/admin/instances/{id}/uncomplete``.
|
||||
|
||||
@@ -683,6 +863,59 @@ class AdminMissedSweepResponse(BaseModel):
|
||||
fired: int
|
||||
|
||||
|
||||
class AdminInstancePayload(BaseModel):
|
||||
"""One instance in the admin snapshot payload.
|
||||
|
||||
Mirrors :func:`nestquest_core.snapshot.instance_payload` with
|
||||
``include_missed=True``: ``state`` is ``open`` | ``completed`` |
|
||||
``missed`` (admin payloads keep missed instances, D-009),
|
||||
``on_time`` passes the completion event's flag through verbatim
|
||||
(``None`` unless completed), and ``completed_at`` is the event's
|
||||
UTC timestamp when completed.
|
||||
"""
|
||||
|
||||
id: int
|
||||
definition_id: int
|
||||
child_id: int
|
||||
title: str
|
||||
icon: str | None
|
||||
window: str
|
||||
due_time: str | None
|
||||
state: str
|
||||
overdue: bool
|
||||
completed_at: str | None
|
||||
on_time: bool | None
|
||||
|
||||
|
||||
class AdminChildSnapshotPayload(BaseModel):
|
||||
"""One child's day: presence, rollup counts, and today's instances."""
|
||||
|
||||
child_id: int
|
||||
child_name: str
|
||||
present: bool
|
||||
#: ISO date of the child's next present day while they are away;
|
||||
#: ``None`` when the child is present today.
|
||||
next_present: str | None
|
||||
due_today: int
|
||||
completed_today: int
|
||||
remaining_today: int
|
||||
completion_pct: int
|
||||
instances: list[AdminInstancePayload]
|
||||
|
||||
|
||||
class AdminSnapshotResponse(BaseModel):
|
||||
"""Today's whole household snapshot for the admin Today tab.
|
||||
|
||||
The panel snapshot's shape (``today_iso``, ``cycle_day``,
|
||||
``children`` in the household's sort order), with missed instances
|
||||
kept in each child's ``instances``.
|
||||
"""
|
||||
|
||||
today_iso: str
|
||||
cycle_day: int
|
||||
children: list[AdminChildSnapshotPayload]
|
||||
|
||||
|
||||
#: 404 detail for a child id the core layer reports as non-existent.
|
||||
_CHILD_NOT_FOUND_DETAIL = "Child not found"
|
||||
|
||||
@@ -845,6 +1078,40 @@ def _raise_sweep_error(error: ValueError) -> NoReturn:
|
||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||
|
||||
|
||||
def _raise_preview_error(error: ValueError) -> NoReturn:
|
||||
"""Map a core recurrence ``ValueError`` from the preview route to 422.
|
||||
|
||||
The preview route has no path ids, so it has no 404 case: a
|
||||
rejected rule (:class:`~nestquest_core.recurrence.RuleValidationError`)
|
||||
or a non-strict ``start_date`` is 422 with the core's message as
|
||||
the detail. Only ``ValueError`` is caught, so an unexpected
|
||||
failure re-raises rather than becoming a 4xx.
|
||||
"""
|
||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||
|
||||
|
||||
def _preview_window_end(start_date: str) -> str:
|
||||
"""Return the preview window's inclusive end for ``start_date``.
|
||||
|
||||
``start_date`` + :data:`_PREVIEW_WINDOW_DAYS`. Parsing here only
|
||||
locates the window; the STRICT ``YYYY-MM-DD`` check stays the
|
||||
core's — :func:`~nestquest_core.recurrence.occurrences_between`
|
||||
re-validates the same string and rejects any lenient form this
|
||||
parse accepted. Raises ``ValueError`` for an unparseable value and
|
||||
for a start whose window would reach the calendar's last date (the
|
||||
core's day walk steps one day PAST its end, which overflows there).
|
||||
"""
|
||||
start = datetime.date.fromisoformat(start_date)
|
||||
if datetime.date.max - start <= datetime.timedelta(
|
||||
days=_PREVIEW_WINDOW_DAYS
|
||||
):
|
||||
raise ValueError(
|
||||
f"start_date {start_date!r} is too close to the end of the "
|
||||
"calendar for a preview window"
|
||||
)
|
||||
return (start + datetime.timedelta(days=_PREVIEW_WINDOW_DAYS)).isoformat()
|
||||
|
||||
|
||||
def _local_now() -> datetime.datetime:
|
||||
"""Return the API host's local time as ONE timezone-aware clock read.
|
||||
|
||||
@@ -887,6 +1154,7 @@ def _quest_definition_response(
|
||||
description=bundle.definition.description,
|
||||
icon=bundle.definition.icon,
|
||||
is_active=bundle.definition.is_active,
|
||||
skip_on_away=bundle.definition.skip_on_away,
|
||||
rule=_rule_response(bundle.rule),
|
||||
assignees=[
|
||||
AdminAssigneeResponse(id=child.id, display_name=child.display_name)
|
||||
@@ -1098,6 +1366,32 @@ async def admin_reorder_children(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/quest-definitions",
|
||||
summary="List quest definitions",
|
||||
response_model=AdminQuestDefinitionListResponse,
|
||||
)
|
||||
async def admin_list_quest_definitions(
|
||||
request: Request,
|
||||
) -> AdminQuestDefinitionListResponse:
|
||||
"""Return every quest definition in rising id order.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter: one
|
||||
:func:`nestquest_core.quest_definitions.list_all_definitions` call,
|
||||
active AND inactive definitions included (the admin screen manages
|
||||
both), each serialized exactly as the create/edit responses are.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
bundles = await core_quest_definitions.list_all_definitions(
|
||||
state.database
|
||||
)
|
||||
return AdminQuestDefinitionListResponse(
|
||||
definitions=[_quest_definition_response(bundle) for bundle in bundles]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/quest-definitions",
|
||||
summary="Create a quest definition",
|
||||
@@ -1132,12 +1426,49 @@ async def admin_create_quest_definition(
|
||||
body.windows,
|
||||
description=body.description,
|
||||
icon=body.icon,
|
||||
skip_on_away=body.skip_on_away,
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_quest_definition_error(error)
|
||||
return _quest_definition_response(bundle)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/quest-definitions/occurrences-preview",
|
||||
summary="Preview the next occurrence dates of a schedule rule",
|
||||
response_model=AdminOccurrencesPreviewResponse,
|
||||
)
|
||||
async def admin_preview_occurrences(
|
||||
body: AdminOccurrencesPreviewRequest,
|
||||
) -> AdminOccurrencesPreviewResponse:
|
||||
"""Return the first ``count`` dates the supplied rule fires on.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter: the ``rule``
|
||||
becomes ONE :class:`~nestquest_core.recurrence.ScheduleRule`
|
||||
(:func:`_rule_from_request`, the create/edit path), and the dates
|
||||
come from ONE :func:`nestquest_core.recurrence.occurrences_between`
|
||||
call over the bounded window ``[start_date, start_date + 366
|
||||
days]`` — ``start_date`` defaulting to ONE :func:`_local_now` clock
|
||||
read — truncated to ``count``. Every core ``ValueError`` is 422
|
||||
(:func:`_raise_preview_error`). No database access.
|
||||
"""
|
||||
start_date = (
|
||||
body.start_date
|
||||
if body.start_date is not None
|
||||
else _local_now().date().isoformat()
|
||||
)
|
||||
try:
|
||||
rule = _rule_from_request(body.rule)
|
||||
dates = core_recurrence.occurrences_between(
|
||||
rule, start_date, _preview_window_end(start_date)
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_preview_error(error)
|
||||
return AdminOccurrencesPreviewResponse(dates=dates[: body.count])
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/quest-definitions/{definition_id}",
|
||||
summary="Edit a quest definition",
|
||||
@@ -1157,10 +1488,12 @@ async def admin_edit_quest_definition(
|
||||
field is never passed to
|
||||
:func:`nestquest_core.quest_definitions.edit_quest_definition` and
|
||||
stays unchanged; a supplied ``windows`` list replaces the WHOLE
|
||||
window set in the core, and a supplied ``rule`` is rebuilt into a
|
||||
ScheduleRule (:func:`_rule_from_request`, validated by the model).
|
||||
An explicit ``null`` ``rule``/``windows`` reaches the core as None
|
||||
and is rejected there (422), while an explicit ``null``
|
||||
window set in the core, a supplied ``assignee_child_ids`` list
|
||||
replaces the WHOLE assignee set in the same core transaction, and a
|
||||
supplied ``rule`` is rebuilt into a ScheduleRule
|
||||
(:func:`_rule_from_request`, validated by the model). An explicit
|
||||
``null`` ``rule``/``windows``/``assignee_child_ids`` reaches the
|
||||
core as None and is rejected there (422), while an explicit ``null``
|
||||
``description``/``icon`` clears the stored value — the core edit
|
||||
path supports clearing, unlike the children edit. An unknown
|
||||
definition id is mapped to 404 and any other core ``ValueError``
|
||||
@@ -1322,6 +1655,114 @@ async def admin_delete_presence_override(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/presence-overrides/consequence",
|
||||
summary="Count the upcoming instances a presence override would remove",
|
||||
response_model=AdminPresenceOverrideConsequenceResponse,
|
||||
)
|
||||
async def admin_presence_override_consequence(
|
||||
body: AdminPresenceOverrideConsequenceRequest, request: Request
|
||||
) -> AdminPresenceOverrideConsequenceResponse:
|
||||
"""Return how many upcoming instances saving the override removes.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter: ONE
|
||||
:func:`nestquest_core.presence_management.preview_presence_override_consequence`
|
||||
call, anchored on ONE :func:`_local_now` clock read, which compares
|
||||
the materialization walk's output for the child with and without
|
||||
the proposed override and excludes instances that already have a
|
||||
completion event (D-005). Nothing is written.
|
||||
|
||||
Errors are mapped by :func:`_raise_child_error`: an unknown child
|
||||
is 404; a malformed or inverted range is 422.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
try:
|
||||
removed = await (
|
||||
core_presence_management.preview_presence_override_consequence(
|
||||
state.database,
|
||||
body.child_id,
|
||||
body.start_date,
|
||||
body.end_date,
|
||||
body.is_present,
|
||||
today=_local_now().date(),
|
||||
)
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_child_error(error)
|
||||
return AdminPresenceOverrideConsequenceResponse(removed=removed)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/children/{child_id}/presence-schedule",
|
||||
summary="Read a child's repeating presence schedule",
|
||||
response_model=AdminPresenceScheduleReadResponse,
|
||||
)
|
||||
async def admin_get_presence_schedule(
|
||||
child_id: int, request: Request
|
||||
) -> AdminPresenceScheduleReadResponse:
|
||||
"""Return the child's presence schedule, or ``schedule: null``.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter: one
|
||||
:func:`nestquest_core.presence_management.get_presence_schedule`
|
||||
call; a stored schedule is serialized exactly as the PUT response
|
||||
is, and a child without one answers ``{"schedule": null}`` (present
|
||||
every day). Errors are mapped by :func:`_raise_child_error`: an
|
||||
unknown child is 404.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
try:
|
||||
schedule = await core_presence_management.get_presence_schedule(
|
||||
state.database, child_id
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_child_error(error)
|
||||
return AdminPresenceScheduleReadResponse(
|
||||
schedule=(
|
||||
None if schedule is None else _presence_schedule_response(schedule)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/presence-overrides",
|
||||
summary="List presence overrides",
|
||||
response_model=AdminPresenceOverrideListResponse,
|
||||
)
|
||||
async def admin_list_presence_overrides(
|
||||
request: Request,
|
||||
child_id: int | None = None,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
) -> AdminPresenceOverrideListResponse:
|
||||
"""Return the household's presence overrides, optionally filtered.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter: the optional
|
||||
``child_id``, ``start`` and ``end`` query parameters go straight to
|
||||
:func:`nestquest_core.presence_management.list_presence_overrides`
|
||||
— the date policy (strict ``YYYY-MM-DD``, ``end >= start``) and the
|
||||
overlap rule live in the core — and each record is serialized
|
||||
exactly as the POST response is, ordered by start date, child id,
|
||||
id. Errors are mapped by :func:`_raise_child_error`: an unknown
|
||||
``child_id`` is 404, a rejected date or range is 422.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
try:
|
||||
records = await core_presence_management.list_presence_overrides(
|
||||
state.database, child_id=child_id, start=start, end=end
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_child_error(error)
|
||||
return AdminPresenceOverrideListResponse(
|
||||
overrides=[_presence_override_response(record) for record in records]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/uncomplete",
|
||||
summary="Reverse one completion (append a reversal event)",
|
||||
@@ -1729,4 +2170,72 @@ async def admin_run_missed_sweep(
|
||||
return AdminMissedSweepResponse(fired=len(events))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/snapshot",
|
||||
summary="Today's household snapshot",
|
||||
response_model=AdminSnapshotResponse,
|
||||
)
|
||||
async def admin_snapshot(request: Request) -> AdminSnapshotResponse:
|
||||
"""Return today's full household snapshot for the admin Today tab.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter: ONE clock read
|
||||
(:func:`_local_now`) goes into
|
||||
:func:`nestquest_core.snapshot.build_snapshot` against the live
|
||||
database, and each child's instances are shaped with
|
||||
``instance_payload(include_missed=True)`` — missed instances are
|
||||
KEPT in the admin payload (D-009) — into
|
||||
:class:`AdminSnapshotResponse`.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
snapshot = await core_snapshot.build_snapshot(
|
||||
state.database, core_settings.NestQuestSettings(), _local_now()
|
||||
)
|
||||
return AdminSnapshotResponse(
|
||||
today_iso=snapshot.today_iso,
|
||||
cycle_day=snapshot.cycle_day,
|
||||
children=[
|
||||
AdminChildSnapshotPayload(
|
||||
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=[
|
||||
AdminInstancePayload.model_validate(instance)
|
||||
for instance in core_snapshot.instance_payload(
|
||||
child.instances, include_missed=True
|
||||
)
|
||||
],
|
||||
)
|
||||
for child in snapshot.children
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events",
|
||||
summary="Stream admin transition events (SSE)",
|
||||
)
|
||||
async def admin_events(request: Request) -> StreamingResponse:
|
||||
"""Stream the app's transition events to an admin client
|
||||
(Server-Sent Events).
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own), so an unauthenticated or
|
||||
panel-token request is refused before any stream is established.
|
||||
|
||||
Returns the shared :func:`api.sse.transition_event_stream` response
|
||||
off the app's single in-process publisher (``app.state.publisher``)
|
||||
— the same publisher, framing and teardown as the panel stream.
|
||||
"""
|
||||
return transition_event_stream(request)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
+7
-30
@@ -53,9 +53,6 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import importlib
|
||||
import json
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -70,6 +67,7 @@ from api.nestquest_core import (
|
||||
core_snapshot,
|
||||
)
|
||||
from api import transitions as api_transitions
|
||||
from api.sse import transition_event_stream
|
||||
|
||||
#: The instance DAO, imported on the app's core copy for the
|
||||
#: child-mismatch guard on the complete route.
|
||||
@@ -328,23 +326,6 @@ async def panel_complete_instance(
|
||||
return {"status": str(result)}
|
||||
|
||||
|
||||
async def _sse_stream(request: Request) -> AsyncIterator[str]:
|
||||
"""Yield the SSE frames for every published transition event.
|
||||
|
||||
One subscriber of the app's single publisher (resolved off
|
||||
``app.state.publisher``); each event becomes one
|
||||
``event: <type>\\ndata: <json>\\n\\n`` frame. Breaking out of the
|
||||
publisher's subscription (on client disconnect) tears the
|
||||
subscription down cleanly — no queue is leaked.
|
||||
"""
|
||||
publisher = request.app.state.publisher
|
||||
async for event_type, payload in publisher.subscribe():
|
||||
yield (
|
||||
f"event: {event_type}\n"
|
||||
f"data: {json.dumps(payload)}\n\n"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events",
|
||||
summary="Stream panel transition events (SSE)",
|
||||
@@ -357,17 +338,13 @@ async def panel_events(request: Request) -> StreamingResponse:
|
||||
:func:`~api.dependencies.require_panel_token` dependency — the ONE
|
||||
token check; this handler performs NO auth of its own).
|
||||
|
||||
Returns a ``text/event-stream`` response whose body is produced by
|
||||
:func:`_sse_stream` off the app's single in-process publisher
|
||||
(``app.state.publisher``, installed in the lifespan). The stream
|
||||
ends when the client disconnects, at which point the subscription
|
||||
is torn down (its queue removed) with nothing leaked.
|
||||
Returns the shared :func:`api.sse.transition_event_stream` response
|
||||
off the app's single in-process publisher (``app.state.publisher``,
|
||||
installed in the lifespan). The stream ends when the client
|
||||
disconnects, at which point the subscription is torn down (its
|
||||
queue removed) with nothing leaked.
|
||||
"""
|
||||
return StreamingResponse(
|
||||
_sse_stream(request),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
return transition_event_stream(request)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
"""The ONE Server-Sent Events response helper for the API service.
|
||||
|
||||
Both SSE planes — the panel plane (``/api/v1/panel/events``, panel
|
||||
service token, LAN-only) and the admin plane (``/api/v1/admin/events``,
|
||||
Authentik admin JWT) — stream the SAME transitions off the SAME
|
||||
in-process publisher (``app.state.publisher``, see :mod:`api.events`)
|
||||
with the SAME framing and teardown, so the stream lives here once and
|
||||
each plane's route only supplies its own router-level auth.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
__all__ = ["transition_event_stream"]
|
||||
|
||||
|
||||
async def _sse_stream(request: Request) -> AsyncIterator[str]:
|
||||
"""Yield the SSE frames for every published transition event.
|
||||
|
||||
One subscriber of the app's single publisher (resolved off
|
||||
``app.state.publisher``); each event becomes one
|
||||
``event: <type>\\ndata: <json>\\n\\n`` frame. Breaking out of the
|
||||
publisher's subscription (on client disconnect) tears the
|
||||
subscription down cleanly — no queue is leaked.
|
||||
"""
|
||||
publisher = request.app.state.publisher
|
||||
async for event_type, payload in publisher.subscribe():
|
||||
yield (
|
||||
f"event: {event_type}\n"
|
||||
f"data: {json.dumps(payload)}\n\n"
|
||||
)
|
||||
|
||||
|
||||
def transition_event_stream(request: Request) -> StreamingResponse:
|
||||
"""A ``text/event-stream`` response over the app's transition events.
|
||||
|
||||
Performs NO auth of its own: the calling route's router-level
|
||||
dependency is the ONE check for its plane. The stream ends when
|
||||
the client disconnects, at which point the subscription is torn
|
||||
down (its queue removed) with nothing leaked.
|
||||
"""
|
||||
return StreamingResponse(
|
||||
_sse_stream(request),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
@@ -41,4 +41,38 @@ class MetaStateDao:
|
||||
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT (key) DO UPDATE SET value = excluded.value",
|
||||
(key, value),
|
||||
)
|
||||
)
|
||||
|
||||
async def claim(self, key: str, value: str) -> bool:
|
||||
"""Atomically set ``key`` to ``value`` unless it already is.
|
||||
|
||||
The single upsert statement carries a ``WHERE`` guard on the
|
||||
conflict path (``t.value IS NOT excluded.value``), so the
|
||||
read-compare-write is ONE SQLite statement — atomic at the
|
||||
DATABASE level, not just within one process: two connections
|
||||
(two processes sharing one SQLite file) cannot both observe
|
||||
"not yet set" and both write, because the second writer's
|
||||
statement re-evaluates the guard against the first writer's
|
||||
committed value and matches zero rows.
|
||||
|
||||
Returns True only when THIS call changed the row: the first
|
||||
claimant wins and every later claimant of the same value
|
||||
returns False. A DIFFERENT value always wins (an advance, e.g.
|
||||
the sweep watermark moving to a new date) and re-arms the
|
||||
same-value loser check for the new value.
|
||||
|
||||
Runs under the shared connection lock and inside one
|
||||
transaction, like :meth:`set`; ``cursor.rowcount`` (1 for a
|
||||
matched write, 0 for a guarded no-op) is captured by the
|
||||
wrapper on the executor thread, so no sqlite3 object crosses
|
||||
back to the event loop.
|
||||
"""
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
result = await self._database.execute(
|
||||
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT (key) DO UPDATE SET value = excluded.value "
|
||||
f"WHERE {_TABLE}.value IS NOT excluded.value",
|
||||
(key, value),
|
||||
)
|
||||
return result.rowcount == 1
|
||||
|
||||
@@ -270,6 +270,57 @@ class PresenceOverridesDao:
|
||||
)
|
||||
return [_override_from_row(row) for row in rows]
|
||||
|
||||
async def list_filtered(
|
||||
self,
|
||||
*,
|
||||
child_id: int | None = None,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
) -> list[PresenceOverrideRecord]:
|
||||
"""Return the household's overrides, optionally narrowed.
|
||||
|
||||
Every filter is optional and they combine: ``child_id`` keeps
|
||||
that child's overrides only (an unknown child raises ValueError
|
||||
rather than reading as "no overrides"); ``start`` keeps
|
||||
overrides ending on or after it and ``end`` keeps overrides
|
||||
starting on or before it, so both together select the overrides
|
||||
overlapping the closed range ``[start, end]`` (the
|
||||
:meth:`list_by_child_and_range` boundary rule). Dates are
|
||||
strict YYYY-MM-DD and ``end`` must not precede ``start``.
|
||||
Ordered stably by start date, then child id, then id.
|
||||
"""
|
||||
if start is not None:
|
||||
_validate_date(start, "start")
|
||||
if end is not None:
|
||||
_validate_date(end, "end")
|
||||
if start is not None and end is not None and end < start:
|
||||
raise ValueError(
|
||||
f"end must be on or after start, got {end!r} < {start!r}"
|
||||
)
|
||||
clauses: list[str] = []
|
||||
params: list[object] = []
|
||||
if child_id is not None:
|
||||
child = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
||||
)
|
||||
if child is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
clauses.append("child_id = ?")
|
||||
params.append(child_id)
|
||||
if start is not None:
|
||||
clauses.append("end_date >= ?")
|
||||
params.append(start)
|
||||
if end is not None:
|
||||
clauses.append("start_date <= ?")
|
||||
params.append(end)
|
||||
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides{where} "
|
||||
"ORDER BY start_date, child_id, id",
|
||||
tuple(params),
|
||||
)
|
||||
return [_override_from_row(row) for row in rows]
|
||||
|
||||
async def get(self, override_id: int) -> PresenceOverrideRecord | None:
|
||||
"""Return one override by id, or None when it does not exist."""
|
||||
row = await self._database.fetch_one(
|
||||
|
||||
@@ -105,6 +105,19 @@ def _validate_window(window: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _validate_skip_on_away(value: object) -> int:
|
||||
"""Return ``value`` as 0/1, raising ValueError for anything else.
|
||||
|
||||
The fresh-schema CHECK constraint enforces this in SQLite, but a
|
||||
migrated database gained the column through ``ADD COLUMN`` without
|
||||
it, so every DAO write path validates here instead. Accepts a bool
|
||||
or the ints 0/1; floats, strings and other ints are rejected.
|
||||
"""
|
||||
if not isinstance(value, int) or value not in (0, 1):
|
||||
raise ValueError(f"skip_on_away must be 0 or 1, got {value!r}")
|
||||
return int(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScheduleRuleRecord:
|
||||
"""One row of ``schedule_rules``."""
|
||||
@@ -152,6 +165,7 @@ class QuestDefinitionRecord:
|
||||
due_time: str | None
|
||||
is_active: bool
|
||||
created_at: str
|
||||
skip_on_away: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -187,7 +201,7 @@ _RULE_COLUMNS = (
|
||||
)
|
||||
_DEFINITION_COLUMNS = (
|
||||
"id, title, description, icon, schedule_rule_id, due_time, "
|
||||
"is_active, created_at"
|
||||
"is_active, created_at, skip_on_away"
|
||||
)
|
||||
_WINDOW_COLUMNS = "definition_id, window, due_time"
|
||||
|
||||
@@ -216,6 +230,7 @@ def _definition_from_row(row: tuple) -> QuestDefinitionRecord:
|
||||
due_time=row[5],
|
||||
is_active=bool(row[6]),
|
||||
created_at=row[7],
|
||||
skip_on_away=bool(row[8]),
|
||||
)
|
||||
|
||||
|
||||
@@ -632,6 +647,7 @@ class QuestDefinitionsDao:
|
||||
due_time: str | None = None,
|
||||
is_active: bool = True,
|
||||
assignee_child_ids: list[int] | None = None,
|
||||
skip_on_away: bool = True,
|
||||
) -> QuestDefinitionRecord:
|
||||
"""Insert one definition and return the record as stored.
|
||||
|
||||
@@ -644,6 +660,7 @@ class QuestDefinitionsDao:
|
||||
empty list or None creates an unassigned definition, which
|
||||
callers may fill via :meth:`add_assignee`.
|
||||
"""
|
||||
skip_on_away_value = _validate_skip_on_away(skip_on_away)
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
await self._validate_rule_exists(schedule_rule_id)
|
||||
@@ -652,7 +669,8 @@ class QuestDefinitionsDao:
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO quest_definitions (title, description, "
|
||||
"icon, schedule_rule_id, due_time, is_active, "
|
||||
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
"created_at, skip_on_away) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
title,
|
||||
description,
|
||||
@@ -661,6 +679,7 @@ class QuestDefinitionsDao:
|
||||
due_time,
|
||||
int(is_active),
|
||||
created_at,
|
||||
skip_on_away_value,
|
||||
),
|
||||
)
|
||||
definition_id = result.lastrowid
|
||||
@@ -684,6 +703,7 @@ class QuestDefinitionsDao:
|
||||
*,
|
||||
description: str | None = None,
|
||||
icon: str | None = None,
|
||||
skip_on_away: bool = True,
|
||||
) -> QuestDefinitionSnapshot:
|
||||
"""Insert a rule, definition, assignees and windows atomically.
|
||||
|
||||
@@ -713,6 +733,7 @@ class QuestDefinitionsDao:
|
||||
_validate_date(schedule_rule.start_date, "start_date")
|
||||
if schedule_rule.end_date is not None:
|
||||
_validate_date(schedule_rule.end_date, "end_date")
|
||||
skip_on_away_value = _validate_skip_on_away(skip_on_away)
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
for child_id in assignee_child_ids:
|
||||
@@ -721,8 +742,18 @@ class QuestDefinitionsDao:
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO quest_definitions (title, description, "
|
||||
"icon, schedule_rule_id, due_time, is_active, "
|
||||
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(title, description, icon, rule_id, None, 1, created_at),
|
||||
"created_at, skip_on_away) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
rule_id,
|
||||
None,
|
||||
1,
|
||||
created_at,
|
||||
skip_on_away_value,
|
||||
),
|
||||
)
|
||||
definition_id = result.lastrowid
|
||||
for child_id in assignee_child_ids:
|
||||
@@ -764,8 +795,10 @@ class QuestDefinitionsDao:
|
||||
icon: str | None | object = _UNSET,
|
||||
rule: ScheduleRuleStorage | object = _UNSET,
|
||||
windows: list[tuple[str, str | None]] | object = _UNSET,
|
||||
skip_on_away: bool | object = _UNSET,
|
||||
assignee_child_ids: list[int] | object = _UNSET,
|
||||
) -> QuestDefinitionSnapshot:
|
||||
"""Edit a definition's metadata, rule and windows atomically.
|
||||
"""Edit a definition's metadata, rule, windows and assignees atomically.
|
||||
|
||||
Mirrors :meth:`create_with_rule_and_windows`: everything runs
|
||||
inside ONE transaction under the connection lock, and the
|
||||
@@ -774,9 +807,10 @@ class QuestDefinitionsDao:
|
||||
``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL
|
||||
NULL so optional metadata (description, icon) can be cleared.
|
||||
|
||||
- ``title``/``description``/``icon`` update the definition row
|
||||
in place; the definition-level ``due_time`` column is never
|
||||
written (per-window due times supersede it, D-008).
|
||||
- ``title``/``description``/``icon``/``skip_on_away`` update the
|
||||
definition row in place; the definition-level ``due_time``
|
||||
column is never written (per-window due times supersede it,
|
||||
D-008).
|
||||
- ``rule`` (a pre-validated :class:`ScheduleRuleStorage`)
|
||||
overwrites the referenced ``schedule_rules`` row in place: the
|
||||
definition keeps its ``schedule_rule_id``, so no new or
|
||||
@@ -785,13 +819,19 @@ class QuestDefinitionsDao:
|
||||
are deleted and the given set re-inserted, so missing windows
|
||||
are added, changed due times updated and removed windows
|
||||
dropped.
|
||||
- ``assignee_child_ids`` REPLACES the whole assignee set: every
|
||||
id is validated as existing-and-active BEFORE any write (the
|
||||
same check :meth:`add_assignee` applies), then links for
|
||||
children no longer listed are removed and links for newly
|
||||
listed children inserted; unchanged links are left alone.
|
||||
|
||||
Assignment is deliberately not settable here (multi-assignee is
|
||||
managed by :meth:`add_assignee`/:meth:`remove_assignee`), and
|
||||
activation is untouched. Raises ValueError when the definition
|
||||
does not exist. Edits change future instances only: no
|
||||
Activation is untouched. Raises ValueError when the definition
|
||||
does not exist or an assignee is rejected; either way the
|
||||
transaction rolls back and nothing is written. Edits change future instances only: no
|
||||
``quest_instances`` or ``completion_events`` row is touched.
|
||||
"""
|
||||
if skip_on_away is not _UNSET:
|
||||
skip_on_away = _validate_skip_on_away(skip_on_away)
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
definition = await self.get(definition_id)
|
||||
@@ -799,6 +839,9 @@ class QuestDefinitionsDao:
|
||||
raise ValueError(
|
||||
f"quest definition {definition_id} does not exist"
|
||||
)
|
||||
if assignee_child_ids is not _UNSET:
|
||||
for child_id in assignee_child_ids:
|
||||
await self._validate_assignable_child(child_id)
|
||||
assignments: list[str] = []
|
||||
parameters: list[object] = []
|
||||
for column, value in (
|
||||
@@ -809,6 +852,9 @@ class QuestDefinitionsDao:
|
||||
if value is not _UNSET:
|
||||
assignments.append(f"{column} = ?")
|
||||
parameters.append(value)
|
||||
if skip_on_away is not _UNSET:
|
||||
assignments.append("skip_on_away = ?")
|
||||
parameters.append(skip_on_away)
|
||||
if assignments:
|
||||
parameters.append(definition_id)
|
||||
await self._database.execute(
|
||||
@@ -844,6 +890,10 @@ class QuestDefinitionsDao:
|
||||
"VALUES (?, ?, ?)",
|
||||
(definition_id, window, due_time),
|
||||
)
|
||||
if assignee_child_ids is not _UNSET:
|
||||
await self._replace_assignees(
|
||||
definition_id, assignee_child_ids
|
||||
)
|
||||
updated = await self.get(definition_id)
|
||||
assignees = await self.list_assignees(definition_id)
|
||||
window_records = await self.list_windows(definition_id)
|
||||
@@ -859,6 +909,36 @@ class QuestDefinitionsDao:
|
||||
windows=window_records,
|
||||
)
|
||||
|
||||
async def _replace_assignees(
|
||||
self, definition_id: int, child_ids: list[int]
|
||||
) -> None:
|
||||
"""Make the definition's assignee links exactly ``child_ids``.
|
||||
|
||||
Removes the links for children not in ``child_ids`` and inserts
|
||||
the missing ones. MUST be called inside the connection lock and
|
||||
an open transaction, after the ids were validated as assignable.
|
||||
"""
|
||||
rows = await self._database.fetch_all(
|
||||
"SELECT child_id FROM quest_definition_assignees "
|
||||
"WHERE definition_id = ?",
|
||||
(definition_id,),
|
||||
)
|
||||
current = {row[0] for row in rows}
|
||||
wanted = set(child_ids)
|
||||
for child_id in sorted(current - wanted):
|
||||
await self._database.execute(
|
||||
"DELETE FROM quest_definition_assignees "
|
||||
"WHERE definition_id = ? AND child_id = ?",
|
||||
(definition_id, child_id),
|
||||
)
|
||||
for child_id in child_ids:
|
||||
if child_id not in current:
|
||||
await self._database.execute(
|
||||
"INSERT INTO quest_definition_assignees "
|
||||
"(definition_id, child_id) VALUES (?, ?)",
|
||||
(definition_id, child_id),
|
||||
)
|
||||
|
||||
async def _validate_rule_exists(self, schedule_rule_id: int) -> None:
|
||||
"""Raise ValueError unless the rule exists.
|
||||
|
||||
@@ -907,6 +987,13 @@ class QuestDefinitionsDao:
|
||||
)
|
||||
return [_definition_from_row(row) for row in rows]
|
||||
|
||||
async def list_all(self) -> list[QuestDefinitionRecord]:
|
||||
"""Return every definition, active AND inactive, oldest first."""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions ORDER BY id"
|
||||
)
|
||||
return [_definition_from_row(row) for row in rows]
|
||||
|
||||
async def _snapshots_for(
|
||||
self, definitions: list[QuestDefinitionRecord]
|
||||
) -> list[QuestDefinitionSnapshot]:
|
||||
@@ -951,6 +1038,18 @@ class QuestDefinitionsDao:
|
||||
definitions = await self.list_active()
|
||||
return await self._snapshots_for(definitions)
|
||||
|
||||
async def list_snapshots_all(self) -> list[QuestDefinitionSnapshot]:
|
||||
"""Return every definition (active AND inactive) with its rule,
|
||||
assignees and windows, all read inside one locked transaction.
|
||||
|
||||
Mirrors :meth:`list_snapshots_active` over :meth:`list_all`
|
||||
(oldest first), with the same coherence guarantee.
|
||||
"""
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
definitions = await self.list_all()
|
||||
return await self._snapshots_for(definitions)
|
||||
|
||||
async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
|
||||
"""Return every active definition's snapshot WITHOUT locking.
|
||||
|
||||
@@ -990,13 +1089,16 @@ class QuestDefinitionsDao:
|
||||
description: str | None | object = _UNSET,
|
||||
icon: str | None | object = _UNSET,
|
||||
due_time: str | None | object = _UNSET,
|
||||
skip_on_away: bool | None = None,
|
||||
) -> int:
|
||||
"""Update the given fields; returns rows updated (0 if absent).
|
||||
|
||||
Arguments default to the module sentinel ``_UNSET`` meaning
|
||||
"leave this column alone"; passing ``None`` explicitly writes
|
||||
SQL NULL, so callers can remove optional metadata
|
||||
(description, icon, due_time). Assignment and activation are
|
||||
(description, icon, due_time). ``skip_on_away`` is NOT NULL,
|
||||
so its ``None`` default leaves it unchanged; a supplied value
|
||||
must be 0/1 (a bool). Assignment and activation are
|
||||
deliberately NOT settable here: they have their own explicit
|
||||
operations (:meth:`add_assignee`, :meth:`remove_assignee`,
|
||||
:meth:`set_active`) so they stay separately loggable and
|
||||
@@ -1013,6 +1115,9 @@ class QuestDefinitionsDao:
|
||||
if value is not _UNSET:
|
||||
assignments.append(f"{column} = ?")
|
||||
parameters.append(value)
|
||||
if skip_on_away is not None:
|
||||
assignments.append("skip_on_away = ?")
|
||||
parameters.append(_validate_skip_on_away(skip_on_away))
|
||||
if not assignments:
|
||||
return 0
|
||||
parameters.append(definition_id)
|
||||
|
||||
@@ -18,7 +18,8 @@ The walk is deterministic and idempotent:
|
||||
:class:`~.presence.PresenceEngine` from that same coherent read.
|
||||
- For each date in the range, each definition whose decoded rule
|
||||
:func:`~.recurrence.occurs_on` on that date, each assignee who is
|
||||
``is_present`` on that date and each declared window, one instance is
|
||||
``is_present`` on that date (or every assignee, when the definition's
|
||||
``skip_on_away`` is off) and each declared window, one instance is
|
||||
written through :class:`~.dao_instances.QuestInstancesDao.upsert_if_valid`
|
||||
with a ``generated_at`` stamp shared by the whole batch (one coherent
|
||||
time per run).
|
||||
@@ -63,9 +64,14 @@ the presence DAO (``dao_presence.py``) must stay free of business rules
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import NamedTuple
|
||||
|
||||
from .const import DEFAULT_HORIZON_DAYS
|
||||
from .dao_instances import QuestInstancesDao, _resolve_today
|
||||
from .dao_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
_resolve_today,
|
||||
)
|
||||
from .dao_rules import (
|
||||
_validate_date,
|
||||
load_materialization_input,
|
||||
@@ -125,8 +131,15 @@ def _decode_rule(snapshot) -> ScheduleRule:
|
||||
def _build_engine(
|
||||
schedules_records,
|
||||
overrides_records,
|
||||
*,
|
||||
extra_overrides: tuple[PresenceOverride, ...] = (),
|
||||
) -> PresenceEngine:
|
||||
"""Convert raw presence records into an immutable PresenceEngine."""
|
||||
"""Convert raw presence records into an immutable PresenceEngine.
|
||||
|
||||
``extra_overrides`` layers already-validated, NOT-yet-stored
|
||||
overrides on top of the stored ones — the consequence preview's
|
||||
proposed override (:func:`count_removed_by_override`).
|
||||
"""
|
||||
schedules: dict[int, PresenceSchedule] = {}
|
||||
for child_id, record in schedules_records.items():
|
||||
schedules[child_id] = PresenceSchedule.decode(
|
||||
@@ -144,9 +157,75 @@ def _build_engine(
|
||||
)
|
||||
for record in records
|
||||
]
|
||||
for override in extra_overrides:
|
||||
overrides.setdefault(override.child_id, []).append(override)
|
||||
return PresenceEngine(schedules, overrides)
|
||||
|
||||
|
||||
class GeneratedTuple(NamedTuple):
|
||||
"""One instance the walk WOULD write: its upsert key plus due time."""
|
||||
|
||||
definition_id: int
|
||||
child_id: int
|
||||
due_date: str
|
||||
window: str
|
||||
due_time: str | None
|
||||
|
||||
|
||||
def _generation_preview(
|
||||
snapshots,
|
||||
engine: PresenceEngine,
|
||||
start: datetime.date,
|
||||
end: datetime.date,
|
||||
*,
|
||||
child_filter: set[int] | None = None,
|
||||
) -> list[GeneratedTuple]:
|
||||
"""Return the tuples the walk would write over ``[start, end]``.
|
||||
|
||||
The pure, read-only half of :func:`materialize`: for each date, each
|
||||
definition whose decoded rule :func:`~.recurrence.occurs_on` it,
|
||||
each assignee in ``child_filter`` (every assignee when None) who is
|
||||
``is_present`` on it — or every assignee when the definition's
|
||||
``skip_on_away`` is off — and each declared window, one tuple, in
|
||||
the walk's write order. :func:`materialize` writes exactly these
|
||||
tuples and :func:`count_removed_by_override` compares two of these
|
||||
previews, so the write and the consequence preview can never
|
||||
diverge. No database access.
|
||||
"""
|
||||
definitions = [
|
||||
(snapshot.definition.id, _decode_rule(snapshot),
|
||||
snapshot.assignees, snapshot.windows,
|
||||
snapshot.definition.skip_on_away)
|
||||
for snapshot in snapshots
|
||||
]
|
||||
generated: list[GeneratedTuple] = []
|
||||
cursor = start
|
||||
while cursor <= end:
|
||||
iso = cursor.isoformat()
|
||||
for (
|
||||
definition_id, rule, assignees, windows, skip_on_away
|
||||
) in definitions:
|
||||
if not occurs_on(rule, cursor):
|
||||
continue
|
||||
for child in assignees:
|
||||
if child_filter is not None and child.id not in child_filter:
|
||||
continue
|
||||
if skip_on_away and not engine.is_present(child.id, cursor):
|
||||
continue
|
||||
for window in windows:
|
||||
generated.append(
|
||||
GeneratedTuple(
|
||||
definition_id,
|
||||
child.id,
|
||||
iso,
|
||||
window.window,
|
||||
window.due_time,
|
||||
)
|
||||
)
|
||||
cursor += datetime.timedelta(days=1)
|
||||
return generated
|
||||
|
||||
|
||||
async def materialize(
|
||||
database: NestQuestDatabase,
|
||||
start_date: str,
|
||||
@@ -206,12 +285,6 @@ async def materialize(
|
||||
engine = _build_engine(schedules_records, overrides_records)
|
||||
child_filter = None if child_ids is None else set(child_ids)
|
||||
|
||||
definitions = [
|
||||
(snapshot.definition.id, _decode_rule(snapshot),
|
||||
snapshot.assignees, snapshot.windows)
|
||||
for snapshot in snapshots
|
||||
]
|
||||
|
||||
generated_at = _now_stamp()
|
||||
instances = QuestInstancesDao(database)
|
||||
|
||||
@@ -226,30 +299,20 @@ async def materialize(
|
||||
start = max(start, today_date)
|
||||
|
||||
count = 0
|
||||
cursor = start
|
||||
while cursor <= end:
|
||||
iso = cursor.isoformat()
|
||||
for definition_id, rule, assignees, windows in definitions:
|
||||
if not occurs_on(rule, cursor):
|
||||
continue
|
||||
for child in assignees:
|
||||
if child_filter is not None and child.id not in child_filter:
|
||||
continue
|
||||
if not engine.is_present(child.id, cursor):
|
||||
continue
|
||||
for window in windows:
|
||||
written = await instances.upsert_if_valid(
|
||||
definition_id,
|
||||
child.id,
|
||||
iso,
|
||||
generated_at,
|
||||
window=window.window,
|
||||
due_time=window.due_time,
|
||||
today=today,
|
||||
)
|
||||
if written is not None:
|
||||
count += 1
|
||||
cursor += datetime.timedelta(days=1)
|
||||
for generated in _generation_preview(
|
||||
snapshots, engine, start, end, child_filter=child_filter
|
||||
):
|
||||
written = await instances.upsert_if_valid(
|
||||
generated.definition_id,
|
||||
generated.child_id,
|
||||
generated.due_date,
|
||||
generated_at,
|
||||
window=generated.window,
|
||||
due_time=generated.due_time,
|
||||
today=today,
|
||||
)
|
||||
if written is not None:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
@@ -375,3 +438,93 @@ async def regenerate_for_child(
|
||||
return await materialize(
|
||||
database, start.isoformat(), end_date, child_ids=[child_id], today=today
|
||||
)
|
||||
|
||||
|
||||
async def count_removed_by_override(
|
||||
database: NestQuestDatabase,
|
||||
override: PresenceOverride,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> int:
|
||||
"""Count the upcoming instances saving ``override`` would remove.
|
||||
|
||||
Read-only: nothing is written. The window is the part of the
|
||||
override's range inside the rolling horizon ``[today, today +
|
||||
horizon_days]`` that :func:`regenerate_for_child` rebuilds after the
|
||||
override is saved (outside the range the two presence answers agree,
|
||||
so nothing there can change). The materialization input is read
|
||||
ONCE (:func:`~.dao_rules.load_materialization_input`) and
|
||||
:func:`_generation_preview` runs twice over it for the override's
|
||||
child — with the stored presence, and with ``override`` layered on
|
||||
top (:func:`_build_engine`'s ``extra_overrides``). The count is the
|
||||
tuples the first preview generates and the second does not, MINUS
|
||||
every (definition, date, window) whose stored instance already has a
|
||||
completion event (D-005: regeneration never deletes those). A
|
||||
definition with ``skip_on_away`` off generates regardless of
|
||||
presence, so it contributes nothing; a present override only ever
|
||||
ADDS tuples, so it counts 0.
|
||||
|
||||
An override overlapping a stored one (which the create path refuses)
|
||||
resolves through the engine's latest-start-wins rule. ``today`` and
|
||||
``horizon_days`` resolve exactly as :func:`regenerate_for_child`
|
||||
resolves them; a malformed horizon raises ValueError.
|
||||
"""
|
||||
horizon = _resolve_horizon_days(horizon_days)
|
||||
today_date = _resolve_today(today)
|
||||
start = max(today_date, override.start_date)
|
||||
end = min(
|
||||
today_date + datetime.timedelta(days=horizon), override.end_date
|
||||
)
|
||||
if end < start:
|
||||
return 0
|
||||
start_date, end_date = start.isoformat(), end.isoformat()
|
||||
child_id = override.child_id
|
||||
|
||||
snapshots, schedules_records, overrides_records = await (
|
||||
load_materialization_input(database, start_date, end_date)
|
||||
)
|
||||
current = _generation_preview(
|
||||
snapshots,
|
||||
_build_engine(schedules_records, overrides_records),
|
||||
start,
|
||||
end,
|
||||
child_filter={child_id},
|
||||
)
|
||||
proposed = set(
|
||||
_generation_preview(
|
||||
snapshots,
|
||||
_build_engine(
|
||||
schedules_records,
|
||||
overrides_records,
|
||||
extra_overrides=(override,),
|
||||
),
|
||||
start,
|
||||
end,
|
||||
child_filter={child_id},
|
||||
)
|
||||
)
|
||||
removed = [generated for generated in current if generated not in proposed]
|
||||
if not removed:
|
||||
return 0
|
||||
|
||||
stored = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, start_date, end_date
|
||||
)
|
||||
touched = {
|
||||
event.instance_id
|
||||
for event in await CompletionEventsDao(
|
||||
database
|
||||
).list_by_child_and_date_range(child_id, start_date, end_date)
|
||||
}
|
||||
completed = {
|
||||
(instance.definition_id, instance.due_date, instance.window)
|
||||
for instance in stored
|
||||
if instance.id in touched
|
||||
}
|
||||
return sum(
|
||||
1
|
||||
for generated in removed
|
||||
if (generated.definition_id, generated.due_date, generated.window)
|
||||
not in completed
|
||||
)
|
||||
|
||||
@@ -30,8 +30,9 @@ widened (definition_id, child_id, due_date, window) key, and migration
|
||||
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
|
||||
column. Migration 7 adds the generic ``nestquest_meta_state``
|
||||
key/value table for integration-owned bookkeeping (Feature 11's
|
||||
missed-sweep watermark). Each callable migration runs inside its own
|
||||
transaction
|
||||
missed-sweep watermark). Migration 8 adds the ``skip_on_away``
|
||||
column to ``quest_definitions`` (Feature 22), defaulting to 1. Each
|
||||
callable migration runs inside its own transaction
|
||||
together with its version stamp, so a crash mid-step rolls the
|
||||
statements and the stamp back together.
|
||||
|
||||
@@ -400,6 +401,28 @@ async def _add_actor_child_id(
|
||||
await database.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
async def _add_skip_on_away(
|
||||
database: NestQuestDatabase, target_version: int
|
||||
) -> None:
|
||||
"""Add the ``skip_on_away`` column to ``quest_definitions``.
|
||||
|
||||
Strictly additive: ``ALTER TABLE ... ADD COLUMN`` with a NOT NULL
|
||||
DEFAULT 1, so existing rows read as 1 (skip absent dates — the
|
||||
behaviour before the flag existed). Fresh databases already carry
|
||||
the column from the canonical DDL, so the ALTER is guarded and the
|
||||
step only stamps there.
|
||||
"""
|
||||
async with database.transaction():
|
||||
if not await _table_has_column(
|
||||
database, "quest_definitions", "skip_on_away"
|
||||
):
|
||||
await database.execute(
|
||||
"ALTER TABLE quest_definitions ADD COLUMN "
|
||||
"skip_on_away INTEGER NOT NULL DEFAULT 1"
|
||||
)
|
||||
await stamp_schema_version(database, target_version)
|
||||
|
||||
|
||||
#: The ordered migration list. Append-only: never edit an applied
|
||||
#: entry, add the next one instead. (Migration 1's content was
|
||||
#: rewritten pre-release per D-007 — no version 1 database shipped.)
|
||||
@@ -423,6 +446,10 @@ MIGRATIONS: Sequence[MigrationStep] = [
|
||||
# fresh databases (which already made it in migration 1) and
|
||||
# pre-v7 databases. Strictly additive; no existing table changes.
|
||||
list(SCHEMA_V7_META_STATE_DDL),
|
||||
# Migration 8 (Feature 22): the skip_on_away flag on
|
||||
# quest_definitions — an additive column defaulting to 1, so every
|
||||
# pre-existing definition keeps today's skip-when-absent behaviour.
|
||||
_add_skip_on_away,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -22,18 +22,23 @@ 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:``.
|
||||
|
||||
The admin plane's presence READS go through here too
|
||||
(:func:`get_presence_schedule`, :func:`list_presence_overrides`), so a
|
||||
reader never touches the DAO directly either.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from .dao_children import ChildrenDao
|
||||
from .dao_presence import (
|
||||
PresenceOverrideRecord,
|
||||
PresenceOverridesDao,
|
||||
PresenceSchedulesDao,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
from .materialize import regenerate_for_child
|
||||
from .materialize import count_removed_by_override, regenerate_for_child
|
||||
from .presence import PresenceOverride, PresenceSchedule
|
||||
|
||||
|
||||
@@ -130,6 +135,78 @@ async def create_presence_override(
|
||||
return record
|
||||
|
||||
|
||||
async def preview_presence_override_consequence(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
start_date: object,
|
||||
end_date: object,
|
||||
is_present: bool,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> int:
|
||||
"""Return how many upcoming instances saving this override removes.
|
||||
|
||||
The admin override editor's live consequence warning, computed
|
||||
BEFORE anything is saved: the arguments build the SAME
|
||||
:class:`~.presence.PresenceOverride` :func:`create_presence_override`
|
||||
builds (so a malformed or inverted range, or a non-bool
|
||||
``is_present``, raises the same ValueError), then
|
||||
:func:`~.materialize.count_removed_by_override` compares what the
|
||||
materialization walk generates for the child with and without it.
|
||||
Nothing is written. An unknown child reads ``child N does not
|
||||
exist``; an INACTIVE child has no instances to remove (the walk's
|
||||
write path never materializes one), so it counts 0.
|
||||
``today``/``horizon_days`` resolve as the post-save regeneration
|
||||
resolves them.
|
||||
"""
|
||||
override = PresenceOverride(child_id, start_date, end_date, is_present)
|
||||
child = await ChildrenDao(database).get(override.child_id)
|
||||
if child is None:
|
||||
raise ValueError(f"child {override.child_id} does not exist")
|
||||
if not child.is_active:
|
||||
return 0
|
||||
return await count_removed_by_override(
|
||||
database, override, today=today, horizon_days=horizon_days
|
||||
)
|
||||
|
||||
|
||||
async def get_presence_schedule(
|
||||
database: NestQuestDatabase, child_id: int
|
||||
) -> PresenceSchedule | None:
|
||||
"""Return the child's presence schedule decoded, or None when unset.
|
||||
|
||||
None means the child exists and has no schedule row — present
|
||||
every day (Feature 05). An unknown child raises ValueError
|
||||
(``child N does not exist``) rather than reading as present.
|
||||
"""
|
||||
record = await PresenceSchedulesDao(database).get_by_child(child_id)
|
||||
if record is None:
|
||||
return None
|
||||
return PresenceSchedule.decode(
|
||||
record.child_id, record.anchor_date, record.pattern
|
||||
)
|
||||
|
||||
|
||||
async def list_presence_overrides(
|
||||
database: NestQuestDatabase,
|
||||
*,
|
||||
child_id: int | None = None,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
) -> list[PresenceOverrideRecord]:
|
||||
"""Return the household's presence overrides, optionally filtered.
|
||||
|
||||
``child_id`` narrows to one child (unknown: ``child N does not
|
||||
exist``); ``start``/``end`` keep the overrides overlapping that
|
||||
closed range (strict YYYY-MM-DD, ``end >= start``). Ordered by
|
||||
start date, then child id, then id.
|
||||
"""
|
||||
return await PresenceOverridesDao(database).list_filtered(
|
||||
child_id=child_id, start=start, end=end
|
||||
)
|
||||
|
||||
|
||||
async def delete_presence_override(
|
||||
database: NestQuestDatabase,
|
||||
override_id: int,
|
||||
|
||||
@@ -4,8 +4,9 @@ Sits between the Feature 09 service gate and the typed
|
||||
:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the
|
||||
all-or-nothing create/edit here, and never touch the DAO SQL directly.
|
||||
:func:`create_quest_definition` is the create path,
|
||||
:func:`edit_quest_definition` the edit path, :func:`assign_child` and
|
||||
:func:`unassign_child` the assignment paths, and
|
||||
:func:`edit_quest_definition` the edit path (which may also replace the
|
||||
whole assignee set), :func:`assign_child` and
|
||||
:func:`unassign_child` the single-child assignment paths, and
|
||||
:func:`set_quest_definition_active` the deactivate/reactivate path.
|
||||
There is no permission check here (that is Feature 09).
|
||||
|
||||
@@ -191,6 +192,17 @@ def _validate_definition_id(value: object) -> int:
|
||||
return value
|
||||
|
||||
|
||||
def _validate_skip_on_away(value: object) -> bool:
|
||||
"""Reject a non-bool ``skip_on_away`` before any write.
|
||||
|
||||
The DAO's ``int()`` would otherwise silently coerce strings and
|
||||
numerics ("0", 1) into a state the caller never asked for.
|
||||
"""
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"skip_on_away must be a real bool, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_child_id(value: object) -> int:
|
||||
"""Reject non-int child ids (bools included) before any lookup.
|
||||
|
||||
@@ -212,6 +224,7 @@ async def create_quest_definition(
|
||||
*,
|
||||
description: str | None = None,
|
||||
icon: str | None = None,
|
||||
skip_on_away: bool = True,
|
||||
) -> CreatedQuestDefinition:
|
||||
"""Create a quest definition (rule + assignees + windows) atomically.
|
||||
|
||||
@@ -231,6 +244,7 @@ async def create_quest_definition(
|
||||
window_specs = _normalize_windows(windows)
|
||||
description_value = _validate_text(description, "description")
|
||||
icon_value = _validate_text(icon, "icon")
|
||||
skip_on_away_value = _validate_skip_on_away(skip_on_away)
|
||||
|
||||
storage = schedule_rule_to_storage(rule)
|
||||
dao = QuestDefinitionsDao(database)
|
||||
@@ -243,6 +257,7 @@ async def create_quest_definition(
|
||||
window_specs,
|
||||
description=description_value,
|
||||
icon=icon_value,
|
||||
skip_on_away=skip_on_away_value,
|
||||
)
|
||||
except ValueError as error:
|
||||
# The DAO's assignee check names only the child; re-raise so the
|
||||
@@ -271,10 +286,12 @@ async def edit_quest_definition(
|
||||
icon: str | None | object = _UNSET,
|
||||
rule: ScheduleRule | object = _UNSET,
|
||||
windows: list[str | tuple[str, str | None]] | object = _UNSET,
|
||||
skip_on_away: bool | None = None,
|
||||
assignee_child_ids: list[int] | object = _UNSET,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> CreatedQuestDefinition:
|
||||
"""Edit a quest definition's metadata, rule and windows atomically.
|
||||
"""Edit a quest definition's metadata, rule, windows and assignees atomically.
|
||||
|
||||
Arguments default to the sentinel ``_UNSET`` meaning "leave this
|
||||
field alone". A provided ``title`` is validated like create's
|
||||
@@ -285,9 +302,16 @@ async def edit_quest_definition(
|
||||
``schedule_rules`` row is updated, never orphaned or duplicated. A
|
||||
provided ``windows`` list REPLACES the whole window set (each entry
|
||||
a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due
|
||||
time), matching create's non-empty contract. Assignment is NOT
|
||||
settable here. Raises ValueError when the definition does not
|
||||
exist or any argument is rejected.
|
||||
time), matching create's non-empty contract. A provided
|
||||
``skip_on_away`` (a real bool) replaces the stored flag; ``None``
|
||||
leaves it unchanged. A provided ``assignee_child_ids`` REPLACES the
|
||||
whole assignee set under create's contract (a non-empty list of
|
||||
distinct plain ints, each an existing ACTIVE child — the DAO checks
|
||||
that inside the edit's transaction): newly listed children are
|
||||
assigned and unlisted ones unassigned. Raises ValueError when the
|
||||
definition does not exist or any argument is rejected; a rejection
|
||||
writes nothing, so the stored assignees (and every other field)
|
||||
stay unchanged.
|
||||
|
||||
Returns the updated definition together with its decoded rule,
|
||||
assignees and windows — all read back inside the DAO's transaction,
|
||||
@@ -331,6 +355,14 @@ async def edit_quest_definition(
|
||||
if windows is not _UNSET:
|
||||
window_specs = _normalize_windows(windows)
|
||||
|
||||
assignee_ids: list[int] | object = _UNSET
|
||||
if assignee_child_ids is not _UNSET:
|
||||
assignee_ids = _validate_assignee_ids(assignee_child_ids)
|
||||
|
||||
skip_on_away_value: bool | object = _UNSET
|
||||
if skip_on_away is not None:
|
||||
skip_on_away_value = _validate_skip_on_away(skip_on_away)
|
||||
|
||||
dao = QuestDefinitionsDao(database)
|
||||
try:
|
||||
snapshot = await dao.edit_definition(
|
||||
@@ -340,14 +372,18 @@ async def edit_quest_definition(
|
||||
icon=icon_value,
|
||||
rule=rule_storage,
|
||||
windows=window_specs,
|
||||
skip_on_away=skip_on_away_value,
|
||||
assignee_child_ids=assignee_ids,
|
||||
)
|
||||
except ValueError as error:
|
||||
# The DAO's missing-definition error names only the id; re-raise
|
||||
# so the public contract names the field the caller passed (matching
|
||||
# assign/unassign/deactivate).
|
||||
# The DAO's missing-definition and assignee errors name only the
|
||||
# id; re-raise so the public contract names the field the caller
|
||||
# passed (matching create/assign/unassign/deactivate).
|
||||
message = str(error)
|
||||
if message.startswith("quest definition "):
|
||||
raise ValueError(f"definition_id: {message}") from error
|
||||
if message.startswith("child "):
|
||||
raise ValueError(f"assignee_child_ids: {message}") from error
|
||||
raise
|
||||
|
||||
decoded_rule = schedule_rule_from_storage(
|
||||
@@ -558,6 +594,21 @@ async def list_active_definitions(
|
||||
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||
|
||||
|
||||
async def list_all_definitions(
|
||||
database: NestQuestDatabase,
|
||||
) -> list[CreatedQuestDefinition]:
|
||||
"""Return EVERY definition, active AND inactive (rich view).
|
||||
|
||||
Mirrors :func:`list_active_definitions` without the activity
|
||||
filter: ordered by rising definition id, each entry the same
|
||||
:class:`CreatedQuestDefinition` snapshot, read by the DAO inside one
|
||||
locked transaction.
|
||||
"""
|
||||
dao = QuestDefinitionsDao(database)
|
||||
snapshots = await dao.list_snapshots_all()
|
||||
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||
|
||||
|
||||
async def list_definitions_for_child(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
|
||||
@@ -257,7 +257,8 @@ QUEST_DEFINITIONS_TABLE_SQL = """(
|
||||
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
|
||||
due_time TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
|
||||
created_at TEXT NOT NULL
|
||||
created_at TEXT NOT NULL,
|
||||
skip_on_away INTEGER NOT NULL DEFAULT 1 CHECK (skip_on_away IN (0, 1))
|
||||
)"""
|
||||
|
||||
SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
|
||||
|
||||
@@ -26,12 +26,17 @@ 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
|
||||
The whole watermark read → query → build 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.
|
||||
a rollover sweep of the SAME process queue instead of interleaving.
|
||||
The announce decision itself is the DATABASE-level claim
|
||||
(:meth:`MetaStateDao.claim`), so two PROCESSES sharing one SQLite file
|
||||
(the integration's rollover sweep and the API service's scheduler)
|
||||
cannot both announce the same missed batch either: both may read the
|
||||
old watermark and build the events, but the single conditional upsert
|
||||
lets exactly one of them win the claim — the loser returns [] and
|
||||
announces nothing.
|
||||
|
||||
The caller supplies ``today`` as a plain :class:`datetime.date`: the
|
||||
household-local calendar date, read from ``hass.config.time_zone`` by
|
||||
@@ -60,11 +65,11 @@ LOGGER = logging.getLogger(__name__)
|
||||
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.
|
||||
#: the settings store's pattern: the watermark read → query → build
|
||||
#: span is one critical section, so an overlapping startup sweep and
|
||||
#: rollover sweep of the SAME process queue instead of interleaving.
|
||||
#: Same-process callers are fully serialised by it; the cross-process
|
||||
#: case is closed by the database-level claim, not by this lock.
|
||||
_SWEEP_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
|
||||
|
||||
|
||||
@@ -99,15 +104,24 @@ async def run_missed_sweep(
|
||||
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
|
||||
The watermark read → query → build span runs inside the
|
||||
per-database sweep lock, so overlapping callers of ONE process
|
||||
queue instead of interleaving. The announce decision is the
|
||||
DATABASE-level claim (:meth:`MetaStateDao.claim` — a single
|
||||
conditional upsert on the watermark, atomic across processes):
|
||||
every caller builds the events optimistically, then only the
|
||||
caller whose claim actually advanced the watermark returns them;
|
||||
a loser (another process already announced for ``today``) returns
|
||||
[] 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.
|
||||
|
||||
Failure ordering: the watermark is advanced ONLY by the claim,
|
||||
AFTER the events are fully built — a failure before the claim
|
||||
leaves the watermark unadvanced, so a later run re-sweeps the same
|
||||
window and nothing is lost.
|
||||
"""
|
||||
today_iso = today.isoformat()
|
||||
dao = MetaStateDao(database)
|
||||
@@ -134,10 +148,23 @@ async def run_missed_sweep(
|
||||
),
|
||||
)
|
||||
)
|
||||
await dao.set(SWEEP_WATERMARK_KEY, today_iso)
|
||||
# The database-level claim gates the announcement: only the
|
||||
# caller that actually advanced the watermark may publish the
|
||||
# built events, so two processes racing one SQLite file cannot
|
||||
# both announce the same missed batch. A same-value loser's
|
||||
# claim matches zero rows and this run announces nothing.
|
||||
claimed = await dao.claim(SWEEP_WATERMARK_KEY, today_iso)
|
||||
if not claimed:
|
||||
LOGGER.info(
|
||||
"NestQuest missed sweep for %s lost the watermark claim; "
|
||||
"announcing nothing",
|
||||
today_iso,
|
||||
)
|
||||
return []
|
||||
LOGGER.info(
|
||||
"NestQuest missed sweep for %s built %d event(s)",
|
||||
today_iso,
|
||||
len(events),
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
|
||||
"requirements": [],
|
||||
"version": "0.5.0"
|
||||
"version": "0.6.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# NestQuest Architecture
|
||||
|
||||
Status: approved direction (2026-09-21). This document supersedes the
|
||||
single-process design implied by Features 01–14, in which the Home Assistant
|
||||
integration owned the database and the parent admin surface was planned as a
|
||||
Lovelace card inside Home Assistant.
|
||||
|
||||
## 1. Why this changed
|
||||
|
||||
The original design put everything inside the Home Assistant integration: the
|
||||
SQLite database, the business logic, the kid panel cards, and (planned) the
|
||||
parent admin card. Three problems surfaced:
|
||||
|
||||
1. **The panel needed hand configuration.** Standing up the kid panel required
|
||||
hand-writing card YAML (`child_order`, `quest_log_path`, `board_path`) and
|
||||
hand-creating a Lovelace dashboard. A fresh install rendered nothing.
|
||||
2. **The admin surface fought Home Assistant.** The admin design
|
||||
(`design/ADMIN-SPEC.md`) is drawn at 390×844 — it is a phone app. Trapping it
|
||||
inside a Lovelace card meant fighting HA's UI constraints for a mobile-first
|
||||
experience.
|
||||
3. **Data was locked to one process.** A standalone admin web app cannot reach a
|
||||
SQLite file inside Home Assistant's config directory.
|
||||
|
||||
The re-imagined design keeps the panel in Home Assistant (TouchHub loads an HA
|
||||
dashboard), moves data ownership to a standalone API service, and promotes the
|
||||
admin surface to a standalone mobile-first PWA.
|
||||
|
||||
## 2. Target architecture
|
||||
|
||||
```
|
||||
DAKboard TouchHub
|
||||
│ (loads an HA dashboard URL)
|
||||
▼
|
||||
HA box API box
|
||||
┌───────────────────────────────┐ ┌───────────────────────────────────┐
|
||||
│ Lovelace panel dashboard │ │ reverse proxy (TLS) │
|
||||
│ ├ nestquest-party-board-card │ service │ ├ /api/v1/panel/* LAN-only │
|
||||
│ └ nestquest-quest-log-card │ token │ └ /api/v1/admin/* public │
|
||||
│ │ ─────────▶ │ FastAPI (imports core/) │
|
||||
│ NestQuest integration │ │ └ SQLite (owned here) │
|
||||
│ (thin client, no database) │ ◀───────── │ Authentik (OIDC) │
|
||||
│ sensors + HA bus events │ SSE └───────────────────────────────────┘
|
||||
└───────────────────────────────┘ ▲
|
||||
PWA ───────────┘
|
||||
https://nestquest.cubecraftlabs.com
|
||||
```
|
||||
|
||||
One owner of the data (the API). Two clients (the HA integration, the PWA).
|
||||
|
||||
## 3. Data ownership
|
||||
|
||||
**The API service is the only writer.** The HA integration never opens the
|
||||
database; it reads a snapshot and posts completions. This removes the
|
||||
two-writer problem entirely.
|
||||
|
||||
- **Storage:** SQLite on the API box. Single household, trivial concurrency,
|
||||
and the existing schema, migrations, and DAO tests are already SQLite. Postgres
|
||||
buys nothing at this scale.
|
||||
- **Schema:** unchanged. The cutover from the current HA-hosted database is a
|
||||
file copy — same schema, same migration version.
|
||||
- **Backups:** continuous replication (e.g. Litestream) plus a proven restore.
|
||||
A configured-but-untested backup is not a backup.
|
||||
|
||||
## 4. Authentication: two planes
|
||||
|
||||
The kid panel and the admin PWA have opposite trust levels, so they use
|
||||
separate credentials and separate network exposure.
|
||||
|
||||
### Panel plane (machine)
|
||||
|
||||
- **Credential:** a static service token.
|
||||
- **Exposure:** `/api/v1/panel/*` is reachable **only from the LAN**.
|
||||
- **Grants:** read the snapshot, and the single open write — complete a quest as
|
||||
`actor: panel` with the tapped `actor_child_id`.
|
||||
- **Refused:** admin routes reject the service token outright, not merely as a
|
||||
non-admin. The internet cannot reach these routes at all.
|
||||
|
||||
This preserves the original fail-closed boundary: the kiosk can complete a
|
||||
quest and nothing else.
|
||||
|
||||
### Admin plane (human)
|
||||
|
||||
- **Credential:** an Authentik OIDC JWT (authorization code + PKCE).
|
||||
- **Exposure:** `/api/v1/admin/*` is internet-facing behind Authentik.
|
||||
- **Grants:** everything a parent can do — definitions, assignment, presence,
|
||||
children, un-complete, history, export, settings, regenerate.
|
||||
- **Gate:** the API validates the JWT (issuer, audience, JWKS signature, expiry)
|
||||
and requires membership in the `nestquest-admins` group. The Authentik group
|
||||
is the single source of truth for "who is a parent", replacing the
|
||||
integration-side admin allowlist.
|
||||
|
||||
## 5. Components
|
||||
|
||||
### `core/` — domain library
|
||||
|
||||
The extracted domain layer: schema, migrations, database access, DAOs, the pure
|
||||
recurrence and presence engines, materialization, completion, event payload
|
||||
construction, and the children/definitions/allowlist business layers. **No
|
||||
Home Assistant imports.** Consumed by both the API and (during transition) the
|
||||
integration. This is where the ~1500 existing tests live after extraction.
|
||||
|
||||
### `api/` — NestQuest API
|
||||
|
||||
A FastAPI service importing `core/`. Owns the database. Serves the panel and
|
||||
admin planes, validates Authentik tokens, and publishes a transition event
|
||||
stream (SSE) that the integration consumes. No business logic in route handlers.
|
||||
|
||||
### `custom_components/nestquest/` — HA integration (client)
|
||||
|
||||
A thin client. Polls the snapshot and builds the existing sensors; subscribes to
|
||||
the SSE stream and re-fires the four HA bus events; proxies the panel completion.
|
||||
Holds a last-good snapshot cache so a brief API outage does not blank the panel.
|
||||
Owns no database and no business logic.
|
||||
|
||||
### `panel/` — Lovelace cards and dashboard strategy
|
||||
|
||||
The two kid-facing Lit cards plus a dashboard strategy that generates the panel
|
||||
views at render time. Cards auto-discover children, so no `child_order` is
|
||||
required; the strategy computes navigation paths, so `quest_log_path` and
|
||||
`board_path` are not required.
|
||||
|
||||
### `admin/` — Admin PWA
|
||||
|
||||
A React progressive web app implementing `design/ADMIN-SPEC.md`, authenticated
|
||||
via Authentik OIDC, installable on a phone. The only admin surface.
|
||||
|
||||
## 6. Zero-configuration panel
|
||||
|
||||
A fresh install should show the panel with no YAML:
|
||||
|
||||
1. **Auto-discovery.** The party-board card reads an ordered child list from a
|
||||
household entity attribute instead of a configured `child_order`.
|
||||
2. **Dashboard strategy.** A Lovelace strategy generates a party-board view and
|
||||
one quest-log view per child, wiring navigation between them, so paths are
|
||||
computed rather than configured. Explicit configuration still wins if a user
|
||||
supplies it.
|
||||
3. **Auto-registration.** The integration registers the dashboard on first
|
||||
setup, so there is no manual dashboard step.
|
||||
|
||||
## 7. Home Assistant surface preserved
|
||||
|
||||
The re-imagining must not break what already works:
|
||||
|
||||
- **Sensors:** the per-child and household sensor entity_ids and the per-child
|
||||
instance attribute payload shape are unchanged. The panel cards and the four
|
||||
blueprints depend on them.
|
||||
- **Events:** the four HA bus events (`nestquest_quest_completed`,
|
||||
`nestquest_quest_uncompleted`, `nestquest_quest_missed`,
|
||||
`nestquest_child_day_complete`) keep firing with their documented payloads.
|
||||
They are now driven by the API's transition stream rather than by local
|
||||
service handlers.
|
||||
- **Blueprints:** the morning summary, afternoon reminder, end-of-day report, and
|
||||
day-complete celebration continue to work with no change.
|
||||
|
||||
## 8. Events without an HA token on the API box
|
||||
|
||||
The API publishes transitions on an SSE stream; the integration subscribes and
|
||||
re-fires the HA bus events locally. This deliberately avoids storing a Home
|
||||
Assistant long-lived token on the API box, which would grant full HA access.
|
||||
|
||||
## 9. Migration phases
|
||||
|
||||
Mapped to the Maestro features:
|
||||
|
||||
| Phase | Feature | Outcome |
|
||||
|---|---|---|
|
||||
| 1 | 15 — Domain Core Extraction | `core/` runs with no HA imports; one implementation of the rules |
|
||||
| 2 | 16 — NestQuest API Service | Panel and admin planes served with two-plane auth |
|
||||
| 3 | 17 — API Deployment and Identity | TLS, Authentik, backups, network separation |
|
||||
| 4 | 18 — HA Integration as API Client | Integration becomes a client; entity/event surface preserved |
|
||||
| 5 | 19 — Admin PWA | Mobile-first React PWA replaces the admin card |
|
||||
| 6 | 20 — Zero-Config Panel Dashboard | Panel installs itself; navigation and animations match the mockups |
|
||||
|
||||
Cutover: copy the existing SQLite file to the API box (same schema).
|
||||
|
||||
## 10. Locked decisions
|
||||
|
||||
- **API on its own box** — separate from Home Assistant.
|
||||
- **Authentik** (`https://auth.cubecraftlabs.com`) for admin identity; a
|
||||
`nestquest-admins` group is the admin source of truth.
|
||||
- **Single household** — no tenant column, no multi-household auth.
|
||||
- **React** for the admin PWA.
|
||||
- **Drop the HA admin services** — the PWA is the only admin surface. The panel
|
||||
completion path stays.
|
||||
- **SQLite on the API box** initially; Postgres only if multi-household ever
|
||||
becomes real.
|
||||
|
||||
## 11. Preserved vs. changed
|
||||
|
||||
**Preserved:** the tested Python domain layer, the panel card designs and
|
||||
tokens, the sensor and event contract, the completion model, the append-only
|
||||
event log, the panel's fail-closed "complete only" boundary.
|
||||
|
||||
**Changed:** data ownership moves to the API; the integration becomes a client;
|
||||
the admin card becomes a PWA; panel cards become config-free with a
|
||||
strategy-generated dashboard.
|
||||
|
||||
**New:** the API service, Authentik-gated admin auth, the PWA, and a
|
||||
last-good-snapshot cache in the integration.
|
||||
|
||||
**Cost:** two new deployables (the API and the PWA) and one new box. Accepted
|
||||
knowingly in exchange for a self-installing panel, an admin surface that is not
|
||||
fighting HA's UI, and a data layer with real auth in front of it.
|
||||
@@ -51,6 +51,36 @@ class CyclePromptTests(unittest.TestCase):
|
||||
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,4 +1,5 @@
|
||||
from pathlib import Path
|
||||
import fcntl
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -47,6 +48,7 @@ class ForegroundLauncherTests(unittest.TestCase):
|
||||
"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)
|
||||
@@ -67,6 +69,16 @@ class ForegroundLauncherTests(unittest.TestCase):
|
||||
["--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)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nestquest"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
description = "NestQuest Home Assistant Integration"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -11,6 +11,7 @@ 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"
|
||||
|
||||
@@ -41,6 +42,17 @@ 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
|
||||
|
||||
@@ -150,6 +150,15 @@ Possible scopes:
|
||||
- `PROJECT_QUEUE`
|
||||
- `RELEASE_PREPARATION`
|
||||
|
||||
Also set `RELEASE_PREPARATION_POLICY` at startup:
|
||||
|
||||
- `AUTO` by default.
|
||||
- `EXCLUDED` only when the user's launch request explicitly says not to prepare
|
||||
a release or `{{INTEGRATION_BRANCH}}`-to-`{{RELEASE_BRANCH}}` promotion.
|
||||
|
||||
Record both `MISSION_SCOPE` and `RELEASE_PREPARATION_POLICY` in durable run
|
||||
state. Do not infer exclusion merely from `SINGLE_TASK` or `SINGLE_FEATURE`.
|
||||
|
||||
If the user explicitly names one task or feature, respect that scope.
|
||||
|
||||
If the user launches the orchestrator for general/autonomous {{PROJECT_NAME}} development without limiting it to one feature, use:
|
||||
@@ -171,6 +180,7 @@ Maintain a durable orchestration checkpoint using {{WORK_TRACKER}} wherever its
|
||||
Track at minimum:
|
||||
|
||||
- `MISSION_SCOPE`
|
||||
- `RELEASE_PREPARATION_POLICY`
|
||||
- Current feature
|
||||
- Current controller phase
|
||||
- Active development agents
|
||||
@@ -185,6 +195,9 @@ Track at minimum:
|
||||
- Blocked items
|
||||
- Tests/checks status
|
||||
- Last verified repository state
|
||||
- Active promotion branch and pull request
|
||||
- Triggering feature and exact `{{INTEGRATION_BRANCH}}` commit for each automatic promotion
|
||||
- Completed features pending the next promotion
|
||||
- `NEXT_ACTION`
|
||||
|
||||
After every material state transition:
|
||||
@@ -254,8 +267,8 @@ Choose the highest-priority actionable transition in this order:
|
||||
8. Remediate feature-level review findings.
|
||||
9. Obtain feature approval.
|
||||
10. Merge an approved feature into `{{INTEGRATION_BRANCH}}`.
|
||||
11. Advance to the next eligible feature when mission scope permits.
|
||||
12. Begin a requested/scheduled release when appropriate.
|
||||
11. Begin an automatically triggered, requested, or scheduled release when appropriate. The automatic post-feature trigger is in scope for both `SINGLE_FEATURE` and `PROJECT_QUEUE` unless `RELEASE_PREPARATION_POLICY = EXCLUDED`.
|
||||
12. Advance to the next eligible feature when mission scope permits.
|
||||
13. Continue unrelated work around locally blocked items.
|
||||
14. Escalate only when the defined escalation rules require it.
|
||||
|
||||
@@ -588,9 +601,10 @@ Immediately transition to `FEATURE_INTEGRATION`.
|
||||
|
||||
A merged feature is a transition.
|
||||
|
||||
It is terminal ONLY when:
|
||||
|
||||
`MISSION_SCOPE = SINGLE_FEATURE`
|
||||
It is terminal only when `MISSION_SCOPE = SINGLE_FEATURE` and no authorized
|
||||
post-feature release transition is pending. Preparing the automatic promotion
|
||||
for that completed feature is part of `SINGLE_FEATURE` scope when
|
||||
`RELEASE_PREPARATION_POLICY = AUTO`.
|
||||
|
||||
Otherwise select the next eligible work.
|
||||
|
||||
@@ -637,13 +651,19 @@ The orchestrator itself does not perform the merge.
|
||||
When `MISSION_SCOPE = PROJECT_QUEUE` and a feature is successfully merged into `{{INTEGRATION_BRANCH}}`:
|
||||
|
||||
1. Reconcile {{WORK_TRACKER}}.
|
||||
2. Check for a requested/scheduled release.
|
||||
2. Evaluate the automatic, requested, and scheduled release triggers.
|
||||
3. Identify remaining incomplete features.
|
||||
4. Determine which features are eligible based on dependencies and priority.
|
||||
5. Select the next eligible feature.
|
||||
6. Initialize it.
|
||||
7. Continue the development loop.
|
||||
|
||||
If an automatic promotion becomes `RELEASE_READY` while another eligible
|
||||
feature remains, keep that promotion waiting for {{RELEASE_OWNER_NAME}} and
|
||||
continue the project queue. A ready promotion is a local checkpoint in this
|
||||
case, not a global terminal condition. Features completed while it remains open
|
||||
are recorded as pending for the next promotion under Section 18.
|
||||
|
||||
Do not return to the user simply because one feature completed.
|
||||
|
||||
If several features are available, choose using:
|
||||
@@ -658,27 +678,52 @@ If several features are available, choose using:
|
||||
|
||||
# 18. RELEASE RULES
|
||||
|
||||
Completed features accumulate in `{{INTEGRATION_BRANCH}}`.
|
||||
Completed features accumulate in `{{INTEGRATION_BRANCH}}`. With no active
|
||||
promotion, each verified feature completion starts release preparation. While
|
||||
one promotion is active, later completed features accumulate for the next
|
||||
promotion.
|
||||
|
||||
Prepare a release only when:
|
||||
Prepare a release when any of these conditions is true:
|
||||
|
||||
- {{RELEASE_OWNER_NAME}} requests it; OR
|
||||
- {{WORK_TRACKER}} schedules it.
|
||||
- {{WORK_TRACKER}} schedules it; OR
|
||||
- `RELEASE_PREPARATION_POLICY = AUTO`, and a feature becomes task-complete and its reviewed merge into `{{INTEGRATION_BRANCH}}` is verified.
|
||||
|
||||
For the automatic trigger, a feature is task-complete only when:
|
||||
|
||||
- Every required feature task is `done`, or deliberately `canceled` with recorded evidence.
|
||||
- No task review, remediation, approval, or merge remains pending.
|
||||
- The feature acceptance criteria and integrated checks pass.
|
||||
- Independent approval applies to the exact feature head.
|
||||
- The approver's merge of that head into `{{INTEGRATION_BRANCH}}` is verified.
|
||||
- {{WORK_TRACKER}} reflects the completed feature and exact repository references.
|
||||
|
||||
Task completion inside an unmerged feature branch does not trigger a release.
|
||||
The verified feature-to-`{{INTEGRATION_BRANCH}}` merge is the triggering transition.
|
||||
|
||||
Before creating a release branch or pull request:
|
||||
|
||||
1. Verify this session is the sole active orchestrator for the project. `{{TERMINAL_LAUNCHER}}` enforces that exclusivity by holding the shared controller lock for the full foreground session. If that lock is not held by this session, do not create a release branch or pull request; escalate instead.
|
||||
2. Reconcile open release records, release branches, and promotion pull requests.
|
||||
3. Do not create a duplicate promotion for an already-covered `{{INTEGRATION_BRANCH}}` commit.
|
||||
4. Allow only one active promotion pull request to `{{RELEASE_BRANCH}}`. If an earlier promotion is still open and does not contain the newly completed feature, record the feature as pending for the next automatic promotion. Resume it after the active promotion is merged or closed; if it was closed without merge, recalculate the next promotion from the current validated `{{INTEGRATION_BRANCH}}` head.
|
||||
5. Record the triggering feature and exact `{{INTEGRATION_BRANCH}}` commit so the automatic trigger runs once for that merge.
|
||||
|
||||
Release process:
|
||||
|
||||
1. Reconcile release scope.
|
||||
2. Confirm included features are complete.
|
||||
3. Create release branch from exact validated `{{INTEGRATION_BRANCH}}`.
|
||||
4. Run complete release checks.
|
||||
5. Open release PR → `{{RELEASE_BRANCH}}`.
|
||||
6. Obtain fresh independent {{REVIEWER_VENDOR}} review.
|
||||
7. Remediate findings through development agents.
|
||||
8. Incorporate every release correction back into `{{INTEGRATION_BRANCH}}`.
|
||||
9. Revalidate.
|
||||
10. Assign final release PR to `{{HOST_ACCOUNT}}`.
|
||||
11. Update {{WORK_TRACKER}}.
|
||||
12. Stop before {{RELEASE_BRANCH}} merge.
|
||||
1. Enforce every pre-creation requirement above, including controller exclusivity, reconciliation, duplicate and active-promotion checks, and trigger recording.
|
||||
2. Reconcile release scope.
|
||||
3. Confirm included features are complete.
|
||||
4. Create release branch from exact validated `{{INTEGRATION_BRANCH}}`.
|
||||
5. Run complete release checks.
|
||||
6. Open release PR → `{{RELEASE_BRANCH}}`.
|
||||
7. Obtain fresh independent {{REVIEWER_VENDOR}} review.
|
||||
8. Remediate findings through development agents.
|
||||
9. Incorporate every release correction back into `{{INTEGRATION_BRANCH}}`.
|
||||
10. Revalidate.
|
||||
11. Assign final release PR to `{{HOST_ACCOUNT}}`.
|
||||
12. Update {{WORK_TRACKER}}.
|
||||
13. Stop before {{RELEASE_BRANCH}} merge.
|
||||
|
||||
Only {{RELEASE_OWNER_NAME}} may merge into `{{RELEASE_BRANCH}}`.
|
||||
|
||||
@@ -724,13 +769,23 @@ For `PROJECT_QUEUE`, this additionally requires:
|
||||
|
||||
- No eligible incomplete feature remains.
|
||||
- No task/review/remediation/merge is pending.
|
||||
- No release preparation, release review, or release remediation is pending.
|
||||
- Every in-scope automatic trigger is either incorporated into the active
|
||||
`RELEASE_READY` promotion or durably recorded for the next promotion.
|
||||
- No development agent is active.
|
||||
- No approver is active.
|
||||
|
||||
An active promotion that has not reached `RELEASE_READY` makes
|
||||
`MISSION_COMPLETE` false.
|
||||
|
||||
## `RELEASE_READY`
|
||||
|
||||
Release PR has passed required gates and is assigned to `{{HOST_ACCOUNT}}`, awaiting {{RELEASE_OWNER_NAME}}'s {{RELEASE_BRANCH}} merge.
|
||||
|
||||
`RELEASE_READY` is globally terminal only when no other in-scope engineering
|
||||
action is executable. In `PROJECT_QUEUE`, if another eligible incomplete
|
||||
feature remains, continue that work while the ready promotion waits.
|
||||
|
||||
## `GLOBAL_ESCALATION`
|
||||
|
||||
All remaining in-scope work is blocked by decisions/access that require {{RELEASE_OWNER_NAME}}.
|
||||
@@ -778,9 +833,12 @@ Output a machine-readable continuation checkpoint:
|
||||
```text
|
||||
ORCHESTRATOR_STATE: CONTINUE
|
||||
MISSION_SCOPE:
|
||||
RELEASE_PREPARATION_POLICY:
|
||||
CONTROLLER_PHASE:
|
||||
CURRENT_FEATURE:
|
||||
ACTIVE_TASKS:
|
||||
ACTIVE_PROMOTION:
|
||||
PENDING_PROMOTION_FEATURES:
|
||||
BLOCKED_ITEMS:
|
||||
LAST_VERIFIED_STATE:
|
||||
NEXT_ACTION:
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Admin-plane occurrence-preview route tests (task 5f843564).
|
||||
|
||||
These tests exercise ``POST /api/v1/admin/quest-definitions/occurrences-preview``
|
||||
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 with NO network access.
|
||||
|
||||
- Authentication runs through the router's ONE ``require_admin``
|
||||
dependency: no credential is 401, the panel service token is 403
|
||||
(refused as a credential type), a JWT without the admin group is
|
||||
403, an admin JWT is 200.
|
||||
- The returned dates are asserted EXACTLY for a weekly rule with an
|
||||
interval and a start_date, a daily rule with an interval, a rule
|
||||
with an end_date (the list stops at it), a month-end-clamped monthly
|
||||
rule, and the default start (the pinned host clock's today); one
|
||||
case also asserts parity with the core's own ``occurrences_between``
|
||||
over the documented window.
|
||||
- A rejected rule (the same core ``from_dict`` validation create/edit
|
||||
use), a non-strict ``start_date`` and a ``count`` outside 1..50 are
|
||||
422.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from api import routes_admin
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
|
||||
# The recurrence engine through the SAME core copy the app uses
|
||||
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy).
|
||||
_recurrence = importlib.import_module("nestquest_core.recurrence")
|
||||
|
||||
PREVIEW_URL = "/api/v1/admin/quest-definitions/occurrences-preview"
|
||||
|
||||
#: The panel service token the harness app is configured with.
|
||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
||||
|
||||
#: 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"
|
||||
)
|
||||
|
||||
#: The pinned host instant: Wednesday 2026-09-23, noon UTC.
|
||||
PINNED_NOW = datetime.datetime(2026, 9, 23, 12, 0, 0, tzinfo=ZoneInfo("UTC"))
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _weekly_rule() -> dict[str, object]:
|
||||
"""Every OTHER week's Tuesday and Friday, anchored on Tue 2026-03-03."""
|
||||
return {
|
||||
"rule_type": "weekly",
|
||||
"interval": 2,
|
||||
"weekday_set": [1, 4],
|
||||
"start_date": "2026-03-03",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def preview(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
||||
"""An admin-plane app client with the host clock pinned (and counted)."""
|
||||
clock_reads: list[datetime.datetime] = []
|
||||
|
||||
def _admin_now() -> datetime.datetime:
|
||||
clock_reads.append(PINNED_NOW)
|
||||
return PINNED_NOW
|
||||
|
||||
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(client=client, clock_reads=clock_reads)
|
||||
|
||||
|
||||
async def _post(client: object, body: dict[str, object]) -> object:
|
||||
"""POST a preview body with an admin JWT."""
|
||||
return await client.post(PREVIEW_URL, json=body, headers=_admin_headers())
|
||||
|
||||
|
||||
# --- authentication: the ONE require_admin dependency ----------------------
|
||||
|
||||
|
||||
async def test_preview_without_credential_is_401(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""No Authorization header is the uniform 401."""
|
||||
response = await preview.client.post(
|
||||
PREVIEW_URL, json={"rule": _weekly_rule()}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_preview_refuses_panel_service_token(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The panel service token is refused as a credential type (403)."""
|
||||
response = await preview.client.post(
|
||||
PREVIEW_URL,
|
||||
json={"rule": _weekly_rule()},
|
||||
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
||||
|
||||
|
||||
async def test_preview_refuses_non_admin_jwt(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A valid JWT without the nestquest-admins group is 403."""
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
response = await preview.client.post(
|
||||
PREVIEW_URL,
|
||||
json={"rule": _weekly_rule()},
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
||||
|
||||
|
||||
async def test_preview_admin_jwt_is_200(preview: SimpleNamespace) -> None:
|
||||
"""An admin JWT is 200 with the documented ``dates`` shape."""
|
||||
response = await _post(
|
||||
preview.client, {"rule": _weekly_rule(), "start_date": "2026-03-01"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert set(response.json()) == {"dates"}
|
||||
|
||||
|
||||
# --- the returned dates ----------------------------------------------------
|
||||
|
||||
|
||||
async def test_weekly_interval_rule_from_before_its_start(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Every other week's Tue/Fri, starting at the rule's own start_date.
|
||||
|
||||
The preview starts on Sun 2026-03-01, before the rule's anchor, so
|
||||
the first date is the anchor itself; the off weeks (Mar 10-16,
|
||||
Mar 24-30) are skipped.
|
||||
"""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{"rule": _weekly_rule(), "start_date": "2026-03-01", "count": 6},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dates"] == [
|
||||
"2026-03-03",
|
||||
"2026-03-06",
|
||||
"2026-03-17",
|
||||
"2026-03-20",
|
||||
"2026-03-31",
|
||||
"2026-04-03",
|
||||
]
|
||||
|
||||
|
||||
async def test_weekly_interval_rule_from_mid_cadence(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A start inside an on-week keeps the rule's anchor cadence."""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{"rule": _weekly_rule(), "start_date": "2026-03-18", "count": 4},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dates"] == [
|
||||
"2026-03-20",
|
||||
"2026-03-31",
|
||||
"2026-04-03",
|
||||
"2026-04-14",
|
||||
]
|
||||
|
||||
|
||||
async def test_daily_interval_rule(preview: SimpleNamespace) -> None:
|
||||
"""Every third day from 2026-01-01, previewed from 2026-01-05."""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{
|
||||
"rule": {
|
||||
"rule_type": "daily",
|
||||
"interval": 3,
|
||||
"start_date": "2026-01-01",
|
||||
},
|
||||
"start_date": "2026-01-05",
|
||||
"count": 4,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dates"] == [
|
||||
"2026-01-07",
|
||||
"2026-01-10",
|
||||
"2026-01-13",
|
||||
"2026-01-16",
|
||||
]
|
||||
|
||||
|
||||
async def test_rule_with_end_date_stops_at_it(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A daily rule ending 2026-05-04 yields only its four dates."""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{
|
||||
"rule": {
|
||||
"rule_type": "daily",
|
||||
"start_date": "2026-05-01",
|
||||
"end_date": "2026-05-04",
|
||||
},
|
||||
"start_date": "2026-04-28",
|
||||
"count": 10,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dates"] == [
|
||||
"2026-05-01",
|
||||
"2026-05-02",
|
||||
"2026-05-03",
|
||||
"2026-05-04",
|
||||
]
|
||||
|
||||
|
||||
async def test_monthly_day_rule_clamps_to_month_end(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The core's month-end policy: the 31st fires on Feb 28th."""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{
|
||||
"rule": {
|
||||
"rule_type": "monthly_day",
|
||||
"day_of_month": 31,
|
||||
"start_date": "2026-01-31",
|
||||
},
|
||||
"start_date": "2026-01-01",
|
||||
"count": 3,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dates"] == [
|
||||
"2026-01-31",
|
||||
"2026-02-28",
|
||||
"2026-03-31",
|
||||
]
|
||||
|
||||
|
||||
async def test_default_start_is_host_today_with_default_count(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""No start_date: ONE clock read's date; no count: ten dates."""
|
||||
response = await _post(preview.client, {"rule": {"rule_type": "daily"}})
|
||||
assert response.status_code == 200
|
||||
expected = [
|
||||
(datetime.date(2026, 9, 23) + datetime.timedelta(days=n)).isoformat()
|
||||
for n in range(10)
|
||||
]
|
||||
assert response.json()["dates"] == expected
|
||||
assert preview.clock_reads == [PINNED_NOW]
|
||||
|
||||
|
||||
async def test_supplied_start_date_reads_no_clock(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A supplied start_date is used as-is: the host clock is not read."""
|
||||
response = await _post(
|
||||
preview.client, {"rule": _weekly_rule(), "start_date": "2026-03-01"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert preview.clock_reads == []
|
||||
|
||||
|
||||
async def test_dates_match_core_occurrences_between_over_window(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The route's dates ARE the core's walk over the 366-day window."""
|
||||
rule_body = {
|
||||
"rule_type": "monthly_weekday",
|
||||
"nth_weekday": -1,
|
||||
"nth_weekday_weekday": 4,
|
||||
"start_date": "2026-01-01",
|
||||
}
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{"rule": rule_body, "start_date": "2026-02-10", "count": 50},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
core_dates = _recurrence.occurrences_between(
|
||||
_recurrence.ScheduleRule.from_dict(rule_body),
|
||||
"2026-02-10",
|
||||
"2027-02-11",
|
||||
)
|
||||
assert response.json()["dates"] == core_dates
|
||||
assert len(core_dates) == 12
|
||||
|
||||
|
||||
async def test_yearly_rule_shows_next_occurrence_within_window(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The 366-day window always reaches a yearly rule's next date."""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{
|
||||
"rule": {
|
||||
"rule_type": "yearly",
|
||||
"month": 9,
|
||||
"day_of_month": 22,
|
||||
"start_date": "2020-09-22",
|
||||
},
|
||||
"start_date": "2026-09-23",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dates"] == ["2027-09-22"]
|
||||
|
||||
|
||||
async def test_start_near_calendar_end_is_422(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A window that would reach 9999-12-31 is rejected, not clamped."""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{"rule": {"rule_type": "daily"}, "start_date": "9999-12-30"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert "too close to the end of the calendar" in response.json()["detail"]
|
||||
|
||||
|
||||
async def test_latest_accepted_start_date(preview: SimpleNamespace) -> None:
|
||||
"""The last start whose window ends before 9999-12-31 is accepted."""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{
|
||||
"rule": {"rule_type": "daily"},
|
||||
"start_date": "9998-12-29",
|
||||
"count": 1,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dates"] == ["9998-12-29"]
|
||||
|
||||
|
||||
# --- rejections: 422 -------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rule",
|
||||
[
|
||||
{"rule_type": "weekly"}, # weekly without weekday_set
|
||||
{"rule_type": "fortnightly"}, # unknown rule_type
|
||||
{"rule_type": "daily", "interval": 0},
|
||||
{"rule_type": "daily", "weekday_set": [1]}, # forbidden field
|
||||
{"rule_type": "daily", "start_date": "2026-3-1"},
|
||||
{
|
||||
"rule_type": "daily",
|
||||
"start_date": "2026-05-04",
|
||||
"end_date": "2026-05-01",
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_malformed_rule_is_422(
|
||||
preview: SimpleNamespace, rule: dict[str, object]
|
||||
) -> None:
|
||||
"""A rule the core's from_dict rejects is 422, as on create/edit."""
|
||||
response = await _post(
|
||||
preview.client, {"rule": rule, "start_date": "2026-03-01"}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_missing_rule_is_422(preview: SimpleNamespace) -> None:
|
||||
"""The rule is required: the body model's 422."""
|
||||
response = await _post(preview.client, {"start_date": "2026-03-01"})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"start_date", ["2026-3-1", "20260301", "tomorrow", "2026-02-30", ""]
|
||||
)
|
||||
async def test_non_strict_start_date_is_422(
|
||||
preview: SimpleNamespace, start_date: str
|
||||
) -> None:
|
||||
"""start_date must be a strict YYYY-MM-DD date (the core's check)."""
|
||||
response = await _post(
|
||||
preview.client, {"rule": _weekly_rule(), "start_date": start_date}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [0, -1, 51, 1000, True, "5", 2.5])
|
||||
async def test_count_out_of_range_is_422(
|
||||
preview: SimpleNamespace, count: object
|
||||
) -> None:
|
||||
"""count must be a strict JSON integer in 1..50."""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{"rule": _weekly_rule(), "start_date": "2026-03-01", "count": count},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [1, 50])
|
||||
async def test_count_bounds_are_inclusive(
|
||||
preview: SimpleNamespace, count: int
|
||||
) -> None:
|
||||
"""count 1 and 50 are accepted and truncate the list exactly."""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{
|
||||
"rule": {"rule_type": "daily"},
|
||||
"start_date": "2026-01-01",
|
||||
"count": count,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["dates"]) == count
|
||||
@@ -0,0 +1,448 @@
|
||||
"""Admin override consequence-count route tests (task 7616e3eb).
|
||||
|
||||
``POST /api/v1/admin/presence-overrides/consequence`` answers, BEFORE an
|
||||
override is saved, how many of the child's upcoming open quest
|
||||
instances saving it would remove (``{"removed": N}``) — the ADMIN-SPEC
|
||||
§6 "This removes N upcoming tasks" warning. These tests exercise it
|
||||
against the ASGI app with the shared stubbed-JWKS harness
|
||||
(:mod:`tests.admin_jwt_harness`):
|
||||
|
||||
- a two-week custody pattern (present the whole first week of the
|
||||
cycle, absent the whole second) where an absent override over a
|
||||
known span removes an EXACT number of instances;
|
||||
- ranges that remove nothing (already-absent days, a present
|
||||
override) count 0;
|
||||
- an instance with a completion event inside the range is NOT counted
|
||||
(D-005 — regeneration never deletes it);
|
||||
- a ``skip_on_away = false`` definition contributes nothing;
|
||||
- the count MATCHES a direct materialise comparison: the override is
|
||||
then actually created (which regenerates the child) and the removed
|
||||
open future instances are counted from the database;
|
||||
- the preview writes nothing;
|
||||
- the four auth outcomes, an unknown child 404 and a malformed range
|
||||
422.
|
||||
|
||||
The cycle is anchored on the REAL host "today" (the create route's
|
||||
regeneration reads the host clock, so the comparison test cannot pin
|
||||
it): days today..today+6 are cycle week 0 (present every day), days
|
||||
today+7..today+13 week 1 (absent every day), today+14 week 0 again.
|
||||
"""
|
||||
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 DAOs and materializer through the SAME core copy the app uses
|
||||
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy).
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
_dao_presence = importlib.import_module("nestquest_core.dao_presence")
|
||||
_materialize = importlib.import_module("nestquest_core.materialize")
|
||||
|
||||
CONSEQUENCE_URL = "/api/v1/admin/presence-overrides/consequence"
|
||||
OVERRIDES_URL = "/api/v1/admin/presence-overrides"
|
||||
|
||||
#: The panel service token the harness app is configured with.
|
||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
||||
|
||||
#: 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"
|
||||
)
|
||||
|
||||
#: The two-week custody pattern: week 0 every weekday, week 1 none.
|
||||
CUSTODY_PATTERN = {"0": [0, 1, 2, 3, 4, 5, 6], "1": []}
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _day(offset: int) -> str:
|
||||
"""The ISO date ``offset`` days after the host's today."""
|
||||
return (datetime.date.today() + datetime.timedelta(days=offset)).isoformat()
|
||||
|
||||
|
||||
def _body(
|
||||
child_id: int, start: int, end: int, *, is_present: bool = False
|
||||
) -> dict[str, object]:
|
||||
"""A consequence body over ``[today+start, today+end]``."""
|
||||
return {
|
||||
"child_id": child_id,
|
||||
"start_date": _day(start),
|
||||
"end_date": _day(end),
|
||||
"is_present": is_present,
|
||||
}
|
||||
|
||||
|
||||
async def _create_child(client: object, name: str = "Ada") -> int:
|
||||
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,
|
||||
child_id: int,
|
||||
title: str,
|
||||
windows: list[str],
|
||||
*,
|
||||
skip_on_away: bool = True,
|
||||
) -> int:
|
||||
created = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": title,
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [child_id],
|
||||
"windows": windows,
|
||||
"skip_on_away": skip_on_away,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
return created.json()["id"]
|
||||
|
||||
|
||||
async def _set_custody_schedule(client: object, child_id: int) -> None:
|
||||
"""Anchor the two-week custody cycle on today (week 0 starts today)."""
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json={
|
||||
"cycle_length_weeks": 2,
|
||||
"anchor_date": _day(0),
|
||||
"pattern": CUSTODY_PATTERN,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def _materialize_horizon(database: object) -> None:
|
||||
"""Materialize the household horizon so the stored state is current."""
|
||||
today = datetime.date.today()
|
||||
await _materialize.materialize(
|
||||
database, _day(0), _day(14), today=today
|
||||
)
|
||||
|
||||
|
||||
async def _open_future_keys(database: object, child_id: int) -> set[tuple]:
|
||||
"""The child's stored open (no completion event) future instance keys."""
|
||||
instances = await _dao_instances.QuestInstancesDao(
|
||||
database
|
||||
).list_by_date_range(child_id, _day(0), _day(60))
|
||||
events = _dao_instances.CompletionEventsDao(database)
|
||||
keys = set()
|
||||
for instance in instances:
|
||||
if not await events.list_by_instance(instance.id):
|
||||
keys.add(
|
||||
(instance.definition_id, instance.due_date, instance.window)
|
||||
)
|
||||
return keys
|
||||
|
||||
|
||||
async def _complete(
|
||||
database: object, child_id: int, definition_id: int, due_date: str,
|
||||
window: str,
|
||||
) -> None:
|
||||
"""Append a completion event to the stored instance."""
|
||||
instance = await _dao_instances.QuestInstancesDao(database).get(
|
||||
definition_id, child_id, due_date, window
|
||||
)
|
||||
assert instance is not None
|
||||
await _dao_instances.CompletionEventsDao(database).append(
|
||||
instance.id,
|
||||
child_id,
|
||||
"completed",
|
||||
"user",
|
||||
datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
),
|
||||
True,
|
||||
actor_user_id="admin-user-1",
|
||||
)
|
||||
|
||||
|
||||
async def _consequence(client: object, body: dict[str, object]) -> int:
|
||||
response = await client.post(
|
||||
CONSEQUENCE_URL, json=body, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()["removed"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def custody(temp_db_path: str) -> SimpleNamespace:
|
||||
"""A child on the two-week custody cycle with a two-window chore.
|
||||
|
||||
Also assigns a ``skip_on_away = false`` daily chore (generated
|
||||
regardless of presence) and materializes the horizon, so the stored
|
||||
instances are the walk's current output.
|
||||
"""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
database = runner.app.state.db.database
|
||||
child_id = await _create_child(client)
|
||||
chore_id = await _create_daily_definition(
|
||||
client, child_id, "Brush teeth", ["morning", "evening"]
|
||||
)
|
||||
always_id = await _create_daily_definition(
|
||||
client, child_id, "Feed the fish", ["afternoon"],
|
||||
skip_on_away=False,
|
||||
)
|
||||
await _set_custody_schedule(client, child_id)
|
||||
await _materialize_horizon(database)
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
database=database,
|
||||
child_id=child_id,
|
||||
chore_id=chore_id,
|
||||
always_id=always_id,
|
||||
)
|
||||
|
||||
|
||||
# --- the count ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_custody_override_removes_a_known_number(
|
||||
custody: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Absent over today+2..today+8 removes days +2..+6 x two windows.
|
||||
|
||||
Days +7 and +8 are already absent (cycle week 1), and the
|
||||
skip_on_away=false chore is generated regardless of presence, so
|
||||
exactly 5 present days x 2 windows = 10 instances would go.
|
||||
"""
|
||||
removed = await _consequence(
|
||||
custody.client, _body(custody.child_id, 2, 8)
|
||||
)
|
||||
assert removed == 10
|
||||
|
||||
|
||||
async def test_already_absent_range_removes_nothing(
|
||||
custody: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An absent override over cycle week 1 changes nothing: 0."""
|
||||
assert await _consequence(
|
||||
custody.client, _body(custody.child_id, 7, 13)
|
||||
) == 0
|
||||
|
||||
|
||||
async def test_present_override_removes_nothing(
|
||||
custody: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A present override only ever adds instances: 0."""
|
||||
assert await _consequence(
|
||||
custody.client, _body(custody.child_id, 2, 8, is_present=True)
|
||||
) == 0
|
||||
|
||||
|
||||
async def test_past_range_removes_nothing(custody: SimpleNamespace) -> None:
|
||||
"""A range entirely before today touches no upcoming instance: 0."""
|
||||
assert await _consequence(
|
||||
custody.client, _body(custody.child_id, -20, -1)
|
||||
) == 0
|
||||
|
||||
|
||||
async def test_completed_instance_in_range_is_not_counted(
|
||||
custody: SimpleNamespace,
|
||||
) -> None:
|
||||
"""D-005: an instance with a completion event is never removed."""
|
||||
await _complete(
|
||||
custody.database, custody.child_id, custody.chore_id, _day(3),
|
||||
"morning",
|
||||
)
|
||||
removed = await _consequence(
|
||||
custody.client, _body(custody.child_id, 2, 8)
|
||||
)
|
||||
assert removed == 9
|
||||
|
||||
|
||||
async def test_skip_on_away_false_definition_contributes_nothing(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A child whose only chore ignores presence: absent removes 0."""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
database = runner.app.state.db.database
|
||||
child_id = await _create_child(client)
|
||||
await _create_daily_definition(
|
||||
client, child_id, "Feed the fish", ["morning"],
|
||||
skip_on_away=False,
|
||||
)
|
||||
await _set_custody_schedule(client, child_id)
|
||||
await _materialize_horizon(database)
|
||||
assert await _consequence(client, _body(child_id, 0, 14)) == 0
|
||||
|
||||
|
||||
async def test_count_matches_a_direct_materialise_comparison(
|
||||
custody: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Saving the override removes EXACTLY the previewed instances.
|
||||
|
||||
With a completed instance inside the range, the preview is taken,
|
||||
then the override is really created (its core path regenerates the
|
||||
child) and the open future instances that disappeared are counted
|
||||
from the database. The completed instance survives the regeneration.
|
||||
"""
|
||||
client, database = custody.client, custody.database
|
||||
await _complete(
|
||||
database, custody.child_id, custody.chore_id, _day(4), "evening"
|
||||
)
|
||||
body = _body(custody.child_id, 1, 9)
|
||||
removed = await _consequence(client, body)
|
||||
|
||||
before = await _open_future_keys(database, custody.child_id)
|
||||
created = await client.post(
|
||||
OVERRIDES_URL, json=body, headers=_admin_headers()
|
||||
)
|
||||
assert created.status_code == 201
|
||||
after = await _open_future_keys(database, custody.child_id)
|
||||
|
||||
assert after <= before
|
||||
assert len(before - after) == removed
|
||||
# Days +1..+6 present x two windows, minus the completed one.
|
||||
assert removed == 11
|
||||
completed = await _dao_instances.QuestInstancesDao(database).get(
|
||||
custody.chore_id, custody.child_id, _day(4), "evening"
|
||||
)
|
||||
assert completed is not None
|
||||
|
||||
|
||||
async def test_preview_writes_nothing(custody: SimpleNamespace) -> None:
|
||||
"""No override is stored and no instance is removed by the preview."""
|
||||
client, database = custody.client, custody.database
|
||||
before = await _open_future_keys(database, custody.child_id)
|
||||
assert await _consequence(client, _body(custody.child_id, 0, 14)) > 0
|
||||
assert await _open_future_keys(database, custody.child_id) == before
|
||||
overrides = await _dao_presence.PresenceOverridesDao(
|
||||
database
|
||||
).list_by_child_and_range(custody.child_id, "2000-01-01", "2100-01-01")
|
||||
assert overrides == []
|
||||
|
||||
|
||||
# --- errors ------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_unknown_child_is_404(custody: SimpleNamespace) -> None:
|
||||
response = await custody.client.post(
|
||||
CONSEQUENCE_URL, json=_body(9999, 1, 2), headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides",
|
||||
[
|
||||
{"start_date": "2026-02-30"},
|
||||
{"end_date": "2026-1-5"},
|
||||
{"start_date": "2026-03-10", "end_date": "2026-03-01"},
|
||||
{"is_present": "sometimes"},
|
||||
{"child_id": "one"},
|
||||
],
|
||||
)
|
||||
async def test_malformed_range_or_status_is_422(
|
||||
custody: SimpleNamespace, overrides: dict[str, object]
|
||||
) -> None:
|
||||
body = {**_body(custody.child_id, 1, 2), **overrides}
|
||||
response = await custody.client.post(
|
||||
CONSEQUENCE_URL, json=body, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides",
|
||||
[
|
||||
{"child_id": True},
|
||||
{"child_id": False},
|
||||
{"child_id": "1"},
|
||||
{"child_id": 1.0},
|
||||
{"is_present": 1},
|
||||
{"is_present": 0},
|
||||
{"is_present": "true"},
|
||||
{"is_present": "false"},
|
||||
],
|
||||
)
|
||||
async def test_coercible_child_id_or_status_is_422(
|
||||
custody: SimpleNamespace, overrides: dict[str, object]
|
||||
) -> None:
|
||||
"""Strict fields: a bool id or a numeric/string status is rejected.
|
||||
|
||||
The fixture child's id is 1, so a coerced ``true`` would otherwise
|
||||
target it and return 200. Nothing is stored and nothing removed.
|
||||
"""
|
||||
assert custody.child_id == 1
|
||||
database = custody.database
|
||||
before = await _open_future_keys(database, custody.child_id)
|
||||
body = {**_body(custody.child_id, 0, 14), **overrides}
|
||||
response = await custody.client.post(
|
||||
CONSEQUENCE_URL, json=body, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert await _open_future_keys(database, custody.child_id) == before
|
||||
overrides_stored = await _dao_presence.PresenceOverridesDao(
|
||||
database
|
||||
).list_by_child_and_range(custody.child_id, "2000-01-01", "2100-01-01")
|
||||
assert overrides_stored == []
|
||||
|
||||
|
||||
# --- authentication: the router's ONE require_admin --------------------------
|
||||
|
||||
|
||||
async def test_no_credential_is_401(custody: SimpleNamespace) -> None:
|
||||
response = await custody.client.post(
|
||||
CONSEQUENCE_URL, json=_body(custody.child_id, 1, 2)
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_panel_service_token_is_403(custody: SimpleNamespace) -> None:
|
||||
response = await custody.client.post(
|
||||
CONSEQUENCE_URL,
|
||||
json=_body(custody.child_id, 1, 2),
|
||||
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
||||
|
||||
|
||||
async def test_non_admin_jwt_is_403(custody: SimpleNamespace) -> None:
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
response = await custody.client.post(
|
||||
CONSEQUENCE_URL,
|
||||
json=_body(custody.child_id, 1, 2),
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
||||
|
||||
|
||||
async def test_admin_jwt_is_200(custody: SimpleNamespace) -> None:
|
||||
response = await custody.client.post(
|
||||
CONSEQUENCE_URL,
|
||||
json=_body(custody.child_id, 1, 2),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"removed": 4}
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Admin-plane presence READ route tests (task 07fde892).
|
||||
|
||||
``GET /api/v1/admin/children/{child_id}/presence-schedule`` and
|
||||
``GET /api/v1/admin/presence-overrides`` against the ASGI app with
|
||||
httpx, through the shared stubbed-JWKS harness
|
||||
(:mod:`tests.admin_jwt_harness`) — no network access.
|
||||
|
||||
- The schedule read answers ``{"schedule": {...}}`` in the PUT
|
||||
response's shape for a seeded schedule, ``{"schedule": null}`` for a
|
||||
child with none (present every day — NOT a 404), and 404 for an
|
||||
unknown child.
|
||||
- The overrides read answers ``{"overrides": [...]}`` in the POST
|
||||
response's shape, ordered by start date, child id, id; ``child_id``
|
||||
and the ``start``/``end`` range each narrow the list; a malformed or
|
||||
inverted range is 422 and an unknown ``child_id`` is 404.
|
||||
- Both routes inherit the router's ONE ``require_admin``: no
|
||||
credential 401, the panel service token 403, a non-admin JWT 403, an
|
||||
admin JWT 200.
|
||||
|
||||
Seeding goes through the admin write routes, so the reads are proven
|
||||
against rows the real write path stored.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for, make_token
|
||||
|
||||
OVERRIDES_URL = "/api/v1/admin/presence-overrides"
|
||||
|
||||
#: The panel service token the harness app is configured with.
|
||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
||||
|
||||
#: 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 _schedule_url(child_id: int) -> str:
|
||||
return f"/api/v1/admin/children/{child_id}/presence-schedule"
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
async def _create_child(client: object, name: str) -> 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"]
|
||||
|
||||
|
||||
async def _create_override(
|
||||
client: object,
|
||||
child_id: int,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
is_present: bool = False,
|
||||
note: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Create one override through the admin write route; return it."""
|
||||
created = await client.post(
|
||||
OVERRIDES_URL,
|
||||
json={
|
||||
"child_id": child_id,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"is_present": is_present,
|
||||
"note": note,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
return created.json()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def reads(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane client over a fresh database."""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(client=client)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seeded(reads: SimpleNamespace) -> SimpleNamespace:
|
||||
"""Two children with interleaved overrides (Ada has a schedule)."""
|
||||
client = reads.client
|
||||
ada = await _create_child(client, "Ada")
|
||||
ben = await _create_child(client, "Ben")
|
||||
put = await client.put(
|
||||
_schedule_url(ada),
|
||||
json={
|
||||
"cycle_length_weeks": 2,
|
||||
"anchor_date": "2026-01-05",
|
||||
"pattern": {"0": [4, 0, 2], "1": []},
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert put.status_code == 200
|
||||
ada_march = await _create_override(
|
||||
client, ada, "2026-03-01", "2026-03-07", note="grandparents"
|
||||
)
|
||||
ben_january = await _create_override(
|
||||
client, ben, "2026-01-10", "2026-01-12", is_present=True
|
||||
)
|
||||
ben_march = await _create_override(client, ben, "2026-03-01", "2026-03-01")
|
||||
ada_may = await _create_override(client, ada, "2026-05-20", "2026-05-20")
|
||||
return SimpleNamespace(
|
||||
client=client,
|
||||
ada=ada,
|
||||
ben=ben,
|
||||
ada_march=ada_march,
|
||||
ben_january=ben_january,
|
||||
ben_march=ben_march,
|
||||
ada_may=ada_may,
|
||||
)
|
||||
|
||||
|
||||
# --- presence schedule read ------------------------------------------------
|
||||
|
||||
|
||||
async def test_schedule_read_returns_the_stored_schedule(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A seeded schedule reads back in the PUT response's shape."""
|
||||
response = await seeded.client.get(
|
||||
_schedule_url(seeded.ada), headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"schedule": {
|
||||
"child_id": seeded.ada,
|
||||
"cycle_length_weeks": 2,
|
||||
"anchor_date": "2026-01-05",
|
||||
"pattern": {"0": [0, 2, 4], "1": []},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def test_schedule_read_without_schedule_is_null_not_404(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A child with no schedule (present every day) is schedule: null."""
|
||||
response = await seeded.client.get(
|
||||
_schedule_url(seeded.ben), headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"schedule": None}
|
||||
|
||||
|
||||
async def test_schedule_read_unknown_child_is_404(
|
||||
reads: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An unknown child is 404 with the children routes' detail."""
|
||||
response = await reads.client.get(
|
||||
_schedule_url(999), headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Child not found"}
|
||||
|
||||
|
||||
# --- presence overrides read -----------------------------------------------
|
||||
|
||||
|
||||
async def test_overrides_read_lists_all_in_stable_order(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Unfiltered: every override, by start date, then child id, then id."""
|
||||
response = await seeded.client.get(
|
||||
OVERRIDES_URL, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"overrides": [
|
||||
seeded.ben_january,
|
||||
seeded.ada_march,
|
||||
seeded.ben_march,
|
||||
seeded.ada_may,
|
||||
]
|
||||
}
|
||||
assert seeded.ada_march == {
|
||||
"id": seeded.ada_march["id"],
|
||||
"child_id": seeded.ada,
|
||||
"start_date": "2026-03-01",
|
||||
"end_date": "2026-03-07",
|
||||
"is_present": False,
|
||||
"note": "grandparents",
|
||||
}
|
||||
|
||||
|
||||
async def test_overrides_read_empty_household(reads: SimpleNamespace) -> None:
|
||||
"""No overrides at all is an empty list."""
|
||||
response = await reads.client.get(OVERRIDES_URL, headers=_admin_headers())
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"overrides": []}
|
||||
|
||||
|
||||
async def test_overrides_read_filters_by_child(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""child_id keeps only that child's overrides."""
|
||||
response = await seeded.client.get(
|
||||
OVERRIDES_URL,
|
||||
params={"child_id": seeded.ada},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"overrides": [seeded.ada_march, seeded.ada_may]
|
||||
}
|
||||
|
||||
|
||||
async def test_overrides_read_filters_by_overlapping_range(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""start/end keep overrides overlapping the closed range (inclusive)."""
|
||||
# 2026-03-07 is Ada's March override's LAST day: the boundary counts.
|
||||
response = await seeded.client.get(
|
||||
OVERRIDES_URL,
|
||||
params={"start": "2026-03-07", "end": "2026-05-20"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"overrides": [seeded.ada_march, seeded.ada_may]
|
||||
}
|
||||
|
||||
|
||||
async def test_overrides_read_open_ended_range_filters(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""start alone keeps later-ending, end alone keeps earlier-starting."""
|
||||
from_april = await seeded.client.get(
|
||||
OVERRIDES_URL, params={"start": "2026-04-01"}, headers=_admin_headers()
|
||||
)
|
||||
assert from_april.json() == {"overrides": [seeded.ada_may]}
|
||||
until_january = await seeded.client.get(
|
||||
OVERRIDES_URL, params={"end": "2026-01-31"}, headers=_admin_headers()
|
||||
)
|
||||
assert until_january.json() == {"overrides": [seeded.ben_january]}
|
||||
|
||||
|
||||
async def test_overrides_read_combines_child_and_range(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""child_id and the range narrow together."""
|
||||
response = await seeded.client.get(
|
||||
OVERRIDES_URL,
|
||||
params={"child_id": seeded.ben, "start": "2026-02-01", "end": "2026-12-31"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"overrides": [seeded.ben_march]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"params",
|
||||
[
|
||||
{"start": "2026-03-10", "end": "2026-03-01"},
|
||||
{"start": "2026-3-1"},
|
||||
{"end": "not-a-date"},
|
||||
{"start": "2026-02-30"},
|
||||
],
|
||||
)
|
||||
async def test_overrides_read_invalid_range_is_422(
|
||||
seeded: SimpleNamespace, params: dict[str, str]
|
||||
) -> None:
|
||||
"""A malformed date or an inverted range is the core's 422."""
|
||||
response = await seeded.client.get(
|
||||
OVERRIDES_URL, params=params, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_overrides_read_unknown_child_is_404(
|
||||
reads: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An unknown child_id filter is 404, never an empty list."""
|
||||
response = await reads.client.get(
|
||||
OVERRIDES_URL, params={"child_id": 999}, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Child not found"}
|
||||
|
||||
|
||||
# --- authentication: the router's ONE require_admin --------------------------
|
||||
|
||||
|
||||
@pytest.fixture(params=["schedule", "overrides"])
|
||||
async def read_url(request: pytest.FixtureRequest, reads: SimpleNamespace) -> str:
|
||||
"""Each read route's URL (the schedule route for a real child)."""
|
||||
if request.param == "overrides":
|
||||
return OVERRIDES_URL
|
||||
return _schedule_url(await _create_child(reads.client, "Ada"))
|
||||
|
||||
|
||||
async def test_read_without_credential_is_401(
|
||||
reads: SimpleNamespace, read_url: str
|
||||
) -> None:
|
||||
response = await reads.client.get(read_url)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_read_refuses_panel_service_token(
|
||||
reads: SimpleNamespace, read_url: str
|
||||
) -> None:
|
||||
response = await reads.client.get(
|
||||
read_url, headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
||||
|
||||
|
||||
async def test_read_refuses_non_admin_jwt(
|
||||
reads: SimpleNamespace, read_url: str
|
||||
) -> None:
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
response = await reads.client.get(
|
||||
read_url, headers={"Authorization": f"Bearer {non_admin}"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
||||
|
||||
|
||||
async def test_read_admin_jwt_is_200(
|
||||
reads: SimpleNamespace, read_url: str
|
||||
) -> None:
|
||||
response = await reads.client.get(read_url, headers=_admin_headers())
|
||||
assert response.status_code == 200
|
||||
@@ -18,8 +18,9 @@ caveat in api/nestquest_core.py):
|
||||
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.
|
||||
semantics); a supplied ``windows`` list replaces the whole set; an
|
||||
omitted ``assignee_child_ids`` leaves the assignees untouched (the
|
||||
supplied-list cases live in test_api_admin_questdef_assignees.py).
|
||||
- ``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);
|
||||
@@ -215,6 +216,7 @@ async def test_create_persists_definition_with_assignees_and_windows(
|
||||
"description",
|
||||
"icon",
|
||||
"is_active",
|
||||
"skip_on_away",
|
||||
"rule",
|
||||
"assignees",
|
||||
"windows",
|
||||
@@ -223,6 +225,7 @@ async def test_create_persists_definition_with_assignees_and_windows(
|
||||
assert payload["description"] == "Put everything back where it belongs."
|
||||
assert payload["icon"] == "toy-box"
|
||||
assert payload["is_active"] is True
|
||||
assert payload["skip_on_away"] is True
|
||||
assert payload["rule"] == _weekly_rule_dict()
|
||||
assert {a["id"]: a["display_name"] for a in payload["assignees"]} == {
|
||||
ada: "Ada",
|
||||
@@ -395,7 +398,7 @@ async def test_edit_persists_supplied_fields_and_leaves_omitted_untouched(
|
||||
)
|
||||
assert stored.definition.icon == "toy-box"
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
# Assignment is not settable through the edit route: untouched.
|
||||
# assignee_child_ids was omitted: the assignees are untouched.
|
||||
assert sorted(child.id for child in stored.assignees) == [ada, bo]
|
||||
|
||||
# A second edit touching only the rule leaves the metadata and the
|
||||
@@ -521,9 +524,130 @@ async def test_edit_rejections_are_422_and_change_nothing(
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("coercible", [0, 1, "true", "false"])
|
||||
async def test_create_rejects_coercible_skip_on_away_and_persists_nothing(
|
||||
admin_questdefs: SimpleNamespace, coercible: object
|
||||
) -> None:
|
||||
"""A numeric or string skip_on_away is 422, not coerced to a bool."""
|
||||
client = admin_questdefs.client
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
existing = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Feed the cat",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": [ada],
|
||||
"windows": ["morning"],
|
||||
"skip_on_away": False,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert existing.status_code == 201
|
||||
existing_id = existing.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Tidy the den",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": [ada],
|
||||
"windows": ["morning"],
|
||||
"skip_on_away": coercible,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert set(await _definitions_by_id(admin_questdefs.database)) == {
|
||||
existing_id
|
||||
}
|
||||
assert (await dao.get(existing_id)).skip_on_away is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("coercible", [0, 1, "true", "false"])
|
||||
async def test_edit_rejects_coercible_skip_on_away_and_changes_nothing(
|
||||
admin_questdefs: SimpleNamespace, coercible: object
|
||||
) -> None:
|
||||
"""A numeric or string skip_on_away edit is 422; the flag is kept."""
|
||||
client = admin_questdefs.client
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
definition_id = (await _create_definition(client, [ada])).json()["id"]
|
||||
# Start from each stored value so a coerced write would be visible.
|
||||
for stored in (True, False):
|
||||
set_flag = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"skip_on_away": stored},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert set_flag.status_code == 200
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"skip_on_away": coercible, "title": "Renamed"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
stored_definition = await dao.get(definition_id)
|
||||
assert stored_definition.skip_on_away is stored
|
||||
assert stored_definition.title == "Tidy the den"
|
||||
|
||||
|
||||
# --- set_active -------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_edit_toggles_skip_on_away_and_omission_keeps_it(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A supplied skip_on_away persists; an edit omitting it keeps it."""
|
||||
client = admin_questdefs.client
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
created = await _create_definition(client, [ada])
|
||||
assert created.status_code == 201
|
||||
definition_id = created.json()["id"]
|
||||
assert created.json()["skip_on_away"] is True
|
||||
|
||||
toggled_off = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"skip_on_away": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert toggled_off.status_code == 200
|
||||
assert toggled_off.json()["skip_on_away"] is False
|
||||
assert (await dao.get(definition_id)).skip_on_away is False
|
||||
|
||||
# An edit that omits the flag leaves the stored False untouched.
|
||||
renamed = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"title": "Tidy the den properly"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert renamed.status_code == 200
|
||||
assert renamed.json()["skip_on_away"] is False
|
||||
stored = await dao.get(definition_id)
|
||||
assert stored.title == "Tidy the den properly"
|
||||
assert stored.skip_on_away is False
|
||||
|
||||
toggled_on = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"skip_on_away": True},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert toggled_on.status_code == 200
|
||||
assert toggled_on.json()["skip_on_away"] is True
|
||||
assert (await dao.get(definition_id)).skip_on_away is True
|
||||
|
||||
# A non-boolean value is a malformed body: 422, nothing changes.
|
||||
rejected = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"skip_on_away": "sometimes"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert rejected.status_code == 422
|
||||
assert (await dao.get(definition_id)).skip_on_away is True
|
||||
|
||||
|
||||
async def test_set_active_toggles_the_flag_and_the_definition_survives(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Admin-plane editable assignees on the definition edit route (task 1c8096f6).
|
||||
|
||||
``PATCH /api/v1/admin/quest-definitions/{definition_id}`` accepts an
|
||||
optional ``assignee_child_ids`` list that REPLACES the definition's
|
||||
assignee set inside the core edit's ONE transaction
|
||||
(:func:`nestquest_core.quest_definitions.edit_quest_definition`):
|
||||
|
||||
- newly listed children are assigned and unlisted ones unassigned; the
|
||||
response and the database both carry the resulting set;
|
||||
- an empty list, a duplicate, an unknown child or an inactive child is
|
||||
422 and NOTHING is written — the stored assignees (and any other
|
||||
field supplied in the same body) are unchanged;
|
||||
- a JSON boolean, float or numeric string id is 422 on create and edit
|
||||
(never coerced to an int) and nothing is written;
|
||||
- an omitted ``assignee_child_ids`` leaves the assignees unchanged;
|
||||
- an assignment change regenerates the definition's future instances
|
||||
through the existing core path;
|
||||
- the route stays behind the router's ONE ``require_admin`` dependency
|
||||
(absent credential 401, panel service token 403, non-admin JWT 403,
|
||||
admin JWT 200).
|
||||
|
||||
Every case is proven against the DATABASE, read through the SAME core
|
||||
copy the app uses (``nestquest_core.*``; see api/nestquest_core.py).
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
_dao_rules = importlib.import_module("nestquest_core.dao_rules")
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
_materialize = importlib.import_module("nestquest_core.materialize")
|
||||
_quest_definitions = importlib.import_module(
|
||||
"nestquest_core.quest_definitions"
|
||||
)
|
||||
|
||||
#: The panel service token the harness app is configured with.
|
||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _edit_path(definition_id: int) -> str:
|
||||
"""The edit route for one definition."""
|
||||
return f"/api/v1/admin/quest-definitions/{definition_id}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_assignees(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database."""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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]) -> int:
|
||||
"""POST a daily morning definition for the given assignees."""
|
||||
created = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Brush teeth",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": assignee_ids,
|
||||
"windows": ["morning"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
return created.json()["id"]
|
||||
|
||||
|
||||
async def _stored_assignee_ids(
|
||||
database: object, definition_id: int
|
||||
) -> set[int]:
|
||||
"""The definition's assignee ids as stored."""
|
||||
dao = _dao_rules.QuestDefinitionsDao(database)
|
||||
return {child.id for child in await dao.list_assignees(definition_id)}
|
||||
|
||||
|
||||
def _response_assignee_ids(response: object) -> set[int]:
|
||||
"""The assignee ids a definition payload reports."""
|
||||
return {assignee["id"] for assignee in response.json()["assignees"]}
|
||||
|
||||
|
||||
# --- add / remove / replace -------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("initial", "requested"),
|
||||
[
|
||||
(("Ada",), ("Ada", "Bo")), # add a child
|
||||
(("Ada", "Bo"), ("Ada",)), # remove a child
|
||||
(("Ada", "Bo"), ("Cy",)), # replace the whole set
|
||||
],
|
||||
)
|
||||
async def test_edit_replaces_the_assignee_set(
|
||||
admin_assignees: SimpleNamespace,
|
||||
initial: tuple[str, ...],
|
||||
requested: tuple[str, ...],
|
||||
) -> None:
|
||||
"""A supplied list becomes exactly the stored and returned set."""
|
||||
client = admin_assignees.client
|
||||
names = ("Ada", "Bo", "Cy")
|
||||
ids = dict(zip(names, await _create_children(client, names)))
|
||||
definition_id = await _create_definition(
|
||||
client, [ids[name] for name in initial]
|
||||
)
|
||||
wanted = {ids[name] for name in requested}
|
||||
|
||||
response = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json={"assignee_child_ids": [ids[name] for name in requested]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert _response_assignee_ids(response) == wanted
|
||||
assert {a["display_name"] for a in response.json()["assignees"]} == set(
|
||||
requested
|
||||
)
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == wanted
|
||||
# The rest of the definition is untouched by an assignee-only edit.
|
||||
assert response.json()["title"] == "Brush teeth"
|
||||
assert [w["window"] for w in response.json()["windows"]] == ["morning"]
|
||||
|
||||
|
||||
async def test_edit_with_assignees_and_other_fields_applies_both(
|
||||
admin_assignees: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Assignees and metadata supplied together land in one edit."""
|
||||
client = admin_assignees.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
definition_id = await _create_definition(client, [ada])
|
||||
|
||||
response = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json={"title": "Floss", "assignee_child_ids": [bo]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["title"] == "Floss"
|
||||
assert _response_assignee_ids(response) == {bo}
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database)
|
||||
assert (await dao.get(definition_id)).title == "Floss"
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == {bo}
|
||||
|
||||
|
||||
# --- rejections leave the stored state unchanged ----------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("case", "expected_detail"),
|
||||
[
|
||||
("empty", "assignee_child_ids must not be empty"),
|
||||
("unknown", "assignee_child_ids: child 999 does not exist"),
|
||||
("inactive", "assignee_child_ids: child {cy} is inactive"),
|
||||
("duplicate", "assignee_child_ids must not contain duplicate"),
|
||||
("null", "assignee_child_ids must be a list"),
|
||||
("mixed_unknown", "assignee_child_ids: child 999 does not exist"),
|
||||
("wrong_type", None), # the body model's own 422
|
||||
],
|
||||
)
|
||||
async def test_rejected_assignee_list_is_422_and_changes_nothing(
|
||||
admin_assignees: SimpleNamespace,
|
||||
case: str,
|
||||
expected_detail: str | None,
|
||||
) -> None:
|
||||
"""Empty/unknown/inactive/malformed lists are 422 with no mutation.
|
||||
|
||||
The same body also renames the definition, proving the whole edit
|
||||
is all-or-nothing: neither the assignees nor the title change.
|
||||
"""
|
||||
client = admin_assignees.client
|
||||
ada, bo, cy, di = await _create_children(
|
||||
client, ("Ada", "Bo", "Cy", "Di")
|
||||
)
|
||||
definition_id = await _create_definition(client, [ada, bo])
|
||||
deactivated = await client.patch(
|
||||
f"/api/v1/admin/children/{cy}/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
|
||||
requested: object = {
|
||||
"empty": [],
|
||||
"unknown": [999],
|
||||
"inactive": [ada, cy],
|
||||
"duplicate": [ada, ada],
|
||||
"null": None,
|
||||
# A valid new child alongside an unknown one: di must NOT be
|
||||
# assigned (all-or-nothing), and ada/bo must NOT be removed.
|
||||
"mixed_unknown": [di, 999],
|
||||
"wrong_type": ["Ada"],
|
||||
}[case]
|
||||
|
||||
response = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json={"title": "Renamed", "assignee_child_ids": requested},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 422, case
|
||||
if expected_detail is not None:
|
||||
assert response.json()["detail"].startswith(
|
||||
expected_detail.format(cy=cy)
|
||||
), response.json()
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == {ada, bo}
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database)
|
||||
assert (await dao.get(definition_id)).title == "Brush teeth"
|
||||
|
||||
|
||||
#: JSON ids the lax ``int`` parser would coerce (``true`` -> 1).
|
||||
_COERCIBLE_IDS = [True, 1.0, "1"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("coercible", _COERCIBLE_IDS)
|
||||
async def test_edit_rejects_coercible_assignee_ids_and_changes_nothing(
|
||||
admin_assignees: SimpleNamespace, coercible: object
|
||||
) -> None:
|
||||
"""A bool, float or numeric-string id edit is 422; nothing changes."""
|
||||
client = admin_assignees.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
# Assign bo only, so a coerced id 1 (ada) would be a visible change.
|
||||
definition_id = await _create_definition(client, [bo])
|
||||
assert ada == 1
|
||||
|
||||
response = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json={"title": "Renamed", "assignee_child_ids": [coercible]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == {bo}
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database)
|
||||
assert (await dao.get(definition_id)).title == "Brush teeth"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("coercible", _COERCIBLE_IDS)
|
||||
async def test_create_rejects_coercible_assignee_ids_and_persists_nothing(
|
||||
admin_assignees: SimpleNamespace, coercible: object
|
||||
) -> None:
|
||||
"""A bool, float or numeric-string id create is 422; nothing is written."""
|
||||
client = admin_assignees.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
assert ada == 1
|
||||
existing_id = await _create_definition(client, [bo])
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Tidy the den",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [coercible],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
bundles = await _quest_definitions.list_active_definitions(
|
||||
admin_assignees.database
|
||||
)
|
||||
assert [bundle.definition.id for bundle in bundles] == [existing_id]
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, existing_id
|
||||
) == {bo}
|
||||
|
||||
|
||||
async def test_rejected_assignees_on_unknown_definition_is_404(
|
||||
admin_assignees: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An unknown definition id is still 404 when assignees are supplied."""
|
||||
client = admin_assignees.client
|
||||
(ada,) = await _create_children(client, ("Ada",))
|
||||
response = await client.patch(
|
||||
_edit_path(999),
|
||||
json={"assignee_child_ids": [ada]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Quest definition not found"
|
||||
|
||||
|
||||
# --- omitted keeps ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_omitted_assignees_are_unchanged(
|
||||
admin_assignees: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An edit without assignee_child_ids never touches the assignees."""
|
||||
client = admin_assignees.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
definition_id = await _create_definition(client, [ada, bo])
|
||||
|
||||
response = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json={"title": "Floss"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert _response_assignee_ids(response) == {ada, bo}
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == {ada, bo}
|
||||
|
||||
|
||||
# --- regeneration -----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_assignment_change_regenerates_future_instances(
|
||||
admin_assignees: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Swapping the assignee moves the future open instances with it.
|
||||
|
||||
The regeneration lives in the core edit path the route calls: the
|
||||
definition's open instances at or after today are deleted and the
|
||||
horizon re-materialized for the NEW assignee set.
|
||||
"""
|
||||
client = admin_assignees.client
|
||||
database = admin_assignees.database
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
definition_id = await _create_definition(client, [ada])
|
||||
|
||||
today = datetime.date.today()
|
||||
start = today.isoformat()
|
||||
end = (today + datetime.timedelta(days=7)).isoformat()
|
||||
await _materialize.materialize(database, start, end, today=today)
|
||||
instances = _dao_instances.QuestInstancesDao(database)
|
||||
assert await instances.list_by_date_range(ada, start, end)
|
||||
assert await instances.list_by_date_range(bo, start, end) == []
|
||||
|
||||
response = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json={"assignee_child_ids": [bo]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert await instances.list_by_date_range(ada, start, end) == []
|
||||
regenerated = await instances.list_by_date_range(bo, start, end)
|
||||
assert regenerated
|
||||
assert {i.definition_id for i in regenerated} == {definition_id}
|
||||
|
||||
|
||||
# --- authentication ---------------------------------------------------------
|
||||
|
||||
|
||||
async def test_assignee_edit_auth_outcomes(
|
||||
admin_assignees: SimpleNamespace,
|
||||
) -> None:
|
||||
"""401 absent, 403 panel token, 403 non-admin, 200 admin."""
|
||||
client = admin_assignees.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
definition_id = await _create_definition(client, [ada])
|
||||
body = {"assignee_child_ids": [bo]}
|
||||
|
||||
absent = await client.patch(_edit_path(definition_id), json=body)
|
||||
assert absent.status_code == 401
|
||||
|
||||
panel = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
|
||||
)
|
||||
assert panel.status_code == 403
|
||||
|
||||
non_admin_token = make_token(groups=("some-other-group",))
|
||||
non_admin = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {non_admin_token}"},
|
||||
)
|
||||
assert non_admin.status_code == 403
|
||||
|
||||
# None of the refused calls reached the handler.
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == {ada}
|
||||
|
||||
admin = await client.patch(
|
||||
_edit_path(definition_id), json=body, headers=_admin_headers()
|
||||
)
|
||||
assert admin.status_code == 200
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == {bo}
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Admin-plane quest-definition LIST route tests (task d4667573).
|
||||
|
||||
``GET /api/v1/admin/quest-definitions`` 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
|
||||
with NO network access.
|
||||
|
||||
- An admin JWT lists EVERY definition — active AND inactive — in rising
|
||||
id order, each in the same documented shape as the create/edit
|
||||
responses (id, title, description, icon, is_active, rule, assignees,
|
||||
windows).
|
||||
- An empty household lists nothing: 200 with an empty list.
|
||||
- Authentication runs through the router's ONE ``require_admin``
|
||||
dependency: an absent credential is 401, the panel service token is
|
||||
refused outright (403, the credential-type detail), and a JWT without
|
||||
the nestquest-admins group is 403.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
|
||||
#: The admin list route under test.
|
||||
LIST_PATH = "/api/v1/admin/quest-definitions"
|
||||
|
||||
#: The panel service token the harness app is configured with.
|
||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
||||
|
||||
#: 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 _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_client(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client over a fresh (empty) household."""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield client
|
||||
|
||||
|
||||
async def _seed_active_and_inactive(client: object) -> tuple[int, int, int]:
|
||||
"""Seed one child, an ACTIVE definition and an INACTIVE one.
|
||||
|
||||
Returns ``(child_id, active_id, inactive_id)``. Seeded through the
|
||||
admin routes so the rows are exactly what the admin plane writes.
|
||||
"""
|
||||
child = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Ada"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert child.status_code == 201
|
||||
child_id = child.json()["id"]
|
||||
|
||||
active = await client.post(
|
||||
LIST_PATH,
|
||||
json={
|
||||
"title": "Tidy the den",
|
||||
"rule": {
|
||||
"rule_type": "weekly",
|
||||
"interval": 1,
|
||||
"weekday_set": [0, 2],
|
||||
"start_date": "2026-03-02",
|
||||
},
|
||||
"assignee_child_ids": [child_id],
|
||||
"windows": ["morning", ["evening", "19:30"]],
|
||||
"description": "Put everything back where it belongs.",
|
||||
"icon": "toy-box",
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert active.status_code == 201
|
||||
|
||||
inactive = await client.post(
|
||||
LIST_PATH,
|
||||
json={
|
||||
"title": "Water the plants",
|
||||
"rule": {
|
||||
"rule_type": "daily",
|
||||
"interval": 1,
|
||||
"start_date": "2026-03-01",
|
||||
},
|
||||
"assignee_child_ids": [child_id],
|
||||
"windows": ["afternoon"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert inactive.status_code == 201
|
||||
inactive_id = inactive.json()["id"]
|
||||
deactivated = await client.patch(
|
||||
f"{LIST_PATH}/{inactive_id}/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
|
||||
return child_id, active.json()["id"], inactive_id
|
||||
|
||||
|
||||
async def test_admin_lists_active_and_inactive_definitions_with_full_shape(
|
||||
admin_client: object,
|
||||
) -> None:
|
||||
"""Both definitions come back, by rising id, in the documented shape."""
|
||||
child_id, active_id, inactive_id = await _seed_active_and_inactive(
|
||||
admin_client
|
||||
)
|
||||
|
||||
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"definitions": [
|
||||
{
|
||||
"id": active_id,
|
||||
"title": "Tidy the den",
|
||||
"description": "Put everything back where it belongs.",
|
||||
"icon": "toy-box",
|
||||
"is_active": True,
|
||||
"skip_on_away": True,
|
||||
"rule": {
|
||||
"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,
|
||||
},
|
||||
"assignees": [{"id": child_id, "display_name": "Ada"}],
|
||||
"windows": [
|
||||
{"window": "morning", "due_time": None},
|
||||
{"window": "evening", "due_time": "19:30"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": inactive_id,
|
||||
"title": "Water the plants",
|
||||
"description": None,
|
||||
"icon": None,
|
||||
"is_active": False,
|
||||
"skip_on_away": True,
|
||||
"rule": {
|
||||
"rule_type": "daily",
|
||||
"interval": 1,
|
||||
"weekday_set": None,
|
||||
"day_of_month": None,
|
||||
"nth_weekday": None,
|
||||
"nth_weekday_weekday": None,
|
||||
"month": None,
|
||||
"start_date": "2026-03-01",
|
||||
"end_date": None,
|
||||
},
|
||||
"assignees": [{"id": child_id, "display_name": "Ada"}],
|
||||
"windows": [{"window": "afternoon", "due_time": None}],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
async def test_list_items_match_the_create_response_shape(
|
||||
admin_client: object,
|
||||
) -> None:
|
||||
"""Each listed item is exactly what the create route returned."""
|
||||
child = await admin_client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Ben"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
created = await admin_client.post(
|
||||
LIST_PATH,
|
||||
json={
|
||||
"title": "Feed the cat",
|
||||
"rule": {
|
||||
"rule_type": "daily",
|
||||
"interval": 1,
|
||||
"start_date": "2026-03-01",
|
||||
},
|
||||
"assignee_child_ids": [child.json()["id"]],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"definitions": [created.json()]}
|
||||
|
||||
|
||||
async def test_skip_on_away_round_trips_true_and_false_through_list(
|
||||
admin_client: object,
|
||||
) -> None:
|
||||
"""skip_on_away posted true, false, or omitted lists back as stored."""
|
||||
child = await admin_client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Cleo"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
child_id = child.json()["id"]
|
||||
|
||||
async def create(title: str, extra: dict[str, object]) -> dict:
|
||||
response = await admin_client.post(
|
||||
LIST_PATH,
|
||||
json={
|
||||
"title": title,
|
||||
"rule": {
|
||||
"rule_type": "daily",
|
||||
"interval": 1,
|
||||
"start_date": "2026-03-01",
|
||||
},
|
||||
"assignee_child_ids": [child_id],
|
||||
"windows": ["morning"],
|
||||
**extra,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()
|
||||
|
||||
skipping = await create("Skip when away", {"skip_on_away": True})
|
||||
keeping = await create("Keep when away", {"skip_on_away": False})
|
||||
defaulted = await create("Default", {})
|
||||
assert skipping["skip_on_away"] is True
|
||||
assert keeping["skip_on_away"] is False
|
||||
assert defaulted["skip_on_away"] is True
|
||||
|
||||
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
|
||||
|
||||
assert response.status_code == 200
|
||||
listed = {item["id"]: item for item in response.json()["definitions"]}
|
||||
assert listed[skipping["id"]]["skip_on_away"] is True
|
||||
assert listed[keeping["id"]]["skip_on_away"] is False
|
||||
assert listed[defaulted["id"]]["skip_on_away"] is True
|
||||
assert response.json() == {"definitions": [skipping, keeping, defaulted]}
|
||||
|
||||
|
||||
async def test_empty_household_lists_no_definitions(
|
||||
admin_client: object,
|
||||
) -> None:
|
||||
"""With no definitions stored the list is 200 and empty."""
|
||||
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"definitions": []}
|
||||
|
||||
|
||||
async def test_list_without_credentials_is_401(admin_client: object) -> None:
|
||||
"""An absent credential is the uniform 401."""
|
||||
response = await admin_client.get(LIST_PATH)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_list_with_panel_service_token_is_403(
|
||||
admin_client: object,
|
||||
) -> None:
|
||||
"""The panel service token is refused outright, never a JWT."""
|
||||
response = await admin_client.get(
|
||||
LIST_PATH,
|
||||
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
||||
|
||||
|
||||
async def test_list_with_non_admin_jwt_is_403(admin_client: object) -> None:
|
||||
"""A valid JWT without the nestquest-admins group is 403."""
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
response = await admin_client.get(
|
||||
LIST_PATH, headers={"Authorization": f"Bearer {non_admin}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Admin-plane snapshot route tests (task ef3f4877).
|
||||
|
||||
These tests exercise ``GET /api/v1/admin/snapshot`` 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 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.*``), and both routes' single
|
||||
clock read (``_local_now``) is pinned to noon so nothing straddles
|
||||
midnight.
|
||||
|
||||
Proven per the done-condition:
|
||||
|
||||
- An admin JWT returns 200 with the documented shape: ``today_iso``,
|
||||
``cycle_day`` and, per child, the presence fields, the rollup counts
|
||||
and ``instances`` in the core ``instance_payload`` shape.
|
||||
- A MISSED instance is KEPT in the admin payload (D-009) and still
|
||||
OMITTED from the panel payload. The core builder lists only
|
||||
instances due on ``now.date()``, and ``derive_state`` calls an
|
||||
instance missed only when its due date is BEFORE that day — so a
|
||||
real snapshot never carries a missed view. The test therefore
|
||||
wraps the real builder to add the seeded past-due instance as a
|
||||
view whose state the core's own ``derive_state`` computes
|
||||
(``missed``), and reads it through both routes.
|
||||
- Auth runs through the router's ONE ``require_admin`` dependency: no
|
||||
credential is 401, the panel service token is refused outright
|
||||
(403, the credential-type detail), and a non-admin JWT is 403.
|
||||
- The handler performs exactly ONE clock read per request.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import datetime
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from api import routes_admin, routes_panel
|
||||
from api.nestquest_core import core_snapshot
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
from tests.test_api_panel import _seed_household
|
||||
|
||||
# The completion layer through the SAME core copy the app uses — the
|
||||
# missed test derives its view's state with the core's own rule.
|
||||
_completion = importlib.import_module("nestquest_core.completion")
|
||||
|
||||
#: The panel service token the harness app is configured with.
|
||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
||||
|
||||
#: 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 _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _panel_headers() -> dict[str, str]:
|
||||
"""The Authorization header carrying the harness panel token."""
|
||||
return {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def snapshot_household(
|
||||
temp_db_path: str, monkeypatch
|
||||
) -> SimpleNamespace:
|
||||
"""A seeded household served by the admin-plane app, clock pinned.
|
||||
|
||||
``today`` comes from one host clock read; the pinned instant is
|
||||
12:00 UTC that day (past Ada's 10:00 due, before Bo's 17:00). Both
|
||||
the admin and the panel route's ``_local_now`` return it, and the
|
||||
admin one counts its calls.
|
||||
"""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
pinned_now = datetime.datetime(
|
||||
today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC")
|
||||
)
|
||||
seed = await _seed_household(temp_db_path, today, pinned_now)
|
||||
|
||||
clock_reads: list[datetime.datetime] = []
|
||||
|
||||
def _admin_now() -> datetime.datetime:
|
||||
clock_reads.append(pinned_now)
|
||||
return pinned_now
|
||||
|
||||
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
|
||||
monkeypatch.setattr(routes_panel, "_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,
|
||||
today=today,
|
||||
pinned_now=pinned_now,
|
||||
pinned_utc_iso=pinned_now.astimezone(
|
||||
datetime.timezone.utc
|
||||
).isoformat(),
|
||||
clock_reads=clock_reads,
|
||||
)
|
||||
|
||||
|
||||
# --- authentication: the ONE require_admin dependency ----------------------
|
||||
|
||||
|
||||
async def test_snapshot_without_credential_is_401(
|
||||
snapshot_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""No Authorization header is the uniform 401."""
|
||||
response = await snapshot_household.client.get("/api/v1/admin/snapshot")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_snapshot_refuses_panel_service_token_outright(
|
||||
snapshot_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The panel service token is refused as a credential type (403).
|
||||
|
||||
403 with the credential-type detail — not the uniform 401 and not
|
||||
the non-admin group detail — proves the token was refused outright,
|
||||
never tried as a JWT.
|
||||
"""
|
||||
response = await snapshot_household.client.get(
|
||||
"/api/v1/admin/snapshot", headers=_panel_headers()
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
||||
|
||||
|
||||
async def test_snapshot_refuses_non_admin_jwt(
|
||||
snapshot_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A valid JWT without the nestquest-admins group is 403."""
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
response = await snapshot_household.client.get(
|
||||
"/api/v1/admin/snapshot",
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
||||
|
||||
|
||||
# --- the payload -----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_admin_jwt_returns_documented_snapshot(
|
||||
snapshot_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An admin JWT returns 200 with the documented snapshot shape.
|
||||
|
||||
Every field is asserted as an explicit literal, with ONE clock
|
||||
read for the request. With no missed view in the real snapshot,
|
||||
the admin body equals the panel body for the same household.
|
||||
"""
|
||||
client = snapshot_household.client
|
||||
seed = snapshot_household.seed
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/snapshot", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(snapshot_household.clock_reads) == 1
|
||||
body = response.json()
|
||||
|
||||
assert body == {
|
||||
"today_iso": snapshot_household.today.isoformat(),
|
||||
# Ada's 2-week cycle anchored yesterday => today is cycle day 2.
|
||||
"cycle_day": 2,
|
||||
"children": [
|
||||
{
|
||||
"child_id": seed.ada.id,
|
||||
"child_name": "Ada",
|
||||
"present": False,
|
||||
"next_present": seed.returns_date.isoformat(),
|
||||
"due_today": 1,
|
||||
"completed_today": 0,
|
||||
"remaining_today": 1,
|
||||
"completion_pct": 0,
|
||||
"instances": [
|
||||
{
|
||||
"id": seed.pack_bag_instance.id,
|
||||
"definition_id": seed.pack_bag_instance.definition_id,
|
||||
"child_id": seed.ada.id,
|
||||
"title": "Pack bag",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "10:00",
|
||||
"state": "open",
|
||||
"overdue": True,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"child_id": seed.bo.id,
|
||||
"child_name": "Bo",
|
||||
"present": True,
|
||||
"next_present": None,
|
||||
"due_today": 1,
|
||||
"completed_today": 1,
|
||||
"remaining_today": 0,
|
||||
"completion_pct": 100,
|
||||
"instances": [
|
||||
{
|
||||
"id": seed.brush_teeth_instance.id,
|
||||
"definition_id": (
|
||||
seed.brush_teeth_instance.definition_id
|
||||
),
|
||||
"child_id": seed.bo.id,
|
||||
"title": "Brush teeth",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "17:00",
|
||||
"state": "completed",
|
||||
"overdue": False,
|
||||
"completed_at": snapshot_household.pinned_utc_iso,
|
||||
"on_time": True,
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
panel = await client.get(
|
||||
"/api/v1/panel/snapshot", headers=_panel_headers()
|
||||
)
|
||||
assert panel.status_code == 200
|
||||
assert panel.json() == body
|
||||
|
||||
|
||||
async def test_admin_keeps_missed_instance_panel_omits_it(
|
||||
snapshot_household: SimpleNamespace, monkeypatch
|
||||
) -> None:
|
||||
"""A missed view is KEPT by the admin route, OMITTED by the panel's.
|
||||
|
||||
The real builder is wrapped to add the seeded past-due Stale chore
|
||||
instance to Ada's views, its state derived by the core's own
|
||||
``derive_state`` (no events, due yesterday => ``missed``). Both
|
||||
routes read the SAME wrapped snapshot, so the only difference is
|
||||
each route's ``include_missed`` flag.
|
||||
"""
|
||||
client = snapshot_household.client
|
||||
seed = snapshot_household.seed
|
||||
stale = seed.stale_instance
|
||||
missed_state = _completion.derive_state(
|
||||
stale, None, snapshot_household.today
|
||||
)
|
||||
assert missed_state == "missed"
|
||||
missed_view = core_snapshot.QuestInstanceView(
|
||||
instance_id=stale.id,
|
||||
definition_id=stale.definition_id,
|
||||
child_id=stale.child_id,
|
||||
title="Stale chore",
|
||||
icon=None,
|
||||
window=stale.window,
|
||||
due_date=stale.due_date,
|
||||
due_time=stale.due_time,
|
||||
state=missed_state,
|
||||
overdue=False,
|
||||
completed_at=None,
|
||||
was_on_time=None,
|
||||
)
|
||||
real_build_snapshot = core_snapshot.build_snapshot
|
||||
|
||||
async def _with_missed_view(database, settings, now):
|
||||
snapshot = await real_build_snapshot(database, settings, now)
|
||||
return dataclasses.replace(
|
||||
snapshot,
|
||||
children=tuple(
|
||||
dataclasses.replace(
|
||||
child, instances=(*child.instances, missed_view)
|
||||
)
|
||||
if child.child_id == seed.ada.id
|
||||
else child
|
||||
for child in snapshot.children
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(core_snapshot, "build_snapshot", _with_missed_view)
|
||||
|
||||
admin = await client.get(
|
||||
"/api/v1/admin/snapshot", headers=_admin_headers()
|
||||
)
|
||||
assert admin.status_code == 200
|
||||
admin_ada = admin.json()["children"][0]
|
||||
assert admin_ada["child_id"] == seed.ada.id
|
||||
assert [instance["id"] for instance in admin_ada["instances"]] == [
|
||||
seed.pack_bag_instance.id,
|
||||
stale.id,
|
||||
]
|
||||
assert admin_ada["instances"][1] == {
|
||||
"id": stale.id,
|
||||
"definition_id": stale.definition_id,
|
||||
"child_id": seed.ada.id,
|
||||
"title": "Stale chore",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "09:00",
|
||||
"state": "missed",
|
||||
"overdue": False,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
}
|
||||
|
||||
panel = await client.get(
|
||||
"/api/v1/panel/snapshot", headers=_panel_headers()
|
||||
)
|
||||
assert panel.status_code == 200
|
||||
panel_ada = panel.json()["children"][0]
|
||||
assert [instance["id"] for instance in panel_ada["instances"]] == [
|
||||
seed.pack_bag_instance.id
|
||||
], "the panel payload must still omit missed instances"
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Admin-plane SSE tests: the admin transition stream (task 92f75680).
|
||||
|
||||
These tests exercise ``GET /api/v1/admin/events`` — the admin plane's
|
||||
Server-Sent Events stream for the Admin PWA — against a REAL server:
|
||||
|
||||
- The stream inherits the admin router's ONE ``require_admin``
|
||||
dependency: an absent credential is 401, the panel service token and
|
||||
a non-admin JWT are 403, all before any stream is established.
|
||||
- An admin JWT subscribes; each of the four documented transitions
|
||||
(quest-completed, quest-uncompleted, quest-missed, child-day-complete)
|
||||
arrives as an ``event: <type>`` / ``data: <json>`` frame carrying its
|
||||
documented payload fields (design/ENTITIES-AND-SERVICES.md §3).
|
||||
- The admin and panel streams are fed by the SAME in-process publisher:
|
||||
one transition arrives, identically, on both.
|
||||
|
||||
Streaming approach: :mod:`tests.test_api_sse`'s live-uvicorn reader
|
||||
(httpx's ASGITransport buffers SSE bodies), on the admin-enabled
|
||||
:class:`tests.test_api_admin_uncomplete._AdminServer` whose JWKS cache
|
||||
is stubbed, so a real admin JWT verifies with NO network access.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from api import routes_admin, routes_panel
|
||||
from tests.admin_jwt_harness import make_token
|
||||
from tests.test_api_admin_uncomplete import _AdminServer
|
||||
from tests.test_api_panel import PANEL_TOKEN, _seed_household
|
||||
from tests.test_api_sse import (
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
EVENT_QUEST_COMPLETED,
|
||||
EVENT_QUEST_MISSED,
|
||||
EVENT_QUEST_UNCOMPLETED,
|
||||
READ_TIMEOUT,
|
||||
_stream,
|
||||
)
|
||||
|
||||
EVENTS_PATH = "/api/v1/admin/events"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path) -> str:
|
||||
"""A fresh per-test SQLite path under pytest's tmp_path."""
|
||||
return str(tmp_path / "nestquest.db")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_live(temp_db_path: str) -> SimpleNamespace:
|
||||
"""A seeded household served by a live admin-enabled uvicorn app.
|
||||
|
||||
Both planes' clocks are pinned to 12:00 UTC (past Ada's 10:00 due,
|
||||
before Bo's 17:00) BEFORE the server starts — the app is created
|
||||
inside uvicorn's factory thread.
|
||||
"""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
pinned_now = datetime.datetime(
|
||||
today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC")
|
||||
)
|
||||
seed = await _seed_household(temp_db_path, today, pinned_now)
|
||||
original_panel = routes_panel._local_now
|
||||
original_admin = routes_admin._local_now
|
||||
routes_panel._local_now = lambda: pinned_now
|
||||
routes_admin._local_now = lambda: pinned_now
|
||||
try:
|
||||
with _AdminServer(temp_db_path) as server:
|
||||
client = httpx.AsyncClient(
|
||||
base_url=server.url(""), timeout=READ_TIMEOUT
|
||||
)
|
||||
async with client:
|
||||
yield SimpleNamespace(client=client, seed=seed, server=server)
|
||||
finally:
|
||||
routes_panel._local_now = original_panel
|
||||
routes_admin._local_now = original_admin
|
||||
|
||||
|
||||
async def _panel_complete(live: SimpleNamespace, instance_id: int, child_id: int):
|
||||
"""Complete ``instance_id`` through the panel route (a real transition)."""
|
||||
response = await live.client.post(
|
||||
f"/api/v1/panel/instances/{instance_id}/complete",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
json={"actor_child_id": child_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# --- auth: the ONE require_admin dependency, no stream on refusal ----------
|
||||
|
||||
|
||||
async def test_admin_events_refuses_absent_panel_and_non_admin(
|
||||
admin_live: SimpleNamespace,
|
||||
) -> None:
|
||||
"""No credential is 401; the panel token and a non-admin JWT are 403.
|
||||
|
||||
Each refusal is a complete (non-streaming) JSON error response, so
|
||||
no stream is established for any of them.
|
||||
"""
|
||||
client = admin_live.client
|
||||
|
||||
absent = await client.get(EVENTS_PATH)
|
||||
assert absent.status_code == 401
|
||||
assert not absent.headers["content-type"].startswith("text/event-stream")
|
||||
|
||||
panel = await client.get(
|
||||
EVENTS_PATH, headers={"Authorization": f"Bearer {PANEL_TOKEN}"}
|
||||
)
|
||||
assert panel.status_code == 403
|
||||
assert panel.json()["detail"] == (
|
||||
"This credential type is not accepted for admin access"
|
||||
)
|
||||
|
||||
non_admin = await client.get(
|
||||
EVENTS_PATH,
|
||||
headers={
|
||||
"Authorization": f"Bearer {make_token(groups=('other-group',))}"
|
||||
},
|
||||
)
|
||||
assert non_admin.status_code == 403
|
||||
assert non_admin.json()["detail"] == (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
|
||||
|
||||
async def test_admin_jwt_is_not_accepted_on_panel_events(
|
||||
admin_live: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The panel plane is unchanged: an admin JWT does not open it."""
|
||||
response = await admin_live.client.get(
|
||||
"/api/v1/panel/events",
|
||||
headers={"Authorization": f"Bearer {make_token()}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_admin_events_sets_no_cache(admin_live: SimpleNamespace) -> None:
|
||||
"""The admin stream is ``text/event-stream`` with ``no-cache``."""
|
||||
async with admin_live.client.stream(
|
||||
"GET",
|
||||
EVENTS_PATH,
|
||||
headers={"Authorization": f"Bearer {make_token()}"},
|
||||
) as response:
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("text/event-stream")
|
||||
assert response.headers["cache-control"] == "no-cache"
|
||||
|
||||
|
||||
# --- the four documented transitions on the admin stream -------------------
|
||||
|
||||
|
||||
async def test_admin_stream_carries_completed_and_day_complete(
|
||||
admin_live: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A panel completion streams quest-completed then child-day-complete.
|
||||
|
||||
Ada's only quest today is Pack bag, so completing it also clears her
|
||||
day; both frames arrive on the ADMIN stream with their documented
|
||||
payload fields.
|
||||
"""
|
||||
seed = admin_live.seed
|
||||
pack_bag = seed.pack_bag_instance
|
||||
stream = _stream(admin_live.server.url(EVENTS_PATH), make_token())
|
||||
async with stream:
|
||||
await _panel_complete(admin_live, pack_bag.id, seed.ada.id)
|
||||
|
||||
event_type, payload = await stream.next_event()
|
||||
assert event_type == EVENT_QUEST_COMPLETED == "nestquest_quest_completed"
|
||||
assert payload["child_id"] == seed.ada.id
|
||||
assert payload["child_name"] == "Ada"
|
||||
assert payload["instance_id"] == pack_bag.id
|
||||
assert payload["quest_title"] == "Pack bag"
|
||||
assert payload["window"] == "morning"
|
||||
assert payload["due_date"] == pack_bag.due_date
|
||||
assert payload["due_time"] == "10:00"
|
||||
assert payload["occurred_at"]
|
||||
assert payload["was_on_time"] is False # 10:00 due, 12:00 now
|
||||
completed_at = payload["occurred_at"]
|
||||
|
||||
event_type, payload = await stream.next_event()
|
||||
assert event_type == EVENT_CHILD_DAY_COMPLETE
|
||||
assert event_type == "nestquest_child_day_complete"
|
||||
assert payload["child_id"] == seed.ada.id
|
||||
assert payload["child_name"] == "Ada"
|
||||
assert payload["quests_due"] == 1
|
||||
assert payload["quests_completed"] == 1
|
||||
assert payload["occurred_at"] == completed_at
|
||||
|
||||
|
||||
async def test_admin_stream_carries_uncompleted(
|
||||
admin_live: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The admin uncomplete route's reversal streams quest-uncompleted."""
|
||||
seed = admin_live.seed
|
||||
brush = seed.brush_teeth_instance
|
||||
admin_headers = {"Authorization": f"Bearer {make_token()}"}
|
||||
stream = _stream(admin_live.server.url(EVENTS_PATH), make_token())
|
||||
async with stream:
|
||||
response = await admin_live.client.post(
|
||||
f"/api/v1/admin/instances/{brush.id}/uncomplete",
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.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"]
|
||||
assert "was_on_time" not in payload
|
||||
|
||||
|
||||
async def test_admin_stream_carries_missed(admin_live: SimpleNamespace) -> None:
|
||||
"""The missed-sweep publisher's transition streams to the admin client.
|
||||
|
||||
Drives the API-side helper the sweep calls, on the SERVER's loop,
|
||||
against the app's own database and its ONE publisher.
|
||||
"""
|
||||
from api import transitions
|
||||
|
||||
seed = admin_live.seed
|
||||
stale = seed.stale_instance
|
||||
app = admin_live.server.app
|
||||
stream = _stream(admin_live.server.url(EVENTS_PATH), make_token())
|
||||
async with stream:
|
||||
admin_live.server.run_on_loop(
|
||||
transitions.publish_quest_missed(
|
||||
app.state.db.database, stale.id, app.state.publisher
|
||||
)
|
||||
)
|
||||
event_type, payload = await stream.next_event()
|
||||
assert event_type == EVENT_QUEST_MISSED == "nestquest_quest_missed"
|
||||
assert payload["child_id"] == seed.ada.id
|
||||
assert payload["child_name"] == "Ada"
|
||||
assert payload["instance_id"] == stale.id
|
||||
assert payload["quest_title"] == "Stale chore"
|
||||
assert payload["window"] == "morning"
|
||||
assert payload["due_date"] == stale.due_date
|
||||
assert payload["due_time"] == "09:00"
|
||||
assert payload["occurred_at"]
|
||||
|
||||
|
||||
# --- one publisher feeds both planes ---------------------------------------
|
||||
|
||||
|
||||
async def test_admin_and_panel_streams_share_one_publisher(
|
||||
admin_live: SimpleNamespace,
|
||||
) -> None:
|
||||
"""One transition arrives, identically, on the admin AND panel streams.
|
||||
|
||||
The route publishes once to ``app.state.publisher``; both planes'
|
||||
subscribers receive the same frames (same ``occurred_at``), which a
|
||||
second, admin-only publisher could never produce.
|
||||
"""
|
||||
seed = admin_live.seed
|
||||
pack_bag = seed.pack_bag_instance
|
||||
admin_stream = _stream(admin_live.server.url(EVENTS_PATH), make_token())
|
||||
panel_stream = _stream(admin_live.server.url("/api/v1/panel/events"))
|
||||
async with admin_stream, panel_stream:
|
||||
await _panel_complete(admin_live, pack_bag.id, seed.ada.id)
|
||||
|
||||
admin_events = [
|
||||
await admin_stream.next_event(),
|
||||
await admin_stream.next_event(),
|
||||
]
|
||||
panel_events = [
|
||||
await panel_stream.next_event(),
|
||||
await panel_stream.next_event(),
|
||||
]
|
||||
assert [event_type for event_type, _ in admin_events] == [
|
||||
EVENT_QUEST_COMPLETED,
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
]
|
||||
assert admin_events == panel_events
|
||||
+10
-5
@@ -158,7 +158,8 @@ class _EventStream:
|
||||
parsed frame with a timeout.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, token: str = PANEL_TOKEN) -> None:
|
||||
self.token = token
|
||||
self.queue: asyncio.Queue = asyncio.Queue()
|
||||
self._task: asyncio.Task | None = None
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
@@ -170,7 +171,7 @@ class _EventStream:
|
||||
self._stream_cm = self._client.stream(
|
||||
"GET",
|
||||
self.url,
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
headers={"Authorization": f"Bearer {self.token}"},
|
||||
)
|
||||
self._response = await self._stream_cm.__aenter__()
|
||||
assert self._response.status_code == 200
|
||||
@@ -263,9 +264,13 @@ async def seeded_panel(temp_db_path: str) -> SimpleNamespace:
|
||||
routes_panel._local_now = original
|
||||
|
||||
|
||||
def _stream(url: str) -> _EventStream:
|
||||
"""A subscribed SSE reader for ``url``."""
|
||||
return _EventStream()._set_url(url)
|
||||
def _stream(url: str, token: str = PANEL_TOKEN) -> _EventStream:
|
||||
"""A subscribed SSE reader for ``url``, sending ``token`` as Bearer.
|
||||
|
||||
Defaults to the panel service token; the admin-plane stream tests
|
||||
pass an admin JWT instead.
|
||||
"""
|
||||
return _EventStream(token)._set_url(url)
|
||||
|
||||
|
||||
# --- auth and content type ----------------------------------------------
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Cross-process safety of the missed-sweep watermark claim.
|
||||
|
||||
Feature 11's sweep guards its announcement with the watermark; the
|
||||
original guard was a read-then-set inside a per-PROCESS asyncio lock,
|
||||
so two PROCESSES sharing one SQLite file (the HA integration's
|
||||
rollover sweep and the API service's scheduler) could both read the
|
||||
old watermark and both announce the same missed batch. These tests
|
||||
pin the fix:
|
||||
|
||||
- :meth:`MetaStateDao.claim` is the atomic, database-level primitive:
|
||||
one conditional upsert whose ``rowcount`` says whether THIS caller
|
||||
changed the row. The first claimant of a value wins; a second
|
||||
claimant of the same value — even on an INDEPENDENT connection to
|
||||
the same file, the shape two processes produce — loses.
|
||||
- :func:`run_missed_sweep` gates the announcement on that claim: two
|
||||
independent ``NestQuestDatabase`` wrappers (two processes' shape)
|
||||
racing one seeded database yields exactly one announcer.
|
||||
- The documented sweep semantics are unchanged: a same-night rerun
|
||||
announces nothing, and a run after days of downtime announces the
|
||||
accumulated ``[watermark, today)`` window exactly once.
|
||||
|
||||
Everything here runs against the bundled HA-free core copy
|
||||
(``custom_components.nestquest.core``) with a plain
|
||||
``asyncio.to_thread`` executor — no Home Assistant, no API server.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_meta import MetaStateDao
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.children import create_child
|
||||
from custom_components.nestquest.core.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.core.materialize import materialize
|
||||
from custom_components.nestquest.core.recurrence import ScheduleRule
|
||||
from custom_components.nestquest.core.sweep import (
|
||||
SWEEP_WATERMARK_KEY,
|
||||
run_missed_sweep,
|
||||
)
|
||||
|
||||
#: One missed event per unswept past-due instance — the sweep's only
|
||||
#: event type.
|
||||
EVENT_QUEST_MISSED = "nestquest_quest_missed"
|
||||
|
||||
|
||||
async def _executor(fn, *args, **kwargs):
|
||||
"""Run a synchronous callable on a worker thread and await it.
|
||||
|
||||
The :class:`NestQuestDatabase` executor contract (the shape of
|
||||
``hass.async_add_executor_job``), so sqlite3 calls never block the
|
||||
event loop.
|
||||
"""
|
||||
return await asyncio.to_thread(fn, *args, **kwargs)
|
||||
|
||||
|
||||
async def _open_database(db_path: str) -> NestQuestDatabase:
|
||||
"""Open (without migrating) one independent connection wrapper."""
|
||||
database = NestQuestDatabase(_executor)
|
||||
await database.open(db_path)
|
||||
return database
|
||||
|
||||
|
||||
async def _seed_missed_household(
|
||||
db_path: str, today: datetime.date
|
||||
) -> SimpleNamespace:
|
||||
"""Seed a household with two past-due open instances on ``db_path``.
|
||||
|
||||
One child, two quests whose ONLY instances are due YESTERDAY
|
||||
(materialized with ``today`` pinned to yesterday, ``end_date``
|
||||
closing the rules so the later today-walk cannot duplicate them).
|
||||
Seeding uses its OWN connection, opened and closed here, so the
|
||||
connections the tests race with start fresh.
|
||||
"""
|
||||
database = await _open_database(db_path)
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
today_iso = today.isoformat()
|
||||
yesterday = today - datetime.timedelta(days=1)
|
||||
yesterday_iso = yesterday.isoformat()
|
||||
rule = ScheduleRule.from_dict(
|
||||
{
|
||||
"rule_type": "daily",
|
||||
"start_date": yesterday_iso,
|
||||
"end_date": yesterday_iso,
|
||||
}
|
||||
)
|
||||
ada = await create_child(database, "Ada", sort_order=0)
|
||||
await create_quest_definition(
|
||||
database, "Stale chore", rule, [ada.id], [("morning", "09:00")]
|
||||
)
|
||||
await create_quest_definition(
|
||||
database, "Old chore", rule, [ada.id], [("evening", "18:30")]
|
||||
)
|
||||
await materialize(database, yesterday_iso, yesterday_iso, today=yesterday)
|
||||
# A quest due TODAY (created after the yesterday walk so it has
|
||||
# no past-due instance): the post-downtime catch-up run's
|
||||
# target — inside [watermark=today, later_today) but not
|
||||
# inside the day-one sweep's window.
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Recoverable chore",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("afternoon", "17:00")],
|
||||
)
|
||||
await materialize(database, today_iso, today_iso, today=today)
|
||||
instances = QuestInstancesDao(database)
|
||||
rows = await instances.list_by_child_and_date(ada.id, yesterday_iso)
|
||||
assert len(rows) == 2
|
||||
today_rows = await instances.list_by_child_and_date(
|
||||
ada.id, today_iso
|
||||
)
|
||||
assert len(today_rows) == 1
|
||||
return SimpleNamespace(
|
||||
child=ada,
|
||||
instances=rows,
|
||||
recoverable_instance=today_rows[0],
|
||||
yesterday_iso=yesterday_iso,
|
||||
today_iso=today_iso,
|
||||
)
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
# --- the claim primitive: atomic at the database level ------------------
|
||||
|
||||
|
||||
async def test_claim_first_caller_wins_and_second_same_value_loses(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""claim() is a conditional upsert: first True, same-value second False.
|
||||
|
||||
A different value always wins (an advance re-arms the check), and
|
||||
the stored value follows the winner.
|
||||
"""
|
||||
database = await _open_database(temp_db_path)
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
dao = MetaStateDao(database)
|
||||
assert await dao.get(SWEEP_WATERMARK_KEY) is None
|
||||
|
||||
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-01") is True
|
||||
# Same value again: the row is already there — nothing changed.
|
||||
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-01") is False
|
||||
assert await dao.get(SWEEP_WATERMARK_KEY) == "2026-09-01"
|
||||
# A DIFFERENT value is an advance, not a re-claim: it wins.
|
||||
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-02") is True
|
||||
assert await dao.get(SWEEP_WATERMARK_KEY) == "2026-09-02"
|
||||
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-02") is False
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
async def test_claim_is_atomic_across_independent_connections(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""Two connections to one file cannot both claim the same value.
|
||||
|
||||
This is the two-process shape: independent sqlite3 connections (one
|
||||
per ``NestQuestDatabase`` wrapper), racing the SAME key on the SAME
|
||||
event loop with no shared lock. The single conditional upsert is
|
||||
serialized by SQLite itself, so exactly one caller's rowcount is 1.
|
||||
"""
|
||||
first = await _open_database(temp_db_path)
|
||||
second = await _open_database(temp_db_path)
|
||||
try:
|
||||
await apply_migrations(first)
|
||||
won = await asyncio.gather(
|
||||
MetaStateDao(first).claim(SWEEP_WATERMARK_KEY, "2026-09-01"),
|
||||
MetaStateDao(second).claim(SWEEP_WATERMARK_KEY, "2026-09-01"),
|
||||
)
|
||||
assert sorted(won) == [False, True]
|
||||
# One stored row, holding the winner's value.
|
||||
assert await MetaStateDao(first).get(SWEEP_WATERMARK_KEY) == (
|
||||
"2026-09-01"
|
||||
)
|
||||
assert await MetaStateDao(second).get(SWEEP_WATERMARK_KEY) == (
|
||||
"2026-09-01"
|
||||
)
|
||||
finally:
|
||||
await first.close()
|
||||
await second.close()
|
||||
|
||||
|
||||
# --- the sweep: the announcement is gated by the atomic claim -----------
|
||||
|
||||
|
||||
async def test_two_process_shape_race_exactly_one_announces(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""Two independent connections racing the sweep: ONE announcer.
|
||||
|
||||
Each wrapper carries its own connection AND its own per-process
|
||||
sweep lock, so nothing in-process serialises the two runs — the
|
||||
only guard left is the database-level claim. Both read the unset
|
||||
watermark and both build the events, but exactly one claim wins:
|
||||
one run returns the missed events, the loser returns [].
|
||||
"""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
seed = await _seed_missed_household(temp_db_path, today)
|
||||
first = await _open_database(temp_db_path)
|
||||
second = await _open_database(temp_db_path)
|
||||
try:
|
||||
results = await asyncio.gather(
|
||||
run_missed_sweep(first, today=today),
|
||||
run_missed_sweep(second, today=today),
|
||||
)
|
||||
announcers = [r for r in results if r]
|
||||
assert len(announcers) == 1
|
||||
event_type, payload = announcers[0][0]
|
||||
assert event_type == EVENT_QUEST_MISSED
|
||||
assert payload["due_date"] == seed.yesterday_iso
|
||||
# Both instances of the seeded window, one event each.
|
||||
assert len(announcers[0]) == 2
|
||||
losers = [r for r in results if not r]
|
||||
assert losers == [[]]
|
||||
# The watermark holds the run's date for whoever looks next.
|
||||
assert (
|
||||
await MetaStateDao(first).get(SWEEP_WATERMARK_KEY)
|
||||
== seed.today_iso
|
||||
)
|
||||
finally:
|
||||
await first.close()
|
||||
await second.close()
|
||||
|
||||
|
||||
async def test_same_night_rerun_announces_nothing(temp_db_path: str) -> None:
|
||||
"""A second sweep for the same date is an empty no-op (the watermark)."""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
await _seed_missed_household(temp_db_path, today)
|
||||
database = await _open_database(temp_db_path)
|
||||
try:
|
||||
first = await run_missed_sweep(database, today=today)
|
||||
assert len(first) == 2
|
||||
second = await run_missed_sweep(database, today=today)
|
||||
assert second == []
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
async def test_post_downtime_run_announces_accumulated_window_once(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""After days of downtime the catch-up run sweeps the window once.
|
||||
|
||||
The first run sweeps yesterday's instances (watermark := today);
|
||||
``today`` then jumps two days and the catch-up run — on a
|
||||
DIFFERENT connection, the two-process shape — announces the
|
||||
accumulated ``[watermark, later_today)`` window exactly once, and
|
||||
a rerun on the original connection is silent again.
|
||||
"""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
later_today = today + datetime.timedelta(days=2)
|
||||
seed = await _seed_missed_household(temp_db_path, today)
|
||||
first = await _open_database(temp_db_path)
|
||||
second = await _open_database(temp_db_path)
|
||||
try:
|
||||
# Day one: yesterday's two instances fire once.
|
||||
day_one = await run_missed_sweep(first, today=today)
|
||||
assert len(day_one) == 2
|
||||
assert all(
|
||||
payload["due_date"] == seed.yesterday_iso
|
||||
for _, payload in day_one
|
||||
)
|
||||
|
||||
# The downtime catch-up: the window is [watermark, later_today).
|
||||
catchup = await run_missed_sweep(second, today=later_today)
|
||||
assert len(catchup) == 1
|
||||
event_type, payload = catchup[0]
|
||||
assert event_type == EVENT_QUEST_MISSED
|
||||
assert payload["due_date"] == seed.today_iso
|
||||
assert payload["instance_id"] == seed.recoverable_instance.id
|
||||
assert payload["child_id"] == seed.child.id
|
||||
|
||||
# A further rerun announces nothing.
|
||||
assert await run_missed_sweep(first, today=later_today) == []
|
||||
assert (
|
||||
await MetaStateDao(first).get(SWEEP_WATERMARK_KEY)
|
||||
== later_today.isoformat()
|
||||
)
|
||||
finally:
|
||||
await first.close()
|
||||
await second.close()
|
||||
@@ -847,6 +847,7 @@ def test_definition_update_cannot_change_assignee_or_active(
|
||||
"description",
|
||||
"icon",
|
||||
"due_time",
|
||||
"skip_on_away",
|
||||
}
|
||||
assert await definitions.update(
|
||||
definition.id, title="A2"
|
||||
@@ -862,6 +863,72 @@ def test_definition_update_cannot_change_assignee_or_active(
|
||||
_with_db(tmp_path, "definition-update-shape.db")(_body)
|
||||
|
||||
|
||||
def test_definition_update_toggles_skip_on_away_and_omission_keeps_it(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
async def _body(database, rules, definitions, children, child):
|
||||
rule = await rules.create("daily", "2026-09-14")
|
||||
definition = await definitions.create("A", rule.id, NOW)
|
||||
assert definition.skip_on_away is True
|
||||
assert await definitions.update(
|
||||
definition.id, skip_on_away=False
|
||||
) == 1
|
||||
assert (await definitions.get(definition.id)).skip_on_away is False
|
||||
# Omitting the flag (None) leaves the stored False untouched.
|
||||
assert await definitions.update(definition.id, title="A2") == 1
|
||||
kept = await definitions.get(definition.id)
|
||||
assert kept.title == "A2"
|
||||
assert kept.skip_on_away is False
|
||||
assert await definitions.update(definition.id) == 0
|
||||
assert await definitions.update(
|
||||
definition.id, skip_on_away=True
|
||||
) == 1
|
||||
assert (await definitions.get(definition.id)).skip_on_away is True
|
||||
|
||||
_with_db(tmp_path, "definition-update-skip-on-away.db")(_body)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [2, -1, 0.0, 1.5, "1", "true", b"\x01"])
|
||||
def test_skip_on_away_rejected_unless_zero_or_one_at_every_write(
|
||||
tmp_path, bad
|
||||
) -> None:
|
||||
"""Every DAO entry point taking the flag rejects non-0/1 values.
|
||||
|
||||
This is the only guard on migrated databases, whose column was
|
||||
added without the fresh-schema CHECK constraint.
|
||||
"""
|
||||
async def _body(database, rules, definitions, children, child):
|
||||
rule = await rules.create("daily", "2026-09-14")
|
||||
definition = await definitions.create("A", rule.id, NOW)
|
||||
with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"):
|
||||
await definitions.create("B", rule.id, NOW, skip_on_away=bad)
|
||||
with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"):
|
||||
await definitions.create_with_rule_and_windows(
|
||||
"C",
|
||||
schedule_rule_to_storage(
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.DAILY, start_date="2026-09-14"
|
||||
)
|
||||
),
|
||||
NOW,
|
||||
[child.id],
|
||||
[("morning", None)],
|
||||
skip_on_away=bad,
|
||||
)
|
||||
with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"):
|
||||
await definitions.edit_definition(
|
||||
definition.id, title="A2", skip_on_away=bad
|
||||
)
|
||||
with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"):
|
||||
await definitions.update(
|
||||
definition.id, title="A2", skip_on_away=bad
|
||||
)
|
||||
stored = await definitions.list_all()
|
||||
assert [(d.title, d.skip_on_away) for d in stored] == [("A", True)]
|
||||
|
||||
_with_db(tmp_path, "definition-skip-on-away-invalid.db")(_body)
|
||||
|
||||
|
||||
def test_definition_update_no_fields_returns_zero(tmp_path) -> None:
|
||||
async def _body(database, rules, definitions, children, child):
|
||||
rule = await rules.create("daily", "2026-09-14")
|
||||
|
||||
@@ -657,6 +657,133 @@ def test_materialize_honours_overrides(tmp_path) -> None:
|
||||
_with_db(tmp_path, "materialize-override.db")(_body)
|
||||
|
||||
|
||||
async def _absent_midweek_setup(database, skip_on_away: bool):
|
||||
"""One child absent on days 2..4 of a future week, one daily quest.
|
||||
|
||||
Returns ``(child_id, definition_id, start, end_iso, absent_dates)``.
|
||||
"""
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
|
||||
child = await ChildrenDao(database).create("Ada", NOW)
|
||||
start = _future_monday()
|
||||
end_iso = (start + datetime.timedelta(days=6)).isoformat()
|
||||
absent = {
|
||||
(start + datetime.timedelta(days=offset)).isoformat()
|
||||
for offset in (2, 3, 4)
|
||||
}
|
||||
await PresenceOverridesDao(database).create(
|
||||
child.id, min(absent), max(absent), False, note="away"
|
||||
)
|
||||
created = await create_quest_definition(
|
||||
database,
|
||||
"Daily chore",
|
||||
ScheduleRule(rule_type=RuleType.DAILY, start_date=start.isoformat()),
|
||||
[child.id],
|
||||
["morning"],
|
||||
skip_on_away=skip_on_away,
|
||||
)
|
||||
assert created.definition.skip_on_away is skip_on_away
|
||||
return child.id, created.definition.id, start, end_iso, absent
|
||||
|
||||
|
||||
def test_materialize_skip_on_away_true_skips_absent_dates(tmp_path) -> None:
|
||||
async def _body(database):
|
||||
child_id, _, start, end_iso, absent = await _absent_midweek_setup(
|
||||
database, skip_on_away=True
|
||||
)
|
||||
count = await materialize(database, start.isoformat(), end_iso)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, start.isoformat(), end_iso
|
||||
)
|
||||
due_dates = {record.due_date for record in records}
|
||||
assert count == 4
|
||||
assert due_dates.isdisjoint(absent)
|
||||
assert len(due_dates) == 4
|
||||
|
||||
_with_db(tmp_path, "skip-on-away-true.db")(_body)
|
||||
|
||||
|
||||
def test_materialize_skip_on_away_false_generates_absent_dates(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
async def _body(database):
|
||||
child_id, _, start, end_iso, absent = await _absent_midweek_setup(
|
||||
database, skip_on_away=False
|
||||
)
|
||||
count = await materialize(database, start.isoformat(), end_iso)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, start.isoformat(), end_iso
|
||||
)
|
||||
due_dates = {record.due_date for record in records}
|
||||
assert count == 7
|
||||
assert absent <= due_dates
|
||||
assert len(due_dates) == 7
|
||||
|
||||
_with_db(tmp_path, "skip-on-away-false.db")(_body)
|
||||
|
||||
|
||||
def test_edit_skip_on_away_regenerates_absent_dates(tmp_path) -> None:
|
||||
"""Toggling the flag through the edit path rebuilds future instances."""
|
||||
async def _body(database):
|
||||
child_id, definition_id, start, end_iso, absent = (
|
||||
await _absent_midweek_setup(database, skip_on_away=True)
|
||||
)
|
||||
await materialize(database, start.isoformat(), end_iso)
|
||||
dao = QuestInstancesDao(database)
|
||||
|
||||
async def due_dates() -> set[str]:
|
||||
records = await dao.list_by_date_range(
|
||||
child_id, start.isoformat(), end_iso
|
||||
)
|
||||
return {record.due_date for record in records}
|
||||
|
||||
assert (await due_dates()).isdisjoint(absent)
|
||||
|
||||
edited = await edit_quest_definition(
|
||||
database, definition_id, skip_on_away=False
|
||||
)
|
||||
assert edited.definition.skip_on_away is False
|
||||
assert absent <= await due_dates()
|
||||
|
||||
# None leaves the stored flag unchanged.
|
||||
unchanged = await edit_quest_definition(
|
||||
database, definition_id, title="Daily chore (renamed)"
|
||||
)
|
||||
assert unchanged.definition.skip_on_away is False
|
||||
|
||||
edited = await edit_quest_definition(
|
||||
database, definition_id, skip_on_away=True
|
||||
)
|
||||
assert edited.definition.skip_on_away is True
|
||||
assert (await due_dates()).isdisjoint(absent)
|
||||
|
||||
_with_db(tmp_path, "skip-on-away-edit.db")(_body)
|
||||
|
||||
|
||||
def test_skip_on_away_rejects_non_bool(tmp_path) -> None:
|
||||
async def _body(database):
|
||||
child = await ChildrenDao(database).create("Ada", NOW)
|
||||
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-01-01")
|
||||
with pytest.raises(ValueError, match="skip_on_away must be a real bool"):
|
||||
await create_quest_definition(
|
||||
database, "Chore", rule, [child.id], ["morning"],
|
||||
skip_on_away=1,
|
||||
)
|
||||
created = await create_quest_definition(
|
||||
database, "Chore", rule, [child.id], ["morning"]
|
||||
)
|
||||
with pytest.raises(ValueError, match="skip_on_away must be a real bool"):
|
||||
await edit_quest_definition(
|
||||
database, created.definition.id, skip_on_away="false"
|
||||
)
|
||||
stored = await QuestDefinitionsDao(database).get(created.definition.id)
|
||||
assert stored.skip_on_away is True
|
||||
|
||||
_with_db(tmp_path, "skip-on-away-validation.db")(_body)
|
||||
|
||||
|
||||
def test_materialize_rejects_malformed_or_inverted_bounds(tmp_path) -> None:
|
||||
async def _body(database):
|
||||
start = (_future_monday()).isoformat()
|
||||
|
||||
@@ -153,7 +153,9 @@ def test_migration_list_shape() -> None:
|
||||
migration 3 brings pre-D-008 definitions to the multi-assignee
|
||||
model, migration 4 adds the windows table, migration 5 rebuilds
|
||||
quest_instances onto the widened (definition, child, date, window)
|
||||
key, and migration 6 adds actor_child_id to completion_events.
|
||||
key, migration 6 adds actor_child_id to completion_events,
|
||||
migration 7 adds the meta_state table and migration 8 adds
|
||||
skip_on_away to quest_definitions.
|
||||
"""
|
||||
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS
|
||||
assert callable(MIGRATIONS[1])
|
||||
@@ -162,7 +164,8 @@ def test_migration_list_shape() -> None:
|
||||
assert callable(MIGRATIONS[4])
|
||||
assert callable(MIGRATIONS[5])
|
||||
assert MIGRATIONS[6] == SCHEMA_V7_META_STATE_DDL
|
||||
assert len(MIGRATIONS) == 7
|
||||
assert callable(MIGRATIONS[7])
|
||||
assert len(MIGRATIONS) == 8
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -564,6 +567,13 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
|
||||
database.fetch_all("PRAGMA table_info(quest_definitions)")
|
||||
)
|
||||
assert "child_id" not in {c[1] for c in columns}
|
||||
# The rebuilt row reads the pre-flag behaviour: skip on away.
|
||||
skip = _run(
|
||||
database.fetch_one(
|
||||
"SELECT skip_on_away FROM quest_definitions WHERE id = 1"
|
||||
)
|
||||
)
|
||||
assert skip == (1,)
|
||||
# The single assignee moved into the assignees table.
|
||||
assignees = _run(
|
||||
database.fetch_all(
|
||||
@@ -637,6 +647,87 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration 8: skip_on_away on quest_definitions (Feature 22)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_v7_database(database) -> None:
|
||||
"""Build a genuine version-7 database holding one definition.
|
||||
|
||||
Migrations 1..7 run first; the fresh v1 DDL already carries
|
||||
``skip_on_away`` (canonical shape), so the column is dropped to
|
||||
reproduce the exact pre-migration-8 table a shipped v7 file holds,
|
||||
and a definition row is written through that old column list.
|
||||
"""
|
||||
assert _migrate(database, MIGRATIONS[:7]) == 7
|
||||
_run(
|
||||
database.execute(
|
||||
"ALTER TABLE quest_definitions DROP COLUMN skip_on_away"
|
||||
)
|
||||
)
|
||||
columns = _run(database.fetch_all("PRAGMA table_info(quest_definitions)"))
|
||||
assert "skip_on_away" not in {c[1] for c in columns}
|
||||
_run(
|
||||
database.execute(
|
||||
"INSERT INTO schedule_rules (id, rule_type, interval, "
|
||||
"start_date) VALUES (1, 'daily', 1, '2026-09-01')"
|
||||
)
|
||||
)
|
||||
_run(
|
||||
database.execute(
|
||||
"INSERT INTO quest_definitions (id, title, schedule_rule_id, "
|
||||
"is_active, created_at) VALUES (1, 'Dishes', 1, 1, "
|
||||
"'2026-09-01T00:00:00+00:00')"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_v7_definitions_gain_skip_on_away_defaulting_true(tmp_path) -> None:
|
||||
from custom_components.nestquest.core.dao_rules import (
|
||||
QuestDefinitionsDao,
|
||||
)
|
||||
|
||||
database = _open_db(tmp_path / "v7-upgrade.db")
|
||||
try:
|
||||
_seed_v7_database(database)
|
||||
before = _version(database)
|
||||
assert before == 7
|
||||
|
||||
final = _migrate(database)
|
||||
|
||||
assert final == before + 1
|
||||
assert _version(database) == before + 1
|
||||
columns = {
|
||||
c[1]: c
|
||||
for c in _run(
|
||||
database.fetch_all("PRAGMA table_info(quest_definitions)")
|
||||
)
|
||||
}
|
||||
assert "skip_on_away" in columns
|
||||
# (cid, name, type, notnull, dflt_value, pk)
|
||||
assert columns["skip_on_away"][2:5] == ("INTEGER", 1, "1")
|
||||
record = _run(QuestDefinitionsDao(database).get(1))
|
||||
assert record is not None
|
||||
assert record.title == "Dishes"
|
||||
assert record.skip_on_away is True
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_skip_on_away_migration_is_noop_on_fresh_schema(tmp_path) -> None:
|
||||
"""A fresh file already has the column; step 8 only stamps."""
|
||||
database = _open_db(tmp_path / "fresh-v8.db")
|
||||
try:
|
||||
assert _migrate(database) == len(MIGRATIONS)
|
||||
columns = _run(
|
||||
database.fetch_all("PRAGMA table_info(quest_definitions)")
|
||||
)
|
||||
assert [c[1] for c in columns].count("skip_on_away") == 1
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Idempotence: second start is a no-op
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -322,6 +322,7 @@ def test_quest_definitions_columns_types_and_constraints(tmp_path) -> None:
|
||||
("due_time", "TEXT", 0, None, 0),
|
||||
("is_active", "INTEGER", 1, "1", 0),
|
||||
("created_at", "TEXT", 1, None, 0),
|
||||
("skip_on_away", "INTEGER", 1, "1", 0),
|
||||
]
|
||||
finally:
|
||||
_run(database.close())
|
||||
@@ -633,6 +634,30 @@ def test_schedule_rules_rule_type_check_enforced(tmp_path) -> None:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_flag", [2, -1])
|
||||
def test_quest_definitions_skip_on_away_check_enforced(
|
||||
tmp_path, bad_flag
|
||||
) -> None:
|
||||
database = _open_db(tmp_path / f"skip-on-away-{bad_flag}.db")
|
||||
try:
|
||||
_apply(database)
|
||||
_child(database)
|
||||
_insert_rule(database)
|
||||
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
|
||||
_insert_definition(database, skip_on_away=bad_flag)
|
||||
definition_id = _insert_definition(database, skip_on_away=0)
|
||||
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
|
||||
_run(
|
||||
database.execute(
|
||||
"UPDATE quest_definitions SET skip_on_away = ? "
|
||||
"WHERE id = ?",
|
||||
(bad_flag, definition_id),
|
||||
)
|
||||
)
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_schedule_rules_interval_zero_fails(tmp_path) -> None:
|
||||
database = _open_db(tmp_path / "interval-zero.db")
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user