Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 82f8f668bf Log every unexpected recipe grounding failure by fixed reason
CI / ci (push) Successful in 2m41s
CI / ci (pull_request) Successful in 2m23s
Wrap an initial dinner read failure on the candidate and link path, and
any manual-recipe read, store or write failure, in ErrGroundFailed so the
handler logs only reason=ground_failed and the dinner id, never database
text. Not-found, archived and conflict answers stay distinct.

Strengthen TestFixInFlightKeepsMemberEdits: start from populated method,
minutes and difficulty, edit each in flight in turn, and require the edit
kept and the untouched populated fields replaced by the recipe's values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:21:12 -04:00
dev_agentandClaude Opus 5.5 1dd53767f7 Guard recipe repairs against same-normalized renames and member edits
CI / ci (push) Successful in 2m41s
CI / ci (pull_request) Successful in 2m31s
F1-1: GroundDinner also compares the observed raw name, so renaming
"Grandma's stew" to "Easy Grandma's stew" (same normalized_name) while a
link is grounding is ErrGroundConflict, not attached to the renamed
dinner.

F1-2: A method, minutes_total or difficulty no longer as observed before
grounding is kept over the recipe's value, as GroundPendingDinner keeps
values a member set while a dinner was pending.

F2-1: Failing to record a grounded candidate or link on the dinner (or to
read it back after a rejected write) wraps ErrGroundFailed, so the API
logs reason=ground_failed with the dinner id, never the database error.

Maestro task 8741cedb-1849-4e3f-8d05-f5648c77c7ee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:11:32 -04:00
dev_agentandClaude Opus 5.5 5c4c507ee6 Make recipe repairs conditional and keep pasted links out of logs
CI / ci (push) Successful in 2m34s
CI / ci (pull_request) Successful in 2m30s
F1: A candidate or link repair now writes the dinner only if it is still
unarchived and as observed before grounding (normalized_name,
grounding_status, grounding_revision and recipe_id). A dinner renamed or
grounded some other way meanwhile is ErrGroundConflict (409
ground_conflict); one archived, before or during the repair, is
ErrArchived (409 dinner_archived). Manual repairs reject archived dinners
too.

F2: Any failure grounding a candidate or link by hand wraps
ErrGroundFailed, which the API logs as reason=ground_failed with the
dinner id only, never the pasted link or dependency error text.
GroundURL no longer formats the link into its error.

Maestro task 8741cedb-1849-4e3f-8d05-f5648c77c7ee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:03:05 -04:00
orchestrator-agent cc7875c76c Integration fix: Feature 12 feature-review findings F1-F8 (#95)
CI / ci (pull_request) Successful in 2m23s
CI / ci (push) Successful in 2m27s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-06 19:54:47 -04:00
dev_agentandClaude Opus 5.5 171c9c2d11 Fix stale Regular grounding and enforce three Regulars for onboarding
CI / ci (push) Successful in 2m20s
CI / ci (pull_request) Successful in 2m21s
A background grounding result now applies only while the dinner is still
pending under the normalized name the job searched: GroundPendingDinner and
FailPendingDinnerGrounding match normalized_name, so a rename makes the old
job's success or failure a no-op. Renaming a dinner still pending enqueues
a fresh grounding job for the new name; if that cannot be enqueued, the
dinner's grounding is failed so it is not left pending with no job.

Completing onboarding (PATCH /api/v1/settings/household with
onboarding_completed true) is refused with 400 invalid_settings until the
household has at least three Regulars (status regular; archived and Quest
log dinners do not count), and onboarding_completed_at is set only then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:47:41 -04:00
dev_agent 9baabad84c Regular dinners management screen (#93)
CI / ci (push) Successful in 2m23s
CI / ci (pull_request) Successful in 2m22s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 18:31:48 -04:00
dev_agent 6a3f1f3936 Onboarding step 3 shell (#92)
CI / ci (push) Successful in 2m19s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 17:57:25 -04:00
dev_agent 0a5db6a9e6 Onboarding steps 1 and 2 (mock 1h) with first-run redirect (#91)
CI / ci (push) Successful in 2m24s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 17:34:52 -04:00
dev_agent 423b898f54 Background grounding and recipe fix-up API (#90)
CI / ci (push) Successful in 2m24s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 16:39:10 -04:00
dev_agent 729c5bd0fe Dinners API for Regulars (#89)
CI / ci (push) Successful in 2m15s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 15:57:13 -04:00
dev_agent 0c89eef577 Deflake TestFetchCanceledWaitHoldsNoSlot (#88)
CI / ci (push) Successful in 1m59s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 13:18:26 -04:00
dev_agent fcbdebd6e8 Feature 11: Settings home and Admin AI assistant (#86)
CI / ci (push) Successful in 2m0s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 12:45:05 -04:00
102 changed files with 16887 additions and 248 deletions
+6 -2
View File
@@ -44,6 +44,9 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
# trusts no peer, which is right for local development.
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
# Optional: a SearXNG instance the Settings screen suggests as the recipe
# search URL until one is saved.
# export MEALQUEST_SEARXNG_URL=http://localhost:8888
export MEALQUEST_DEFAULT_TZ=America/New_York
make dev
```
@@ -53,8 +56,9 @@ make dev
http://localhost:5173. Configuration is environment-only and fails fast:
every variable above is required except the commented-out session
lifetimes (when set they must be positive, with MAX at least IDLE),
trusted proxy list (every entry must be an IP or CIDR) and
`MEALQUEST_DEFAULT_TZ`, and
trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL
(an absolute `http://` or `https://` URL without credentials, a query or a
fragment) and `MEALQUEST_DEFAULT_TZ`, and
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
endpoints the issuer advertises must be `https://`; plain `http://` is
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
+49 -6
View File
@@ -13,8 +13,12 @@ import (
"syscall"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
@@ -74,11 +78,31 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
photos, err := recipes.NewPhotoCache(recipes.NewFetcher(), db, cfg.DataDir)
// One fetcher serves pages and photos, so its per-host spacing and
// page cache cover both.
fetcher := recipes.NewFetcher()
photos, err := recipes.NewPhotoCache(fetcher, db, cfg.DataDir)
if err != nil {
return err
}
search, err := recipes.NewSearchSettingsService(db, box)
search, err := recipes.NewSearchSettingsService(db, box, cfg.SearXNGURL)
if err != nil {
return fmt.Errorf("%s: %w", config.EnvSearXNGURL, err)
}
ai, err := aisettings.New(db, box, cfg.PublicURL.String())
if err != nil {
return err
}
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
return err
}
grounder, err := recipes.NewGrounder(db, search, fetcher, photos, normalizer(ai, guard))
if err != nil {
return err
}
dinnerSvc, err := dinners.NewService(db, grounder, runner, logger)
if err != nil {
return err
}
@@ -99,7 +123,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, runner, photos, frontend.Assets())
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, runner, photos, frontend.Assets())
if err != nil {
return err
}
@@ -115,6 +139,12 @@ func run(cfg config.Config, logger *slog.Logger) error {
if err := runner.Start(jobsCtx); err != nil {
return err
}
// With those jobs failed, a dinner still pending with no grounding job
// queued or running is failed as interrupted, before any request can
// create one.
if _, err := dinnerSvc.FailInterruptedGroundings(ctx); err != nil {
return err
}
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: router,
@@ -196,7 +226,7 @@ func seedDefaults(ctx context.Context, db *store.Store, defaultTZ string) error
return household.SeedNotificationPrefs(ctx, db)
}
// jobKinds enqueues every job kind the server runs.
// jobKinds enqueues the framework's job kinds.
type jobKinds struct {
builtins *jobs.Builtins
}
@@ -215,8 +245,9 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
return runner, kinds, nil
}
// registerJobKinds registers every job kind's handler on runner. Each
// feature's job kinds are registered here.
// registerJobKinds registers the framework's job kinds on runner. A feature
// service registers its own kinds when it is built, before the runner
// starts (dinners.NewService registers dinners.KindGroundDinner).
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
builtins, err := jobs.RegisterBuiltins(runner)
if err != nil {
@@ -225,6 +256,18 @@ func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
return &jobKinds{builtins: builtins}, nil
}
// normalizer runs normalize_recipe with the AI settings current at each
// run, sharing guard.
func normalizer(ai *aisettings.Service, guard *harness.Guard) recipes.Normalizer {
return func(ctx context.Context, scope string, in skills.RecipeText) (skills.NormalizedRecipe, error) {
cfg, err := ai.HarnessConfig(ctx, guard)
if err != nil {
return skills.NormalizedRecipe{}, err
}
return skills.NormalizeRecipe(ctx, cfg, scope, in)
}
}
// logListening logs the startup line. Only the public URL's origin is logged
// so no credential-bearing URL component can reach the log.
func logListening(logger *slog.Logger, cfg config.Config) {
+4 -4
View File
@@ -68,10 +68,10 @@ MEALQUEST_SESSION_MAX=4320h
# MEALQUEST_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
# The Authentik provider's client secret.
# MEALQUEST_SEARXNG_URL=http://searxng:8080
# The SearXNG recipe-search sidecar on the compose network. Planned; not
# read by the service yet. The app takes its recipe-search provider and
# base URL from PATCH /api/v1/settings/recipe-search; see
# deploy/RUNBOOK.md "Recipe search (SearXNG)".
# The SearXNG recipe-search sidecar on the compose network. The Settings
# screen pre-fills it as the SearXNG URL until one is saved; the saved
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
# See deploy/RUNBOOK.md "Recipe search (SearXNG)".
# --- Planned; not read by the service yet -------------------------------------
#
+16 -8
View File
@@ -417,15 +417,23 @@ The `searxng` service in `deploy/compose.yaml` is a private SearXNG
instance for recipe search. On the compose network it is reachable at
`http://searxng:8080`; that is the address to configure in the app.
Starting the sidecar does not connect the app to it. The service does not
read `MEALQUEST_SEARXNG_URL`: `deploy/compose.yaml` sets it for a future
wiring, and today it does nothing. The recipe-search provider and base URL
live in the `recipe_search_settings` row and are set through
`GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the Settings
screen's recipe search; the screen itself is not built yet). Until that row
exists, recipe search answers 409 `not_configured`.
Starting the sidecar does not connect the app to it. The recipe-search
provider and base URL live in the `recipe_search_settings` row and are set
through `GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the
recipe search card on Settings → AI assistant). Until that row exists,
recipe search answers 409 `not_configured`.
After the first deploy, from a signed-in session (every non-`GET` request
`deploy/compose.yaml` sets `MEALQUEST_SEARXNG_URL=http://searxng:8080`.
The service only suggests it: `GET` returns it as `default_base_url`, and
the recipe search card pre-fills the SearXNG URL with it while no URL is
saved. Nothing is stored until the card is saved, and a saved `base_url`
always wins. A set value must be an absolute `http://` or `https://` URL
without credentials, a query or a fragment, or the service refuses to
start; unset, `default_base_url` is `null`.
To set it up, open Settings → AI assistant, check that the recipe search
card shows SearXNG with `http://searxng:8080`, select **Save recipe search**
and then **Test**. Or, from a signed-in session (every non-`GET` request
needs the `X-MealQuest-CSRF` header set to `csrfToken` from
`GET /api/v1/me`):
+153 -2
View File
@@ -96,7 +96,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
| `recipe_steps` | recipe_id, position, text. |
| `photos` | source_url, local_path, width, height, sha256, fetched_at. |
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by. |
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised when a pending dinner is renamed, so only the latest `ground_dinner` job may write it), updated_by. |
| `suggestion_batches` | trigger, status, ai_run_id. |
| `suggestions` | dinner_id, batch_id, reason, cited_regular_ids (JSON), status, decided_at. |
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. |
@@ -149,9 +149,117 @@ automatic** (D-021).
the Brave key (`api_key`, write-only: responses carry only
`api_key_set` and `api_key_last4`; an empty string removes it). The
result must be usable: SearXNG needs a base_url, Brave a key.
Responses also carry the read-only `default_base_url`, the deployment's
`MEALQUEST_SEARXNG_URL` (or null), which the client pre-fills while no
base_url is saved; only a PATCH of base_url stores a URL.
`POST /api/v1/settings/recipe-search/test` searches "chicken tikka
masala" and returns `{query, count}`, 409 `not_configured`, or 502
`search_failed` with the provider's error.
- `GET`/`PATCH /api/v1/settings/ai` (`aisettings.Service`) — provider,
base_url, model, temperature, system_prompt, plus the read-only
`api_key_last4` and `key_verified_at`. PATCH requires a preset name as
provider, temperature 0–1 in 0.1 steps, a non-empty model and a
system_prompt of at most 8000 characters; a fixed-URL preset's base_url
must be empty or its own. Provider and base_url cannot change while a
key is stored (remove the key first), so a stored key is never sent to
an endpoint it was not stored for.
`PUT /api/v1/settings/ai/api-key` takes `{api_key}` (write-only, at least
8 characters, sealed with the secret box; an empty string removes it) and
clears `key_verified_at`; a preset without a fixed base_url takes a key
only when its base_url passes `secureurl.Check`. `POST /api/v1/settings/ai/verify` calls
`Provider.Verify` and records `key_verified_at`;
`GET /api/v1/settings/ai/models` returns `{models: [{id, display_name}]}`
live from the provider, with the key redacted from every id and
display_name. Both work before a model is chosen
(`presets.ForAccount`), answer 409 `not_configured` when the settings
cannot build a provider, and 502 with the provider's typed error
(`auth_failed`, `rate_limited`, `bad_output`, `provider_error`; 504
`timeout`) when the call fails. `POST /api/v1/settings/ai/reset-prompt`
restores `household.DefaultPersonaPrompt` (CTXD PROMPTS.MD §4). No
response holds the key. `Service.HarnessConfig` reads the settings for
each skill run, so a saved change applies to the next run.
- Dinners (`dinners.Service`). `POST /api/v1/dinners` adds a Regular from
exactly one of `{name}` or `{recipe_url}` (an https:// link, kept in
`recipe_url` for grounding) with status and origin `regular` and
grounding_status `pending`, answering 201 with the dinner. A link's
dinner is named from its last path segment (separators become spaces,
an extension and all-digit words are dropped: `…/12345/beef-stew/` is
"Beef stew"); a link that names nothing is 400 — add it by name. A name
is trimmed, at most 120 characters and must keep a word after
`harness.Normalize`, which sets `normalized_name`. A name
`harness.Similar` to an unarchived dinner's, or a link one already has
(the source_url of the recipe it is grounded in, else its pasted
`recipe_url`), is 409 `duplicate_dinner`; archived names may be added
again.
`GET /api/v1/dinners?status=regular|quest_log` returns
`{dinners: [...]}` ordered by normalized name, each with `photo_url`
(`/media/photos/{photo_id}-400.jpg`, or null) and `grounding_status`;
any other status is 400. `PATCH /api/v1/dinners/{id}` edits `name`
(deduplicated against the other unarchived dinners), `method`
(`stovetop` / `oven` / `crockpot` / `grill` / `other`), `minutes_total`
(1–1440) and `difficulty` (`easy` / `medium` / `hard`); invalid values
are 400 `invalid_dinner` and change nothing. `DELETE /api/v1/dinners/{id}`
sets status `archived` and keeps the row; `POST
/api/v1/dinners/{id}/favorite` toggles `is_favorite`. Every write records
the session's subject as `updated_by`; an unknown id is 404.
- Dinner grounding. `POST /api/v1/dinners` also enqueues a `ground_dinner`
job and answers with its `grounding_job_id` without waiting for it
(`GET /api/v1/jobs/{id}` reports it); if the job cannot be enqueued it
still answers 201, with grounding_status `failed` (`grounding_error`)
and `grounding_job_id` null. The job grounds the dinner's
`recipe_url` directly with `GroundURL`, else its name with `Ground`
(see Recipe grounding), and sets grounding_status `grounded` with the
recipe's recipe_id, photo_id, method, minutes_total and difficulty, or
`failed` with `grounding_error` — also the job's failure code — one of
`no_recipe_found`, `recipe_unusable` (the pasted link), `search_not_configured`,
`search_failed`, `ai_failed`, `interrupted` (the server stopped) or
`grounding_error`. Each job carries the dinner's `grounding_revision`
from when it was enqueued; renaming a `pending` dinner clears its
`recipe_url`, raises the revision and enqueues a job for the new name.
A job for a dinner archived, no longer `pending` or at another revision
does no work, and a job only writes a dinner still unarchived, `pending`
and at its revision, so a fix-up made meanwhile wins and an earlier job
for an old name — queued or running — never decides the outcome.
Archiving or renaming a dinner also stops its running job where it is,
so no further search, page fetch, photo download or model call is made
for it. A
method, minutes_total or difficulty a member saved while the dinner was
`pending` is kept; grounding fills only those left empty. At boot, after
the job runner fails the jobs a previous process left running, every
unarchived dinner still `pending` with no `ground_dinner` job for its revision
queued or running is set `failed` with `interrupted`, so no dinner stays
pending with nothing left to ground it.
`GET /api/v1/dinners/{id}/recipe-candidates`
searches for the dinner's name and returns `{candidates: [{candidate_id,
title, site_name, photo_url, minutes}]}`, at most 3, ranked as `Ground`
ranks them; it fetches pages but downloads no photo and runs no model,
so `photo_url` is set only for a page already stored, and `candidate_id`
is the page's URL. `POST /api/v1/dinners/{id}/ground` takes exactly one
of `{candidate_id}`, `{recipe_url}` (both grounded with `GroundURL`,
setting `grounded`) or `{manual: {ingredients[], steps[], minutes,
method}}` (1–100 ingredient lines, up to 100 steps, each non-blank and at
most 500 characters; minutes 1–1440). A manual recipe is stored as typed
— no model runs — as a `recipes` row whose source_url is
`mealquest:manual:{recipe_id}` and whose raw_jsonld is a schema.org
Recipe built from it, and sets `manual` with its method and minutes, no
photo and the dinner's difficulty unchanged. Either clears the dinner's
pasted `recipe_url`: the recipe chosen replaces it. It answers 200 with
the dinner; an invalid body is 400, an unknown dinner 404, a page that cannot
ground it 422 `ungroundable` (the dinner is unchanged), recipe search not
set up 409 `not_configured`, a search or model failure 502
`search_failed` / `ai_failed` (only the reason code is logged, never the
provider's text), and grounding a candidate or link that runs past 90
seconds (fetch, photo and `normalize_recipe`, all in the request) 504
`ground_timeout`, leaving the dinner unchanged. An archived dinner is 409
`dinner_archived`. A candidate or link is written only if the dinner is
still as it was before grounding began: one renamed or grounded some
other way meanwhile is 409 `ground_conflict`, and one archived meanwhile
409 `dinner_archived`, either leaving the dinner unchanged. A method,
minutes or difficulty a member edits meanwhile is kept over the recipe's.
Any other failure grounding a candidate or link, or recording it on the
dinner, is 500, logged with a fixed reason and the dinner id only —
never the link, which can carry a personal token, or dependency error
text.
- `/health` — liveness.
- Everything else serves the embedded PWA with SPA fallback.
@@ -320,7 +428,10 @@ automatic** (D-021).
Rank's per-candidate reasons, photo or normalize failures). Failures
that are not the candidates' — search, model provider, database — are
returned as they are. `GroundURL(ctx, url)` does the same for one pasted
URL. `GroundMany` grounds items 3 at a time and returns one result per
URL. `Candidates(ctx, dishName, query, n)` returns the first n of the
candidates `Ground` would try, without grounding any. Search failures
wrap `recipes.ErrSearchFailed` and model provider failures
`recipes.ErrNormalizeFailed`. `GroundMany` grounds items 3 at a time and returns one result per
item, so one failure never affects its siblings. A recipe is usable only
with ingredients, an image and, if given, a positive total time.
@@ -396,6 +507,46 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
`/settings/household`, `/settings/regulars` and `/settings/calendar` come from
CTXD; the other routes are defined in the UI handoff.
First run: while `household_settings.onboarding_completed_at` (read from
`GET /api/v1/settings/household`) is null, every route redirects to
`/onboarding`. Onboarding is a full screen outside the app shell, in three
steps: the welcome (`/onboarding`), the Regulars (`/onboarding/regulars`,
mock 1h, adding and archiving through the dinners API optimistically; Next
waits for three saved Regulars, and a common pick is not offered once a
Regular has a name `harness.Similar` to it — the PWA's copy of the name
rules in `web/src/dinners/names.ts` folds letters with
`web/src/dinners/letterFolds.json`, which `gen_folds.py` writes from the
same table as the server's, and runs the same cases,
`internal/ai/harness/testdata/dedupe_cases.json`, as the server's) and
notifications (`/onboarding/notifications`: what MealQuest notifies about,
the iOS install hint, "Turn on notifications" and "Maybe later"). Either
button sends `PATCH /api/v1/settings/household` with
`{"onboarding_completed": true}`; the server sets `onboarding_completed_at`
to the time of that update (keeping the first time if it is already set;
the field itself is read-only and `false` is refused) and the app goes Home.
Completing needs at least three Regulars (`household.MinRegulars`); with
fewer the server answers 400 `invalid_settings` naming
`onboarding_completed` and changes nothing. Step 3 can be reached with
fewer — by its URL, or after another member removes a Regular — so it then
shows the server's reason with an "Add Regulars" button back to step 2.
Notification permission is never asked for on page load; until Feature 19
wires Web Push, "Turn on notifications" asks for nothing. Offline on the saved pages the
settings cannot be read (the service worker never caches `/api/v1/settings`),
so the saved pages open as they are.
Regular dinners (`/settings/regulars`) lists the Regulars from
`GET /api/v1/dinners?status=regular`, each with its photo, "method ·
minutes", favorite heart and grounding badge ("Finding recipe…" while
`pending`, "Recipe found" when `grounded` or `manual`, "Needs a recipe" when
`failed`). The add field takes a name or a pasted `https://` recipe link.
Tapping a Regular opens an edit sheet (name, method, minutes, difficulty;
remove archives it after a confirmation); tapping one that needs a recipe
opens "Fix recipe" (pick one of the recipe candidates, paste a link or type
the ingredients and steps in). Adds, removals and the heart show at once and
are undone if the server refuses them. While any Regular is `pending`, the
list is read again every 3 seconds (not while a change is in flight), and
polling stops once none is.
## Boundaries and gotchas
- **Secrets never reach the client** — API keys, OAuth tokens and the Chat
+478
View File
@@ -0,0 +1,478 @@
// Package aisettings reads and changes the household's AI settings — the
// ai_settings row seeded by household.SeedAISettings — and builds the
// provider and harness.Config they describe. The API key is sealed with the
// secret box and never leaves the package except to the provider.
package aisettings
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"math"
"net/url"
"strings"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/presets"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const (
// MaxSystemPromptChars is the longest system prompt accepted, in
// characters.
MaxSystemPromptChars = 8000
// MinKeyLength is the shortest API key accepted, so its last four
// characters never give away most of it.
MinKeyLength = 8
)
var (
// ErrNotConfigured is returned when the stored AI settings cannot
// build a provider, e.g. there is no API key yet. It is joined with
// the llm configuration error that says why.
ErrNotConfigured = errors.New("aisettings: AI provider is not configured")
// ErrProviderFailed is returned when a call to the provider fails. It
// is joined with the provider's error, so the llm typed error it
// matches says why.
ErrProviderFailed = errors.New("aisettings: AI provider call failed")
// ErrChanged is returned when the AI settings changed while
// their key was being verified, so the result applies to old settings.
ErrChanged = errors.New("aisettings: AI settings changed during verification")
)
// Settings is the household's AI configuration as the API shows it. The
// API key is never part of it: only its last four characters.
type Settings struct {
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
Model string `json:"model"`
Temperature float64 `json:"temperature"`
// SystemPrompt is the admin persona, nil when none is stored.
SystemPrompt *string `json:"system_prompt"`
// APIKeyLast4 is nil when no key is stored.
APIKeyLast4 *string `json:"api_key_last4"`
// KeyVerifiedAt is when the stored key was last verified with these
// provider settings, nil when it has not been.
KeyVerifiedAt *string `json:"key_verified_at"`
UpdatedAt string `json:"updated_at"`
}
// Patch changes the settings. A nil field is left as it is.
type Patch struct {
Provider *string `json:"provider"`
BaseURL *string `json:"base_url"`
Model *string `json:"model"`
Temperature *float64 `json:"temperature"`
SystemPrompt *string `json:"system_prompt"`
}
// Model is one model the provider offers.
type Model struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
}
// Problem is one invalid field, named by its JSON key.
type Problem struct {
Field string
Reason string
}
// ValidationError lists every problem with an Patch or API
// key, or with the settings it would leave. No problem quotes the key.
type ValidationError struct {
Problems []Problem
}
func (e *ValidationError) Error() string {
parts := make([]string, len(e.Problems))
for i, p := range e.Problems {
parts[i] = p.Field + ": " + p.Reason
}
return "invalid AI settings: " + strings.Join(parts, "; ")
}
// Service reads and changes the ai_settings row and builds the provider it
// names. API keys are sealed with its secret box and never leave it except
// to the provider.
type Service struct {
st *store.Store
box *secretbox.Box
publicURL string
// newProvider and newAccount are presets.FromSettings and
// presets.ForAccount; tests substitute a fake provider.
newProvider harness.NewProvider
newAccount func(llm.Settings, string) (presets.Account, error)
}
// New returns the service over st, sealing keys with box.
// publicURL is MEALQUEST_PUBLIC_URL, which some presets send.
func New(st *store.Store, box *secretbox.Box, publicURL string) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("aisettings: nil store")
case box == nil:
return nil, errors.New("aisettings: nil secret box")
case publicURL == "":
return nil, errors.New("aisettings: empty public URL")
}
return &Service{st: st, box: box, publicURL: publicURL, newProvider: presets.FromSettings, newAccount: presets.ForAccount}, nil
}
// Get returns the settings.
func (s *Service) Get(ctx context.Context) (Settings, error) {
row, err := s.read(ctx, db.New(s.st.Reader))
if err != nil {
return Settings{}, err
}
return settingsFrom(row), nil
}
// Update applies patch and returns the settings it leaves. The provider
// and base URL cannot change while an API key is stored, so the key never
// reaches an endpoint it was not stored for. Every problem is reported in
// a *ValidationError and nothing changes.
func (s *Service) Update(ctx context.Context, patch Patch) (Settings, error) {
return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) {
return applyPatch(row, patch)
})
}
// SetAPIKey seals key and records its last four characters, clearing
// key_verified_at. An empty key removes the stored one. A preset without
// a fixed base URL takes a key only when its base URL is secure (see
// secureurl.Check).
func (s *Service) SetAPIKey(ctx context.Context, key string) (Settings, error) {
return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) {
row.ApiKeyCt, row.ApiKeyLast4, row.KeyVerifiedAt = nil, sql.NullString{}, sql.NullString{}
if key == "" {
return row, nil
}
p, _ := presets.Lookup(row.Provider)
var reason string
switch {
case p.Key == presets.KeyNone:
reason = row.Provider + " takes no API key"
case p.BaseURL == "" && !secureBaseURL(row.BaseUrl):
reason = "is sent only over https://, or http:// to localhost: set such a base_url first"
case !validHeaderValue(key):
reason = "has a character not allowed in a header"
case len(key) < MinKeyLength:
reason = fmt.Sprintf("must be at least %d characters", MinKeyLength)
}
if reason != "" {
return db.AiSetting{}, &ValidationError{Problems: []Problem{{Field: "api_key", Reason: reason}}}
}
ct, err := s.box.Encrypt([]byte(key))
if err != nil {
return db.AiSetting{}, fmt.Errorf("aisettings: seal AI API key: %w", err)
}
row.ApiKeyCt = ct
row.ApiKeyLast4 = sql.NullString{String: key[len(key)-4:], Valid: true}
return row, nil
})
}
// ResetPrompt restores household.DefaultPersonaPrompt as the system prompt.
func (s *Service) ResetPrompt(ctx context.Context) (Settings, error) {
return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) {
row.SystemPrompt = sql.NullString{String: household.DefaultPersonaPrompt, Valid: true}
return row, nil
})
}
// Verify checks the stored key with the provider and, when it is
// accepted, records key_verified_at. Settings that cannot build a
// provider fail with ErrNotConfigured; a provider failure matches
// ErrProviderFailed and the llm typed error the provider returned. If the
// settings change while the provider is asked, nothing is recorded and the
// error is ErrChanged.
func (s *Service) Verify(ctx context.Context) (Settings, error) {
row, err := s.read(ctx, db.New(s.st.Reader))
if err != nil {
return Settings{}, err
}
acct, key, err := s.account(row)
if err != nil {
return Settings{}, err
}
if err := acct.Verify(ctx); err != nil {
return Settings{}, redact(fmt.Errorf("%w: verify: %w", ErrProviderFailed, err), key)
}
verified := store.Now().String()
return s.change(ctx, func(now db.AiSetting) (db.AiSetting, error) {
if now.Provider != row.Provider || now.BaseUrl != row.BaseUrl || !bytes.Equal(now.ApiKeyCt, row.ApiKeyCt) {
return db.AiSetting{}, ErrChanged
}
now.KeyVerifiedAt = sql.NullString{String: verified, Valid: true}
return now, nil
})
}
// Models returns the provider's live model list with the API key removed
// from every model's ID and display name. Its errors are Verify's.
func (s *Service) Models(ctx context.Context) ([]Model, error) {
row, err := s.read(ctx, db.New(s.st.Reader))
if err != nil {
return nil, err
}
acct, key, err := s.account(row)
if err != nil {
return nil, err
}
models, err := acct.ListModels(ctx)
if err != nil {
return nil, redact(fmt.Errorf("%w: list models: %w", ErrProviderFailed, err), key)
}
out := make([]Model, len(models))
for i, m := range models {
out[i] = Model{ID: llm.Redact(m.ID, key), DisplayName: llm.Redact(m.DisplayName, key)}
}
return out, nil
}
// HarnessConfig returns the harness.Config for a skill run with the
// current settings, sharing guard. Read it for each run, so a run always
// uses the latest saved provider, model, temperature and persona.
func (s *Service) HarnessConfig(ctx context.Context, guard *harness.Guard) (harness.Config, error) {
row, err := s.read(ctx, db.New(s.st.Reader))
if err != nil {
return harness.Config{}, err
}
key, err := s.key(row)
if err != nil {
return harness.Config{}, err
}
return harness.Config{
Settings: s.llmSettings(row),
APIKey: key,
NewProvider: s.newProvider,
Persona: row.SystemPrompt.String,
Store: s.st,
Guard: guard,
}, nil
}
// account builds the provider's Account from row, returning the key too
// so the caller can redact it.
func (s *Service) account(row db.AiSetting) (presets.Account, string, error) {
key, err := s.key(row)
if err != nil {
return nil, "", err
}
acct, err := s.newAccount(s.llmSettings(row), key)
if err != nil {
return nil, "", redact(fmt.Errorf("%w: %w", ErrNotConfigured, err), key)
}
return acct, key, nil
}
func (s *Service) llmSettings(row db.AiSetting) llm.Settings {
return llm.Settings{
Provider: row.Provider,
BaseURL: row.BaseUrl,
Model: row.Model,
Temperature: row.Temperature,
PublicURL: s.publicURL,
}
}
// key opens row's API key; "" when none is stored.
func (s *Service) key(row db.AiSetting) (string, error) {
if len(row.ApiKeyCt) == 0 {
return "", nil
}
key, err := s.box.Decrypt(row.ApiKeyCt)
if err != nil {
return "", fmt.Errorf("aisettings: open AI API key: %w", err)
}
return string(key), nil
}
func (s *Service) read(ctx context.Context, q *db.Queries) (db.AiSetting, error) {
row, err := q.GetAISettings(ctx)
if errors.Is(err, sql.ErrNoRows) {
return db.AiSetting{}, errors.New("aisettings: AI settings are not seeded")
}
if err != nil {
return db.AiSetting{}, fmt.Errorf("aisettings: read AI settings: %w", err)
}
return row, nil
}
// change saves edit's result for the current row in one transaction.
func (s *Service) change(ctx context.Context, edit func(db.AiSetting) (db.AiSetting, error)) (Settings, error) {
var saved db.AiSetting
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
row, err := s.read(ctx, q)
if err != nil {
return err
}
next, err := edit(row)
if err != nil {
return err
}
saved, err = q.UpsertAISettings(ctx, db.UpsertAISettingsParams{
Provider: next.Provider,
BaseUrl: next.BaseUrl,
Model: next.Model,
Temperature: next.Temperature,
SystemPrompt: next.SystemPrompt,
ApiKeyCt: next.ApiKeyCt,
ApiKeyLast4: next.ApiKeyLast4,
KeyVerifiedAt: next.KeyVerifiedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: store.Now().String(),
})
if err != nil {
return fmt.Errorf("aisettings: save AI settings: %w", err)
}
return nil
})
if err != nil {
return Settings{}, err
}
return settingsFrom(saved), nil
}
// applyPatch returns row with patch applied, validated.
func applyPatch(row db.AiSetting, patch Patch) (db.AiSetting, error) {
var problems []Problem
fail := func(field, reason string) {
problems = append(problems, Problem{Field: field, Reason: reason})
}
if patch == (Patch{}) {
fail("settings", "no settings to change")
}
before := row
if patch.Provider != nil {
if _, ok := presets.Lookup(*patch.Provider); ok {
row.Provider = *patch.Provider
} else {
fail("provider", "must be one of "+strings.Join(presets.Names(), ", "))
}
}
if patch.BaseURL != nil {
row.BaseUrl = *patch.BaseURL
}
if patch.Model != nil {
switch m := *patch.Model; {
case strings.TrimSpace(m) == "":
fail("model", "must not be empty")
case !validHeaderValue(m):
fail("model", "must not contain control characters")
default:
row.Model = m
}
}
if patch.Temperature != nil {
t := *patch.Temperature
tenths := math.Round(t * 10)
if t < 0 || t > 1 || math.Abs(t*10-tenths) > 1e-9 {
fail("temperature", "must be between 0 and 1 in steps of 0.1")
} else {
row.Temperature = tenths / 10
}
}
if patch.SystemPrompt != nil {
if n := utf8.RuneCountInString(*patch.SystemPrompt); n > MaxSystemPromptChars {
fail("system_prompt", fmt.Sprintf("must be at most %d characters, got %d", MaxSystemPromptChars, n))
} else {
row.SystemPrompt = sql.NullString{String: *patch.SystemPrompt, Valid: true}
}
}
if p, ok := presets.Lookup(row.Provider); ok && len(problems) == 0 {
switch {
case p.BaseURL != "" && row.BaseUrl != "" && row.BaseUrl != p.BaseURL:
fail("base_url", fmt.Sprintf("is fixed for %s: must be empty or %s", p.Name, p.BaseURL))
case p.BaseURL == "" && row.BaseUrl != "":
// The preset's own check of the URL; its errors never quote it.
if _, err := presets.ForAccount(llm.Settings{Provider: p.Name, BaseURL: row.BaseUrl}, ""); err != nil {
fail("base_url", err.Error())
}
}
}
// The stored key is only ever sent where it was stored for: moving it
// to another provider or server would let a changed base URL receive
// it. Remove the key, change the endpoint, then store the new key.
if len(problems) == 0 && len(row.ApiKeyCt) > 0 {
if row.Provider != before.Provider {
fail("provider", "cannot change while an API key is stored: remove the key first")
}
if row.BaseUrl != before.BaseUrl {
fail("base_url", "cannot change while an API key is stored: remove the key first")
}
}
if len(problems) > 0 {
return db.AiSetting{}, &ValidationError{Problems: problems}
}
return row, nil
}
func settingsFrom(row db.AiSetting) Settings {
return Settings{
Provider: row.Provider,
BaseURL: row.BaseUrl,
Model: row.Model,
Temperature: row.Temperature,
SystemPrompt: nullString(row.SystemPrompt),
APIKeyLast4: nullString(row.ApiKeyLast4),
KeyVerifiedAt: nullString(row.KeyVerifiedAt),
UpdatedAt: row.UpdatedAt,
}
}
func nullString(s sql.NullString) *string {
if !s.Valid {
return nil
}
return &s.String
}
// secureBaseURL reports whether base is a URL a key may be sent to.
func secureBaseURL(base string) bool {
u, err := url.Parse(base)
return base != "" && err == nil && secureurl.Check(u) == nil
}
// validHeaderValue reports whether value has no control character other
// than tab.
func validHeaderValue(value string) bool {
for i := 0; i < len(value); i++ {
if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f {
return false
}
}
return true
}
// redactedError is an error with the API key removed from its text. It
// does not unwrap, because the original may hold the key; Is still matches
// the sentinels the original wraps.
type redactedError struct {
text string
err error
}
func (e *redactedError) Error() string { return e.text }
// Is reports whether the original error matches target.
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
// redact returns err with key removed from its text; an empty key returns
// err unchanged.
func redact(err error, key string) error {
if key == "" {
return err
}
return &redactedError{text: llm.Redact(err.Error(), key), err: err}
}
+463
View File
@@ -0,0 +1,463 @@
package aisettings
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"reflect"
"slices"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/presets"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const testKey = "sk-ant-test-secret-key-41ab"
// newService returns a Service over a migrated store with the seed AI
// settings.
func newService(t *testing.T) (*Service, *store.Store) {
t.Helper()
ctx := context.Background()
st, err := store.Open(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if err := household.SeedAISettings(ctx, st); err != nil {
t.Fatal(err)
}
raw := make([]byte, secretbox.KeySize)
rand.Read(raw)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(raw))
if err != nil {
t.Fatal(err)
}
s, err := New(st, box, "https://mealquest.example")
if err != nil {
t.Fatal(err)
}
return s, st
}
// useFake makes s build f for every run, verification and model list, and
// returns a func reporting the settings and key each build was given.
func useFake(s *Service, f *llm.Fake) func() ([]llm.Settings, []string) {
var mu sync.Mutex
var settings []llm.Settings
var keys []string
record := func(set llm.Settings, key string) {
mu.Lock()
defer mu.Unlock()
settings = append(settings, set)
keys = append(keys, key)
}
s.newProvider = func(set llm.Settings, key string) (llm.Provider, error) {
record(set, key)
return f, nil
}
s.newAccount = func(set llm.Settings, key string) (presets.Account, error) {
record(set, key)
return f, nil
}
return func() ([]llm.Settings, []string) {
mu.Lock()
defer mu.Unlock()
return settings, keys
}
}
func ptr[T any](v T) *T { return &v }
// echoSkill asks the model to repeat a word; its prompt is the harness's
// test template prompts/echo.v1.tmpl.
type echoSkill struct{}
type echoInput struct{ Word string }
type echoOutput struct {
Echo string `json:"echo"`
}
func (echoSkill) Name() string { return "echo" }
func (echoSkill) Version() int { return 1 }
func (echoSkill) PromptInput(in echoInput) (harness.PromptInput, error) {
return harness.PromptInput{Data: in}, nil
}
func (echoSkill) Schema() llm.JSONSchema {
return llm.JSONSchema{Name: "echo", Schema: json.RawMessage(
`{"type":"object","properties":{"echo":{"type":"string"}},"required":["echo"],"additionalProperties":false}`)}
}
func (echoSkill) Validate(echoOutput) []string { return nil }
func (echoSkill) PostProcess(out echoOutput) (echoOutput, error) { return out, nil }
func (echoSkill) Timeout() time.Duration { return harness.DefaultTimeout }
// A PATCHed model and temperature are what the next skill run uses: the
// provider is built with them, the request carries the temperature and the
// ai_runs row records both. A second PATCH changes the run after it.
func TestPatchedModelAndTemperatureUsedByNextRun(t *testing.T) {
ctx := context.Background()
s, st := newService(t)
f := &llm.Fake{}
built := useFake(s, f)
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
t.Fatal(err)
}
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
t.Fatal(err)
}
for i, want := range []struct {
model string
temp float64
}{{"claude-patched", 0.3}, {"claude-repatched", 1}} {
if _, err := s.Update(ctx, Patch{Model: ptr(want.model), Temperature: ptr(want.temp)}); err != nil {
t.Fatal(err)
}
cfg, err := s.HarnessConfig(ctx, guard)
if err != nil {
t.Fatal(err)
}
f.QueueGenerate(llm.GenerateResult{Response: llm.Response{JSON: json.RawMessage(`{"echo":"hi"}`)}})
if _, err := harness.Run(ctx, cfg, echoSkill{}, "test", echoInput{Word: "hi"}); err != nil {
t.Fatalf("run %d: %v", i, err)
}
settings, keys := built()
if got := settings[i]; got.Provider != "anthropic" || got.Model != want.model || got.Temperature != want.temp || keys[i] != testKey {
t.Errorf("run %d provider built with %+v, key ok %v; want model %s at %v", i, got, keys[i] == testKey, want.model, want.temp)
}
req := f.Requests()[i]
if req.Temperature == nil || *req.Temperature != want.temp {
t.Errorf("run %d request temperature = %v, want %v", i, req.Temperature, want.temp)
}
if !strings.HasPrefix(req.System, household.DefaultPersonaPrompt[:40]) {
t.Errorf("run %d system prompt does not start with the persona: %.80q", i, req.System)
}
var model string
var temp float64
err = st.Reader.QueryRowContext(ctx, `SELECT model, temperature FROM ai_runs ORDER BY created_at DESC, rowid DESC LIMIT 1`).Scan(&model, &temp)
if err != nil {
t.Fatal(err)
}
if model != want.model || temp != want.temp {
t.Errorf("run %d ai_runs row = %s at %v, want %s at %v", i, model, temp, want.model, want.temp)
}
}
}
func TestUpdateValidates(t *testing.T) {
ctx := context.Background()
for _, c := range []struct {
name string
patch Patch
field string
}{
{"empty", Patch{}, "settings"},
{"unknown provider", Patch{Provider: ptr("gemini")}, "provider"},
{"temperature above 1", Patch{Temperature: ptr(1.1)}, "temperature"},
{"negative temperature", Patch{Temperature: ptr(-0.1)}, "temperature"},
{"temperature between steps", Patch{Temperature: ptr(0.35)}, "temperature"},
{"empty model", Patch{Model: ptr(" ")}, "model"},
{"long system prompt", Patch{SystemPrompt: ptr(strings.Repeat("ü", MaxSystemPromptChars+1))}, "system_prompt"},
{"other base URL for a fixed preset", Patch{BaseURL: ptr("https://api.example/v1")}, "base_url"},
{"bad base URL", Patch{Provider: ptr("ollama"), BaseURL: ptr("ftp://ollama:11434")}, "base_url"},
} {
t.Run(c.name, func(t *testing.T) {
s, _ := newService(t)
before, err := s.Get(ctx)
if err != nil {
t.Fatal(err)
}
_, err = s.Update(ctx, c.patch)
var invalid *ValidationError
if !errors.As(err, &invalid) || len(invalid.Problems) != 1 || invalid.Problems[0].Field != c.field {
t.Fatalf("Update = %v, want one %s problem", err, c.field)
}
if after, err := s.Get(ctx); err != nil || !reflect.DeepEqual(after, before) {
t.Errorf("settings changed by a refused patch: %+v, %v", after, err)
}
})
}
}
func TestUpdateAccepts(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
for _, temp := range []float64{0, 0.1, 0.2, 0.3, 0.6, 0.7, 0.9, 1} {
got, err := s.Update(ctx, Patch{Temperature: ptr(temp)})
if err != nil || got.Temperature != temp {
t.Errorf("temperature %v = %v, %v", temp, got.Temperature, err)
}
}
prompt := strings.Repeat("ü", MaxSystemPromptChars)
if got, err := s.Update(ctx, Patch{SystemPrompt: &prompt}); err != nil || *got.SystemPrompt != prompt {
t.Errorf("system prompt of %d characters: %v", MaxSystemPromptChars, err)
}
for _, p := range presets.Names() {
patch := Patch{Provider: ptr(p), BaseURL: ptr("")}
if pr, _ := presets.Lookup(p); pr.BaseURL == "" {
patch.BaseURL = ptr("http://llm.local:11434")
}
if got, err := s.Update(ctx, patch); err != nil || got.Provider != p {
t.Errorf("provider %s: %+v, %v", p, got, err)
}
}
}
// While a key is stored the provider and base URL cannot change, so the
// key never reaches an endpoint it was not stored for; the model,
// temperature and system prompt can, keeping the verification.
func TestEndpointLockedWhileKeyStored(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
f := &llm.Fake{}
useFake(s, f)
if _, err := s.Update(ctx, Patch{Provider: ptr("openai_compatible"), BaseURL: ptr("https://llm.example/v1")}); err != nil {
t.Fatal(err)
}
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
t.Fatal(err)
}
f.QueueVerify(nil)
if got, err := s.Verify(ctx); err != nil || got.KeyVerifiedAt == nil {
t.Fatalf("Verify = %+v, %v", got, err)
}
got, err := s.Update(ctx, Patch{Model: ptr("m"), Temperature: ptr(0.5), SystemPrompt: ptr("p")})
if err != nil || got.KeyVerifiedAt == nil {
t.Errorf("model change = %+v, %v; want verification kept", got, err)
}
before, err := s.Get(ctx)
if err != nil {
t.Fatal(err)
}
for _, c := range []struct {
patch Patch
field string
}{
{Patch{BaseURL: ptr("https://attacker.example/v1")}, "base_url"},
{Patch{Provider: ptr("openai"), BaseURL: ptr("")}, "provider"},
{Patch{Provider: ptr("ollama"), BaseURL: ptr("http://localhost:11434")}, "provider"},
} {
var invalid *ValidationError
_, err := s.Update(ctx, c.patch)
if !errors.As(err, &invalid) || !slices.ContainsFunc(invalid.Problems, func(p Problem) bool { return p.Field == c.field }) {
t.Errorf("Update(%+v) with a key stored = %v, want a %s problem", c.patch, err, c.field)
}
}
if after, err := s.Get(ctx); err != nil || !reflect.DeepEqual(after, before) {
t.Errorf("refused endpoint change altered the settings: %+v, %v", after, err)
}
// Removing the key frees the endpoint; a new key is unverified.
if _, err := s.SetAPIKey(ctx, ""); err != nil {
t.Fatal(err)
}
if _, err := s.Update(ctx, Patch{Provider: ptr("openai"), BaseURL: ptr("")}); err != nil {
t.Fatal(err)
}
got, err = s.SetAPIKey(ctx, "sk-another-key-9999")
if err != nil || got.KeyVerifiedAt != nil || *got.APIKeyLast4 != "9999" {
t.Errorf("new key = %+v, %v; want it unverified", got, err)
}
}
// A key is never stored for a base URL it would cross the network to in
// plain text.
func TestSetAPIKeyNeedsSecureBaseURL(t *testing.T) {
ctx := context.Background()
for _, c := range []struct {
base string
ok bool
}{
{"", false},
{"http://llm.lan:8080/v1", false},
{"http://127.0.0.1:8080/v1", true},
{"https://llm.example/v1", true},
} {
s, _ := newService(t)
patch := Patch{Provider: ptr("openai_compatible")}
if c.base != "" {
patch.BaseURL = ptr(c.base)
}
if _, err := s.Update(ctx, patch); err != nil {
t.Fatal(err)
}
_, err := s.SetAPIKey(ctx, testKey)
var invalid *ValidationError
if c.ok != (err == nil) || (!c.ok && !errors.As(err, &invalid)) {
t.Errorf("SetAPIKey with base URL %q = %v, want ok %v", c.base, err, c.ok)
}
}
}
func TestVerifyReturnsProviderTypedError(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
f := &llm.Fake{}
useFake(s, f)
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
t.Fatal(err)
}
for _, want := range []error{llm.ErrAuth, llm.NewRateLimitedError(time.Second, testKey), llm.NewProviderError(500, []byte("bad "+testKey), testKey)} {
f.QueueVerify(want)
_, err := s.Verify(ctx)
if !errors.Is(err, ErrProviderFailed) || !errors.Is(err, want) {
t.Errorf("Verify = %v, want ErrProviderFailed and %v", err, want)
}
if err != nil && strings.Contains(err.Error(), testKey) {
t.Errorf("Verify error holds the key: %v", err)
}
}
if got, err := s.Get(ctx); err != nil || got.KeyVerifiedAt != nil {
t.Errorf("failed verification recorded: %+v, %v", got, err)
}
}
// A key error that quotes the key, as a misbehaving adapter might, is
// redacted by the service.
func TestVerifyRedactsKey(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
f := &llm.Fake{}
useFake(s, f)
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
t.Fatal(err)
}
f.QueueVerify(errors.Join(llm.ErrAuth, errors.New("rejected "+testKey)))
_, err := s.Verify(ctx)
if !errors.Is(err, llm.ErrAuth) || strings.Contains(err.Error(), testKey) {
t.Errorf("Verify = %v, want ErrAuth without the key", err)
}
f.QueueListModels(llm.ListModelsResult{Err: errors.New("rejected " + testKey)})
if _, err := s.Models(ctx); !errors.Is(err, ErrProviderFailed) || strings.Contains(err.Error(), testKey) {
t.Errorf("Models = %v, want ErrProviderFailed without the key", err)
}
f.QueueListModels(llm.ListModelsResult{Models: []llm.Model{{ID: "m-" + testKey, DisplayName: "Model " + testKey}}})
models, err := s.Models(ctx)
if err != nil || len(models) != 1 || models[0] != (Model{ID: "m-[REDACTED]", DisplayName: "Model [REDACTED]"}) {
t.Errorf("Models = %+v, %v, want the key redacted", models, err)
}
}
// Settings changed while the provider verifies the key are not marked
// verified.
func TestVerifyRefusesChangedSettings(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
t.Fatal(err)
}
s.newAccount = func(llm.Settings, string) (presets.Account, error) {
return changingAccount{s: s}, nil
}
if _, err := s.Verify(ctx); !errors.Is(err, ErrChanged) {
t.Fatalf("Verify = %v, want ErrChanged", err)
}
if got, err := s.Get(ctx); err != nil || got.KeyVerifiedAt != nil || *got.APIKeyLast4 != "5678" {
t.Errorf("settings after a changed verification = %+v, %v", got, err)
}
}
// changingAccount replaces the stored key while it verifies.
type changingAccount struct{ s *Service }
func (a changingAccount) Verify(ctx context.Context) error {
_, err := a.s.SetAPIKey(ctx, "sk-replaced-5678")
return err
}
func (changingAccount) ListModels(context.Context) ([]llm.Model, error) { return nil, nil }
func TestNotConfigured(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
// The seed is anthropic with no key; the real presets refuse it.
if _, err := s.Verify(ctx); !errors.Is(err, ErrNotConfigured) || !errors.Is(err, llm.ErrMissingKey) {
t.Errorf("Verify = %v, want ErrNotConfigured and ErrMissingKey", err)
}
if _, err := s.Models(ctx); !errors.Is(err, ErrNotConfigured) {
t.Errorf("Models = %v, want ErrNotConfigured", err)
}
}
func TestSetAPIKey(t *testing.T) {
ctx := context.Background()
s, st := newService(t)
got, err := s.SetAPIKey(ctx, testKey)
if err != nil || got.APIKeyLast4 == nil || *got.APIKeyLast4 != "41ab" {
t.Fatalf("SetAPIKey = %+v, %v", got, err)
}
var ct []byte
if err := st.Reader.QueryRowContext(ctx, `SELECT api_key_ct FROM ai_settings`).Scan(&ct); err != nil {
t.Fatal(err)
}
if strings.Contains(string(ct), testKey) {
t.Error("the key is stored in plain text")
}
if key, err := s.key(mustRow(t, s)); err != nil || key != testKey {
t.Errorf("stored key opens to %q, %v", key, err)
}
for _, bad := range []string{"short", "sk-bad\r\nkey"} {
var invalid *ValidationError
if _, err := s.SetAPIKey(ctx, bad); !errors.As(err, &invalid) {
t.Errorf("SetAPIKey(%q) = %v, want a ValidationError", bad, err)
}
}
// Ollama takes no key: neither storing one nor switching to it with one.
var invalid *ValidationError
if _, err := s.Update(ctx, Patch{Provider: ptr("ollama"), BaseURL: ptr("http://ollama:11434")}); !errors.As(err, &invalid) {
t.Errorf("switch to ollama with a key = %v, want a ValidationError", err)
}
if got, err := s.SetAPIKey(ctx, ""); err != nil || got.APIKeyLast4 != nil {
t.Fatalf("remove key = %+v, %v", got, err)
}
if _, err := s.Update(ctx, Patch{Provider: ptr("ollama"), BaseURL: ptr("http://ollama:11434")}); err != nil {
t.Fatal(err)
}
if _, err := s.SetAPIKey(ctx, testKey); !errors.As(err, &invalid) {
t.Errorf("SetAPIKey for ollama = %v, want a ValidationError", err)
}
}
func TestResetPrompt(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
if _, err := s.Update(ctx, Patch{SystemPrompt: ptr("")}); err != nil {
t.Fatal(err)
}
got, err := s.ResetPrompt(ctx)
if err != nil || got.SystemPrompt == nil || *got.SystemPrompt != household.DefaultPersonaPrompt {
t.Errorf("ResetPrompt = %+v, %v", got, err)
}
}
func mustRow(t *testing.T, s *Service) db.AiSetting {
t.Helper()
row, err := s.read(context.Background(), db.New(s.st.Reader))
if err != nil {
t.Fatal(err)
}
return row
}
+60
View File
@@ -1,10 +1,70 @@
package harness
import (
"encoding/json"
"os"
"testing"
"unicode"
"unicode/utf8"
)
// TestSharedDedupeCases runs testdata/dedupe_cases.json, which the PWA's
// copy of Normalize and Similar (web/src/dinners/names.ts) runs too, so the
// two cannot drift apart on these names.
func TestSharedDedupeCases(t *testing.T) {
raw, err := os.ReadFile("testdata/dedupe_cases.json")
if err != nil {
t.Fatal(err)
}
var cases struct {
Normalize []struct{ Name, Want string }
Similar []struct {
A, B string
Want bool
}
}
if err := json.Unmarshal(raw, &cases); err != nil {
t.Fatal(err)
}
if len(cases.Normalize) == 0 || len(cases.Similar) == 0 {
t.Fatal("dedupe_cases.json has no cases")
}
for _, c := range cases.Normalize {
if got := Normalize(c.Name); got != c.Want {
t.Errorf("Normalize(%q) = %q, want %q", c.Name, got, c.Want)
}
}
for _, c := range cases.Similar {
if got := Similar(c.A, c.B); got != c.Want {
t.Errorf("Similar(%q, %q) = %v, want %v", c.A, c.B, got, c.Want)
}
}
}
// TestLetterFoldsMatchPWA checks that the PWA's letter-fold table, which
// gen_folds.py writes beside folds_table.go, is letterFolds exactly; run
// go generate when it fails.
func TestLetterFoldsMatchPWA(t *testing.T) {
raw, err := os.ReadFile("../../../web/src/dinners/letterFolds.json")
if err != nil {
t.Fatal(err)
}
var pwa map[string]string
if err := json.Unmarshal(raw, &pwa); err != nil {
t.Fatal(err)
}
for r, want := range letterFolds {
if got, ok := pwa[string(r)]; !ok || got != want {
t.Errorf("PWA folds %U to %q (present %v), want %q", r, got, ok, want)
}
}
for k := range pwa {
if utf8.RuneCountInString(k) != 1 || letterFolds[[]rune(k)[0]] == "" {
t.Errorf("PWA folds %q, which letterFolds does not", k)
}
}
}
func TestNormalize(t *testing.T) {
tests := []struct {
name, want string
+10 -1
View File
@@ -1,5 +1,8 @@
#!/usr/bin/env python3
"""Generate folds_table.go, the letter-fold table used by harness.Normalize.
"""Generate folds_table.go, the letter-fold table used by harness.Normalize,
and its copy web/src/dinners/letterFolds.json, used by the PWA's
normalizeName (web/src/dinners/names.ts). TestLetterFoldsMatchPWA fails when
the two differ.
Run from this directory with `go generate ./internal/ai/harness` (or
`python3 gen_folds.py`; gofmt must be on PATH). It uses only the Python
@@ -28,11 +31,14 @@ Hangul syllables are left out: their decomposition is into conjoining jamo,
not combining marks.
"""
import json
import re
import subprocess
import sys
import unicodedata
PWA_TABLE = "../../../web/src/dinners/letterFolds.json"
EXTRA = {
"ß": "ss", "ẞ": "ss", "æ": "ae", "Æ": "ae", "œ": "oe", "Œ": "oe",
"þ": "th", "Þ": "th", "ð": "d", "Ð": "d", "ı": "i", "ʼn": "n",
@@ -110,6 +116,9 @@ def main():
with open("folds_table.go", "w", encoding="utf-8") as f:
f.write("\n".join(out) + "\n")
subprocess.run(["gofmt", "-w", "folds_table.go"], check=True)
with open(PWA_TABLE, "w", encoding="ascii") as f:
json.dump({chr(cp): folded for cp, folded in entries}, f, indent=2)
f.write("\n")
if __name__ == "__main__":
+136
View File
@@ -0,0 +1,136 @@
{
"normalize": [
{
"name": "Easy Chicken Tikka-Masala!",
"want": "chicken tikka masala"
},
{
"name": " Homemade Chili ",
"want": "chili"
},
{
"name": "Grandma's Stew",
"want": "grandmas stew"
},
{
"name": "Grandma’s Stew",
"want": "grandmas stew"
},
{
"name": "İskender Kebab",
"want": "iskender kebab"
},
{
"name": "Paella à la Valenciana",
"want": "paella la valenciana"
},
{
"name": "Crème Brûlée",
"want": "creme brulee"
},
{
"name": "Crème Brûlée",
"want": "creme brulee"
},
{
"name": "Smørrebrød",
"want": "smorrebrod"
},
{
"name": "Pierogi z Łososiem",
"want": "pierogi z lososiem"
},
{
"name": "Straße Würstchen",
"want": "strasse wurstchen"
},
{
"name": "Œufs en Cocotte",
"want": "oeufs en cocotte"
},
{
"name": "Æbleskiver",
"want": "aebleskiver"
},
{
"name": "Phở Bò",
"want": "pho bo"
},
{
"name": "The Best Quick Recipe",
"want": ""
},
{
"name": "Chicken stir-fry",
"want": "chicken stir fry"
},
{
"name": "7-layer dip",
"want": "7 layer dip"
},
{
"name": "Ƈhili",
"want": "chili"
},
{
"name": "Ꞔarbanzo Stew",
"want": "carbanzo stew"
},
{
"name": "ΜΟΥΣΑΚΑΣ",
"want": "μουσακασ"
}
],
"similar": [
{
"a": "Homemade chili",
"b": "Chili",
"want": true
},
{
"a": "Easy Spaghetti Bolognese",
"b": "spaghetti bolognese",
"want": true
},
{
"a": "Chicken stir fry",
"b": "Chicken stir-fry",
"want": true
},
{
"a": "Crème brûlée",
"b": "Creme brulee",
"want": true
},
{
"a": "Smørrebrød",
"b": "smorrebrod",
"want": true
},
{
"a": "Beef tacos",
"b": "Fish tacos",
"want": false
},
{
"a": "Chili",
"b": "White chicken chili",
"want": false
},
{
"a": "Pot roast",
"b": "Roast",
"want": false
},
{
"a": "The best",
"b": "Easy recipe",
"want": false
},
{
"a": "Ƈhili",
"b": "Chili",
"want": true
}
]
}
+34 -6
View File
@@ -15,6 +15,7 @@ import (
"context"
"errors"
"fmt"
"maps"
"math"
"regexp"
"slices"
@@ -115,6 +116,11 @@ func Lookup(name string) (Preset, bool) {
return p, ok
}
// Names returns every preset's name, sorted.
func Names() []string {
return slices.Sorted(maps.Keys(all))
}
// all is every preset, by Settings.Provider name.
var all = map[string]Preset{
"anthropic": {
@@ -181,19 +187,40 @@ var all = map[string]Preset{
// preset's OmitTemperature matches. It is wrapped in llm.NewRetrying, so
// Generate retries transient vendor failures and has the default timeout.
func FromSettings(s llm.Settings, key string) (llm.Provider, error) {
cp, err := fromSettings(s, key)
cp, err := fromSettings(s, key, true)
if err != nil {
return nil, redact(err, key)
}
return llm.NewRetrying(cp), nil
}
func fromSettings(s llm.Settings, key string) (*configured, error) {
// Account is what a provider offers before a model is chosen: the live
// model list and a check of the credentials.
type Account interface {
ListModels(ctx context.Context) ([]llm.Model, error)
Verify(ctx context.Context) error
}
// ForAccount returns the Account s describes, using key as its API key. It
// checks s and key as FromSettings does except that s.Model may be empty,
// so the household can verify its key and pick from the live model list
// before it has chosen a model. Neither call is retried.
func ForAccount(s llm.Settings, key string) (Account, error) {
cp, err := fromSettings(s, key, false)
if err != nil {
return nil, redact(err, key)
}
return cp, nil
}
// fromSettings builds the provider s describes; needModel is whether
// s.Model is required.
func fromSettings(s llm.Settings, key string, needModel bool) (*configured, error) {
p, ok := Lookup(s.Provider)
if !ok {
return nil, llm.ErrUnknownProvider
}
if err := check(p, s, key); err != nil {
if err := check(p, s, key, needModel); err != nil {
return nil, fmt.Errorf("%s: %w", p.Name, err)
}
base := p.BaseURL
@@ -233,8 +260,9 @@ func fromSettings(s llm.Settings, key string) (*configured, error) {
return cp, nil
}
// check validates s and key against p. Its errors never quote a value.
func check(p Preset, s llm.Settings, key string) error {
// check validates s and key against p, and that s names a model when
// needModel is set. Its errors never quote a value.
func check(p Preset, s llm.Settings, key string, needModel bool) error {
switch {
case p.Key == KeyRequired && key == "":
return llm.ErrMissingKey
@@ -249,7 +277,7 @@ func check(p Preset, s llm.Settings, key string) error {
case p.BaseURL != "" && s.BaseURL != "" && s.BaseURL != p.BaseURL:
return fmt.Errorf("%w: base URL is fixed by the preset", llm.ErrInvalidSetting)
}
if s.Model == "" {
if needModel && s.Model == "" {
return llm.ErrMissingModel
}
if p.RequiresPublicURL && s.PublicURL == "" {
+33
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"slices"
"strings"
"testing"
@@ -660,3 +661,35 @@ func TestFromSettingsRetriesTransportTimeout(t *testing.T) {
t.Errorf("sent %d requests, want 2", st.sent)
}
}
// ForAccount builds without a model, so a key can be verified and the
// model list read before a model is chosen, but keeps every other check.
func TestForAccount(t *testing.T) {
if _, err := ForAccount(llm.Settings{Provider: "openai"}, testKey); err != nil {
t.Errorf("ForAccount without a model = %v", err)
}
for _, c := range []struct {
s llm.Settings
key string
want error
}{
{llm.Settings{Provider: "gemini"}, testKey, llm.ErrUnknownProvider},
{llm.Settings{Provider: "openai"}, "", llm.ErrMissingKey},
{llm.Settings{Provider: "ollama"}, "", llm.ErrMissingBaseURL},
{llm.Settings{Provider: "openrouter"}, testKey, llm.ErrMissingPublicURL},
} {
if _, err := ForAccount(c.s, c.key); !errors.Is(err, c.want) {
t.Errorf("ForAccount(%s) = %v, want %v", c.s.Provider, err, c.want)
}
}
if _, err := FromSettings(llm.Settings{Provider: "openai"}, testKey); !errors.Is(err, llm.ErrMissingModel) {
t.Errorf("FromSettings without a model = %v, want ErrMissingModel", err)
}
}
func TestNames(t *testing.T) {
want := []string{"anthropic", "ollama", "openai", "openai_compatible", "openrouter", "xai"}
if got := Names(); !slices.Equal(got, want) {
t.Errorf("Names = %v, want %v", got, want)
}
}
+11
View File
@@ -35,6 +35,8 @@ const (
EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY"
EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ"
EnvSearXNGURL = "MEALQUEST_SEARXNG_URL"
)
// Session lifetime defaults (D-004): long enough that the kitchen tablet
@@ -73,6 +75,13 @@ type Config struct {
// validates it only while the settings do not exist yet, and ignores it
// once they do. Empty when unset.
DefaultTZ string
// SearXNGURL is the deployment's SearXNG instance, which the Settings
// screen suggests while no recipe-search URL is saved. It is optional
// and validated by recipes.NewSearchSettingsService: the compose
// network's http://searxng:8080 is neither https nor loopback, so the
// secure-URL rule does not apply. Empty when unset.
SearXNGURL string
}
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
@@ -107,6 +116,8 @@ func load(lookup func(string) (string, bool)) (Config, error) {
TrustedProxy: r.trustedProxy(EnvTrustedProxy),
DefaultTZ: r.optional(EnvDefaultTZ),
SearXNGURL: r.optional(EnvSearXNGURL),
}
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
+5
View File
@@ -422,6 +422,8 @@ func TestLoadValues(t *testing.T) {
EnvOIDCAllowedGroups: " mealquest-admin ,family",
EnvDefaultTZ: "Europe/London",
EnvSearXNGURL: "http://searxng:8080",
}
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
if err != nil {
@@ -480,6 +482,9 @@ func TestLoadValues(t *testing.T) {
if cfg.DefaultTZ != "Europe/London" {
t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ)
}
if cfg.SearXNGURL != "http://searxng:8080" {
t.Errorf("SearXNGURL = %q, want http://searxng:8080", cfg.SearXNGURL)
}
}
func TestSecretRedacted(t *testing.T) {
+32 -1
View File
@@ -106,13 +106,22 @@ func TestDinners(t *testing.T) {
Cuisine: str("Mexican"),
IsFavorite: 0,
RecipeID: str(recipe.ID),
RecipeUrl: str("https://example.com/tacos"),
PhotoID: str(photo.ID),
GroundingStatus: "grounded",
UpdatedBy: str("alex@example.com"),
CreatedAt: created,
UpdatedAt: created,
}
want := db.Dinner(create)
want := db.Dinner{
ID: create.ID, Name: create.Name, NormalizedName: create.NormalizedName,
Status: create.Status, Origin: create.Origin, Method: create.Method,
MinutesTotal: create.MinutesTotal, Difficulty: create.Difficulty,
Cuisine: create.Cuisine, IsFavorite: create.IsFavorite, RecipeID: create.RecipeID,
PhotoID: create.PhotoID, GroundingStatus: create.GroundingStatus,
UpdatedBy: create.UpdatedBy, CreatedAt: created, UpdatedAt: created,
RecipeUrl: create.RecipeUrl,
}
got, err := q.CreateDinner(ctx, create)
if err != nil {
t.Fatalf("CreateDinner: %v", err)
@@ -181,6 +190,7 @@ func TestDinners(t *testing.T) {
Difficulty: str("medium"),
Cuisine: sql.NullString{},
RecipeID: sql.NullString{},
RecipeUrl: str("https://example.com/tacos"),
PhotoID: str(photo.ID),
GroundingStatus: "failed",
UpdatedBy: str("sam@example.com"),
@@ -197,6 +207,7 @@ func TestDinners(t *testing.T) {
Cuisine: update.Cuisine, IsFavorite: 0, RecipeID: update.RecipeID,
PhotoID: update.PhotoID, GroundingStatus: update.GroundingStatus,
UpdatedBy: update.UpdatedBy, CreatedAt: created, UpdatedAt: updated,
RecipeUrl: create.RecipeUrl, // UpdateDinner leaves the recipe link
}
if got != want {
t.Fatalf("UpdateDinner returned %+v, want %+v", got, want)
@@ -227,6 +238,17 @@ func TestDinners(t *testing.T) {
_, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ID: "missing", IsFavorite: 1, UpdatedAt: updated})
mustNoRows(t, "SetDinnerFavorite missing", err)
for _, wantFav := range []int64{1, 0} {
got, err = q.ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{
ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: updated,
})
if err != nil || got.IsFavorite != wantFav || got.UpdatedBy != str("alex@example.com") {
t.Fatalf("ToggleDinnerFavorite = %+v, %v; want is_favorite %d", got, err, wantFav)
}
}
_, err = q.ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{ID: "missing", UpdatedAt: updated})
mustNoRows(t, "ToggleDinnerFavorite missing", err)
const archivedAt = "2026-10-05T12:00:00.000Z"
got, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{
ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: archivedAt,
@@ -240,6 +262,15 @@ func TestDinners(t *testing.T) {
}
assertIDs(t, "archived", listIDs("archived"), create.ID)
assertIDs(t, "tried after archive", listIDs("tried"))
unarchived, err := q.ListUnarchivedDinners(ctx)
if err != nil {
t.Fatalf("ListUnarchivedDinners: %v", err)
}
var unarchivedIDs []string
for _, d := range unarchived {
unarchivedIDs = append(unarchivedIDs, d.Dinner.ID)
}
assertIDs(t, "unarchived", unarchivedIDs, other.ID, third.ID)
_, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt})
mustNoRows(t, "ArchiveDinner missing", err)
}
+381 -23
View File
@@ -16,7 +16,7 @@ SET status = 'archived',
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
`
type ArchiveDinnerParams struct {
@@ -46,21 +46,37 @@ func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (D
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
const countRegularDinners = `-- name: CountRegularDinners :one
SELECT COUNT(*) FROM dinners
WHERE status = 'regular'
`
// How many Regulars the household has; archived dinners are not counted.
func (q *Queries) CountRegularDinners(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countRegularDinners)
var count int64
err := row.Scan(&count)
return count, err
}
const createDinner = `-- name: CreateDinner :one
INSERT INTO dinners (
id, name, normalized_name, status, origin, method, minutes_total,
difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status,
updated_by, created_at, updated_at
difficulty, cuisine, is_favorite, recipe_id, recipe_url, photo_id,
grounding_status, updated_by, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?,
?, ?, ?
?, ?, ?, ?
)
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
`
type CreateDinnerParams struct {
@@ -75,6 +91,7 @@ type CreateDinnerParams struct {
Cuisine sql.NullString
IsFavorite int64
RecipeID sql.NullString
RecipeUrl sql.NullString
PhotoID sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
@@ -95,6 +112,7 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din
arg.Cuisine,
arg.IsFavorite,
arg.RecipeID,
arg.RecipeUrl,
arg.PhotoID,
arg.GroundingStatus,
arg.UpdatedBy,
@@ -119,12 +137,85 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
const failOrphanedPendingDinnerGroundings = `-- name: FailOrphanedPendingDinnerGroundings :execrows
UPDATE dinners
SET grounding_status = 'failed',
grounding_error = ?1,
updated_at = ?2
WHERE grounding_status = 'pending'
AND status <> 'archived'
AND NOT EXISTS (
SELECT 1 FROM jobs
WHERE jobs.kind = ?3
AND jobs.status IN ('queued', 'running')
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
AND json_extract(jobs.payload, '$.revision') = dinners.grounding_revision
)
`
type FailOrphanedPendingDinnerGroundingsParams struct {
GroundingError sql.NullString
UpdatedAt string
Kind string
}
// Fails every unarchived dinner still pending whose grounding job for its
// current grounding_revision is no longer queued or running: the process
// stopped before the job, or before its handler recorded a result. An
// archived dinner is left as it is; archiving stopped its grounding. Run at
// boot, after the job runner fails the jobs a previous process left running.
func (q *Queries) FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failOrphanedPendingDinnerGroundings, arg.GroundingError, arg.UpdatedAt, arg.Kind)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const failPendingDinnerGrounding = `-- name: FailPendingDinnerGrounding :execrows
UPDATE dinners
SET grounding_status = 'failed',
grounding_error = ?,
updated_at = ?
WHERE id = ?
AND status <> 'archived'
AND grounding_status = 'pending'
AND grounding_revision = ?
`
type FailPendingDinnerGroundingParams struct {
GroundingError sql.NullString
UpdatedAt string
ID string
GroundingRevision int64
}
// Records why the background grounding of an unarchived dinner still
// pending at the grounding_revision it was grounded for failed; a dinner
// grounded some other way, renamed or archived meanwhile is left as it is
// (0 rows).
func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failPendingDinnerGrounding,
arg.GroundingError,
arg.UpdatedAt,
arg.ID,
arg.GroundingRevision,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getDinner = `-- name: GetDinner :one
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at FROM dinners
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
WHERE id = ?
`
@@ -148,12 +239,162 @@ func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) {
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
const groundDinner = `-- name: GroundDinner :one
UPDATE dinners
SET recipe_id = ?1,
recipe_url = NULL,
photo_id = ?2,
method = CASE WHEN method IS ?3
THEN ?4 ELSE method END,
minutes_total = CASE WHEN minutes_total IS ?5
THEN ?6 ELSE minutes_total END,
difficulty = CASE WHEN difficulty IS ?7
THEN ?8 ELSE difficulty END,
grounding_status = ?9,
grounding_error = NULL,
updated_by = ?10,
updated_at = ?11
WHERE id = ?12
AND status <> 'archived'
AND name = ?13
AND normalized_name = ?14
AND grounding_status = ?15
AND grounding_revision = ?16
AND recipe_id IS ?17
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
`
type GroundDinnerParams struct {
RecipeID sql.NullString
PhotoID sql.NullString
ObservedMethod sql.NullString
Method sql.NullString
ObservedMinutesTotal sql.NullInt64
MinutesTotal sql.NullInt64
ObservedDifficulty sql.NullString
Difficulty sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
UpdatedAt string
ID string
ObservedName string
ObservedNormalizedName string
ObservedGroundingStatus string
ObservedGroundingRevision int64
ObservedRecipeID sql.NullString
}
// Records a dinner's grounding by hand (grounded or manual) and clears any
// grounding_error and pasted recipe_url: the recipe chosen replaces the
// link. It writes only an unarchived dinner still as it was observed
// before grounding (same name, normalized_name, grounding_status,
// grounding_revision and recipe_id), so a dinner renamed, archived or
// grounded some other way meanwhile is left as it is. That dinner, or an
// unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
// no longer as observed is kept: a member edited it while it was grounding.
func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error) {
row := q.db.QueryRowContext(ctx, groundDinner,
arg.RecipeID,
arg.PhotoID,
arg.ObservedMethod,
arg.Method,
arg.ObservedMinutesTotal,
arg.MinutesTotal,
arg.ObservedDifficulty,
arg.Difficulty,
arg.GroundingStatus,
arg.UpdatedBy,
arg.UpdatedAt,
arg.ID,
arg.ObservedName,
arg.ObservedNormalizedName,
arg.ObservedGroundingStatus,
arg.ObservedGroundingRevision,
arg.ObservedRecipeID,
)
var i Dinner
err := row.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
UPDATE dinners
SET recipe_id = ?1,
photo_id = ?2,
method = COALESCE(method, ?3),
minutes_total = COALESCE(minutes_total, ?4),
difficulty = COALESCE(difficulty, ?5),
grounding_status = 'grounded',
grounding_error = NULL,
updated_at = ?6
WHERE id = ?7
AND status <> 'archived'
AND grounding_status = 'pending'
AND grounding_revision = ?8
`
type GroundPendingDinnerParams struct {
RecipeID sql.NullString
PhotoID sql.NullString
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
UpdatedAt string
ID string
GroundingRevision int64
}
// Records the background grounding of an unarchived dinner still pending at
// the grounding_revision it was grounded for; a dinner grounded some other
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
// minutes_total or difficulty already set is kept: a pending dinner has
// them only when a member edited them while it was grounding.
func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error) {
result, err := q.db.ExecContext(ctx, groundPendingDinner,
arg.RecipeID,
arg.PhotoID,
arg.Method,
arg.MinutesTotal,
arg.Difficulty,
arg.UpdatedAt,
arg.ID,
arg.GroundingRevision,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const listDinnersByStatus = `-- name: ListDinnersByStatus :many
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at FROM dinners
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
WHERE status = ?
ORDER BY normalized_name, id
`
@@ -184,6 +425,68 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listUnarchivedDinners = `-- name: ListUnarchivedDinners :many
SELECT dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision, recipes.source_url AS recipe_source_url
FROM dinners
LEFT JOIN recipes ON recipes.id = dinners.recipe_id
WHERE dinners.status <> 'archived'
ORDER BY dinners.id
`
type ListUnarchivedDinnersRow struct {
Dinner Dinner
RecipeSourceUrl sql.NullString
}
// Every dinner a new or renamed dinner must not repeat, with the
// source_url of the recipe it is grounded in (NULL while it has none).
func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error) {
rows, err := q.db.QueryContext(ctx, listUnarchivedDinners)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListUnarchivedDinnersRow
for rows.Next() {
var i ListUnarchivedDinnersRow
if err := rows.Scan(
&i.Dinner.ID,
&i.Dinner.Name,
&i.Dinner.NormalizedName,
&i.Dinner.Status,
&i.Dinner.Origin,
&i.Dinner.Method,
&i.Dinner.MinutesTotal,
&i.Dinner.Difficulty,
&i.Dinner.Cuisine,
&i.Dinner.IsFavorite,
&i.Dinner.RecipeID,
&i.Dinner.PhotoID,
&i.Dinner.GroundingStatus,
&i.Dinner.UpdatedBy,
&i.Dinner.CreatedAt,
&i.Dinner.UpdatedAt,
&i.Dinner.RecipeUrl,
&i.Dinner.GroundingError,
&i.Dinner.GroundingRevision,
&i.RecipeSourceUrl,
); err != nil {
return nil, err
}
@@ -204,7 +507,7 @@ SET is_favorite = ?,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
`
type SetDinnerFavoriteParams struct {
@@ -240,6 +543,52 @@ func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoritePa
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
const toggleDinnerFavorite = `-- name: ToggleDinnerFavorite :one
UPDATE dinners
SET is_favorite = 1 - is_favorite,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
`
type ToggleDinnerFavoriteParams struct {
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// An unknown id returns sql.ErrNoRows.
func (q *Queries) ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error) {
row := q.db.QueryRowContext(ctx, toggleDinnerFavorite, arg.UpdatedBy, arg.UpdatedAt, arg.ID)
var i Dinner
err := row.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
@@ -255,29 +604,33 @@ SET name = ?,
difficulty = ?,
cuisine = ?,
recipe_id = ?,
recipe_url = ?,
photo_id = ?,
grounding_status = ?,
grounding_revision = ?,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
`
type UpdateDinnerParams struct {
Name string
NormalizedName string
Status string
Origin string
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
Cuisine sql.NullString
RecipeID sql.NullString
PhotoID sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
UpdatedAt string
ID string
Name string
NormalizedName string
Status string
Origin string
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
Cuisine sql.NullString
RecipeID sql.NullString
RecipeUrl sql.NullString
PhotoID sql.NullString
GroundingStatus string
GroundingRevision int64
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// An unknown id returns sql.ErrNoRows.
@@ -292,8 +645,10 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
arg.Difficulty,
arg.Cuisine,
arg.RecipeID,
arg.RecipeUrl,
arg.PhotoID,
arg.GroundingStatus,
arg.GroundingRevision,
arg.UpdatedBy,
arg.UpdatedAt,
arg.ID,
@@ -316,6 +671,9 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
+19 -16
View File
@@ -51,22 +51,25 @@ type ChatSetting struct {
}
type Dinner struct {
ID string
Name string
NormalizedName string
Status string
Origin string
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
Cuisine sql.NullString
IsFavorite int64
RecipeID sql.NullString
PhotoID sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
CreatedAt string
UpdatedAt string
ID string
Name string
NormalizedName string
Status string
Origin string
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
Cuisine sql.NullString
IsFavorite int64
RecipeID sql.NullString
PhotoID sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
CreatedAt string
UpdatedAt string
RecipeUrl sql.NullString
GroundingError sql.NullString
GroundingRevision int64
}
type HouseholdSetting struct {
+36
View File
@@ -21,9 +21,14 @@ type Querier interface {
// Drops references to the runs created before the cutoff, so the purge
// can delete them.
ClearWeekPlanAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
// How many Regulars the household has; archived dinners are not counted.
CountRegularDinners(ctx context.Context) (int64, error)
CreateAIRun(ctx context.Context, arg CreateAIRunParams) error
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
CreateJob(ctx context.Context, arg CreateJobParams) error
// A recipe the household typed in. Its source_url is a mealquest:manual:
// URN rather than a page, so it never matches a fetched page.
CreateManualRecipe(ctx context.Context, arg CreateManualRecipeParams) (Recipe, error)
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
@@ -31,6 +36,17 @@ type Querier interface {
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
DeleteRecipeSteps(ctx context.Context, recipeID string) error
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
// Fails every unarchived dinner still pending whose grounding job for its
// current grounding_revision is no longer queued or running: the process
// stopped before the job, or before its handler recorded a result. An
// archived dinner is left as it is; archiving stopped its grounding. Run at
// boot, after the job runner fails the jobs a previous process left running.
FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error)
// Records why the background grounding of an unarchived dinner still
// pending at the grounding_revision it was grounded for failed; a dinner
// grounded some other way, renamed or archived meanwhile is left as it is
// (0 rows).
FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error)
// Fails every job left running, at boot before any worker starts.
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
// Returns sql.ErrNoRows until the row exists.
@@ -51,6 +67,21 @@ type Querier interface {
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
// Returns sql.ErrNoRows until the row exists.
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
// Records a dinner's grounding by hand (grounded or manual) and clears any
// grounding_error and pasted recipe_url: the recipe chosen replaces the
// link. It writes only an unarchived dinner still as it was observed
// before grounding (same name, normalized_name, grounding_status,
// grounding_revision and recipe_id), so a dinner renamed, archived or
// grounded some other way meanwhile is left as it is. That dinner, or an
// unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
// no longer as observed is kept: a member edited it while it was grounding.
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
// Records the background grounding of an unarchived dinner still pending at
// the grounding_revision it was grounded for; a dinner grounded some other
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
// minutes_total or difficulty already set is kept: a pending dinner has
// them only when a member edited them while it was grounding.
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
// The planned dinners dated from from_date up to but not including
// to_date, oldest first. Days with no dinner are left out.
@@ -60,12 +91,17 @@ type Querier interface {
ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error)
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
// Every dinner a new or renamed dinner must not repeat, with the
// source_url of the recipe it is grounded in (NULL while it has none).
ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error)
// Stores a partial result of a running job.
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
// An unknown id returns sql.ErrNoRows.
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
// An unknown id returns sql.ErrNoRows.
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
// An unknown id returns sql.ErrNoRows.
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
// Creates the single row (id 1) or replaces every column except id and
// created_at.
+61
View File
@@ -10,6 +10,67 @@ import (
"database/sql"
)
const createManualRecipe = `-- name: CreateManualRecipe :one
INSERT INTO recipes (
id, source_url, title, total_minutes, raw_jsonld, method,
minutes_total, fetched_at, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?,
?, ?, ?, ?
)
RETURNING id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at, photo_id, method, difficulty, minutes_total
`
type CreateManualRecipeParams struct {
ID string
SourceUrl string
Title string
TotalMinutes sql.NullInt64
RawJsonld string
Method sql.NullString
MinutesTotal sql.NullInt64
FetchedAt string
CreatedAt string
UpdatedAt string
}
// A recipe the household typed in. Its source_url is a mealquest:manual:
// URN rather than a page, so it never matches a fetched page.
func (q *Queries) CreateManualRecipe(ctx context.Context, arg CreateManualRecipeParams) (Recipe, error) {
row := q.db.QueryRowContext(ctx, createManualRecipe,
arg.ID,
arg.SourceUrl,
arg.Title,
arg.TotalMinutes,
arg.RawJsonld,
arg.Method,
arg.MinutesTotal,
arg.FetchedAt,
arg.CreatedAt,
arg.UpdatedAt,
)
var i Recipe
err := row.Scan(
&i.ID,
&i.SourceUrl,
&i.SiteName,
&i.Title,
&i.TotalMinutes,
&i.PrepMinutes,
&i.CookMinutes,
&i.Yield,
&i.RawJsonld,
&i.FetchedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.PhotoID,
&i.Method,
&i.Difficulty,
&i.MinutesTotal,
)
return i, err
}
const createRecipeIngredient = `-- name: CreateRecipeIngredient :exec
INSERT INTO recipe_ingredients (
id, recipe_id, position, raw_text, name, qty, unit, note,
+590
View File
@@ -0,0 +1,590 @@
// Package dinners keeps the household's dinners: adding Regulars by name or
// recipe link, listing Regulars and the Quest log, editing, archiving and
// favoriting, and grounding each dinner in a recipe. Names are deduplicated with the harness's dinner-name rules,
// so the Regulars the household types and the Side quests the AI suggests
// are compared the same way.
package dinners
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"math"
"net/url"
"slices"
"strings"
"sync"
"unicode"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// Dinner statuses (the dinners.status column).
const (
StatusSuggested = "suggested"
StatusQuestLog = "quest_log"
StatusTried = "tried"
StatusRegular = "regular"
StatusArchived = "archived"
)
// Limits on what a member may enter.
const (
MaxNameChars = 120
MaxRecipeURL = 2048
MaxMinutes = 1440 // a day; a crock-pot dinner fits
photoListWidth = 400 // the stored photo size lists show
)
// Methods and Difficulties are the values the dinners table allows.
var (
Methods = []string{"stovetop", "oven", "crockpot", "grill", "other"}
Difficulties = []string{"easy", "medium", "hard"}
)
// listable are the statuses List answers for.
var listable = []string{StatusRegular, StatusQuestLog}
// ErrNotFound is returned for an unknown dinner id.
var ErrNotFound = errors.New("dinner not found")
// Dinner is a dinner as the API shows it.
type Dinner struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Origin string `json:"origin"`
Method *string `json:"method"`
MinutesTotal *int64 `json:"minutes_total"`
Difficulty *string `json:"difficulty"`
Cuisine *string `json:"cuisine"`
IsFavorite bool `json:"is_favorite"`
RecipeID *string `json:"recipe_id"`
RecipeURL *string `json:"recipe_url"`
PhotoURL *string `json:"photo_url"`
GroundingStatus string `json:"grounding_status"`
// GroundingError is why grounding failed, one of the Reason codes;
// null unless GroundingStatus is failed.
GroundingError *string `json:"grounding_error"`
UpdatedBy *string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// NewRegular is a Regular to add: exactly one of Name and RecipeURL.
type NewRegular struct {
Name *string
RecipeURL *string
}
// Patch edits a dinner. Nil fields are left as they are.
type Patch struct {
Name *string
Method *string
MinutesTotal *int
Difficulty *string
}
// Problem is one invalid field, named by its JSON key.
type Problem struct {
Field string
Reason string
}
// ValidationError lists every invalid field of a request.
type ValidationError struct {
Problems []Problem
}
func (e *ValidationError) Error() string {
parts := make([]string, len(e.Problems))
for i, p := range e.Problems {
parts[i] = p.Field + ": " + p.Reason
}
return "invalid dinner: " + strings.Join(parts, "; ")
}
func invalid(field, reason string) *ValidationError {
return &ValidationError{Problems: []Problem{{Field: field, Reason: reason}}}
}
// DuplicateError is returned when a new or renamed dinner repeats an
// unarchived one: its name is harness.Similar, or it has the same recipe
// link.
type DuplicateError struct {
Existing Dinner
}
func (e *DuplicateError) Error() string {
return fmt.Sprintf("%q is already a dinner (%s)", e.Existing.Name, e.Existing.Status)
}
// Service reads and changes dinners.
type Service struct {
st *store.Store
grounder *recipes.Grounder
logger *slog.Logger
groundJob *jobs.Kind[GroundJob]
// groundingsMu guards groundings: the KindGroundDinner jobs running,
// by dinner id, so archiving or renaming a dinner can stop them.
groundingsMu sync.Mutex
groundings map[string]map[*runningGrounding]struct{}
}
// NewService returns a service over st that grounds dinners with grounder
// and registers the KindGroundDinner job on runner, which must not have
// started yet.
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("dinners: nil store")
case grounder == nil:
return nil, errors.New("dinners: nil grounder")
case runner == nil:
return nil, errors.New("dinners: nil job runner")
case logger == nil:
return nil, errors.New("dinners: nil logger")
}
s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{})}
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
if err != nil {
return nil, err
}
s.groundJob = kind
return s, nil
}
// Created is a new Regular with the id of the job grounding it, which is
// nil when the job could not be enqueued and the dinner's grounding is
// failed instead.
type Created struct {
Dinner
GroundingJobID *string `json:"grounding_job_id"`
}
// CreateRegular adds a Regular named by r.Name, or by the last path segment
// of r.RecipeURL (whose recipe grounding later replaces), with status and
// origin regular and grounding_status pending, recording updatedBy, and
// enqueues the KindGroundDinner job that grounds it. It returns without
// waiting for the job. A name repeating an unarchived dinner is a
// *DuplicateError. When the job cannot be enqueued, the stored dinner is
// returned with grounding_status failed (ReasonGroundingError) and no job
// id, so the household can fix its recipe by hand.
func (s *Service) CreateRegular(ctx context.Context, r NewRegular, updatedBy string) (Created, error) {
d, err := s.createRegular(ctx, r, updatedBy)
if err != nil {
return Created{}, err
}
jobID, err := s.enqueueGrounding(ctx, d.ID, d.GroundingRevision)
if err != nil {
return Created{}, err
}
if jobID == nil {
row, err := db.New(s.st.Reader).GetDinner(context.WithoutCancel(ctx), d.ID)
if err != nil {
return Created{}, fmt.Errorf("dinners: read %s: %w", d.ID, err)
}
return Created{Dinner: fromRow(row)}, nil
}
return Created{Dinner: fromRow(d), GroundingJobID: jobID}, nil
}
// enqueueGrounding enqueues the KindGroundDinner job for pending dinner id
// at grounding_revision revision and returns its id. When the job cannot be
// enqueued, the dinner's grounding is failed with ReasonGroundingError, so
// it is not left pending with no job, and the id is nil.
func (s *Service) enqueueGrounding(ctx context.Context, id string, revision int64) (*string, error) {
jobID, err := s.groundJob.Enqueue(ctx, GroundJob{DinnerID: id, Revision: revision})
if err == nil {
return &jobID, nil
}
// The error is a database error, never a dependency's text.
s.logger.ErrorContext(ctx, "dinner grounding not enqueued", "dinner_id", id, "error", err)
if _, err := db.New(s.st.Writer).FailPendingDinnerGrounding(context.WithoutCancel(ctx), db.FailPendingDinnerGroundingParams{
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
UpdatedAt: store.Now().String(),
ID: id,
GroundingRevision: revision,
}); err != nil {
return nil, fmt.Errorf("dinners: record failed grounding of %s: %w", id, err)
}
return nil, nil
}
func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy string) (db.Dinner, error) {
if updatedBy == "" {
return db.Dinner{}, errors.New("dinners: create: no member to record as updated_by")
}
var name string
var recipeURL sql.NullString
switch {
case r.Name != nil && r.RecipeURL != nil:
return db.Dinner{}, invalid("name", "give a name or a recipe_url, not both")
case r.Name != nil:
n, err := checkName(*r.Name)
if err != nil {
return db.Dinner{}, err
}
name = n
case r.RecipeURL != nil:
u, err := checkRecipeURL(*r.RecipeURL)
if err != nil {
return db.Dinner{}, err
}
n, ok := nameFromURL(u)
if !ok {
return db.Dinner{}, invalid("recipe_url", "the link does not name a dinner; add it by name instead")
}
name = n
recipeURL = sql.NullString{String: u.String(), Valid: true}
default:
return db.Dinner{}, invalid("name", "give a name or a recipe_url")
}
var row db.Dinner
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
if err := checkUnique(ctx, q, "", name, recipeURL); err != nil {
return err
}
now := store.Now().String()
var err error
row, err = q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: StatusRegular,
Origin: "regular",
RecipeUrl: recipeURL,
GroundingStatus: "pending",
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return fmt.Errorf("dinners: create: %w", err)
}
return nil
})
if err != nil {
return db.Dinner{}, err
}
return row, nil
}
// List returns the dinners with status, which must be regular or
// quest_log, ordered by normalized name.
func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
if !slices.Contains(listable, status) {
return nil, invalid("status", "must be one of "+strings.Join(listable, ", "))
}
rows, err := db.New(s.st.Reader).ListDinnersByStatus(ctx, status)
if err != nil {
return nil, fmt.Errorf("dinners: list %s: %w", status, err)
}
out := make([]Dinner, len(rows))
for i, row := range rows {
out[i] = fromRow(row)
}
return out, nil
}
// Update applies p to dinner id, recording updatedBy. An invalid patch is a
// *ValidationError and a new name repeating another unarchived dinner a
// *DuplicateError; either changes nothing. Renaming a dinner still pending
// clears its recipe_url, raises its grounding_revision and enqueues a fresh
// KindGroundDinner job that grounds the new name: a job queued or running
// for the old name or link carries the old revision, so it can no longer
// write the dinner, and one running is stopped.
func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: update: no member to record as updated_by")
}
name, err := p.validate()
if err != nil {
return Dinner{}, err
}
var row db.Dinner
var regrounding bool
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetDinner(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("dinners: read %s: %w", id, err)
}
params := db.UpdateDinnerParams{
ID: cur.ID,
Name: cur.Name,
NormalizedName: cur.NormalizedName,
Status: cur.Status,
Origin: cur.Origin,
Method: cur.Method,
MinutesTotal: cur.MinutesTotal,
Difficulty: cur.Difficulty,
Cuisine: cur.Cuisine,
RecipeID: cur.RecipeID,
RecipeUrl: cur.RecipeUrl,
PhotoID: cur.PhotoID,
GroundingStatus: cur.GroundingStatus,
GroundingRevision: cur.GroundingRevision,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
}
if p.Name != nil {
if err := checkUnique(ctx, q, cur.ID, name, sql.NullString{}); err != nil {
return err
}
params.Name = name
params.NormalizedName = harness.Normalize(name)
if cur.GroundingStatus == GroundingPending && params.NormalizedName != cur.NormalizedName {
// The link was the old dinner's recipe; the new name is
// grounded by name, and a link can be given by hand.
params.RecipeUrl = sql.NullString{}
params.GroundingRevision++
}
}
if p.Method != nil {
params.Method = sql.NullString{String: *p.Method, Valid: true}
}
if p.MinutesTotal != nil {
params.MinutesTotal = sql.NullInt64{Int64: int64(*p.MinutesTotal), Valid: true}
}
if p.Difficulty != nil {
params.Difficulty = sql.NullString{String: *p.Difficulty, Valid: true}
}
row, err = q.UpdateDinner(ctx, params)
if err != nil {
return fmt.Errorf("dinners: update %s: %w", id, err)
}
regrounding = row.GroundingRevision != cur.GroundingRevision
return nil
})
if err != nil {
return Dinner{}, err
}
if regrounding {
s.stopGroundings(row.ID, row.GroundingRevision)
jobID, err := s.enqueueGrounding(ctx, row.ID, row.GroundingRevision)
if err != nil {
return Dinner{}, err
}
if jobID == nil {
row, err = db.New(s.st.Reader).GetDinner(context.WithoutCancel(ctx), row.ID)
if err != nil {
return Dinner{}, fmt.Errorf("dinners: read %s: %w", id, err)
}
}
}
return fromRow(row), nil
}
// Archive sets dinner id's status to archived, recording updatedBy. The row
// is kept, so its name still counts for Side quest dedupe. A
// KindGroundDinner job running for it is stopped.
func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: archive: no member to record as updated_by")
}
row, err := db.New(s.st.Writer).ArchiveDinner(ctx, db.ArchiveDinnerParams{
ID: id,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
})
if errors.Is(err, sql.ErrNoRows) {
return Dinner{}, ErrNotFound
}
if err != nil {
return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err)
}
s.stopGroundings(row.ID, math.MaxInt64)
return fromRow(row), nil
}
// ToggleFavorite flips dinner id's is_favorite, recording updatedBy.
func (s *Service) ToggleFavorite(ctx context.Context, id, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: favorite: no member to record as updated_by")
}
row, err := db.New(s.st.Writer).ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{
ID: id,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
})
if errors.Is(err, sql.ErrNoRows) {
return Dinner{}, ErrNotFound
}
if err != nil {
return Dinner{}, fmt.Errorf("dinners: favorite %s: %w", id, err)
}
return fromRow(row), nil
}
// validate checks every field of p and returns its trimmed name.
func (p Patch) validate() (string, error) {
var problems []Problem
var name string
if p.Name == nil && p.Method == nil && p.MinutesTotal == nil && p.Difficulty == nil {
return "", invalid("patch", "no fields to change")
}
if p.Name != nil {
n, err := checkName(*p.Name)
var verr *ValidationError
if errors.As(err, &verr) {
problems = append(problems, verr.Problems...)
}
name = n
}
if p.Method != nil && !slices.Contains(Methods, *p.Method) {
problems = append(problems, Problem{"method", "must be one of " + strings.Join(Methods, ", ")})
}
if p.MinutesTotal != nil && (*p.MinutesTotal < 1 || *p.MinutesTotal > MaxMinutes) {
problems = append(problems, Problem{"minutes_total", fmt.Sprintf("must be 1 to %d", MaxMinutes)})
}
if p.Difficulty != nil && !slices.Contains(Difficulties, *p.Difficulty) {
problems = append(problems, Problem{"difficulty", "must be one of " + strings.Join(Difficulties, ", ")})
}
if problems != nil {
return "", &ValidationError{Problems: problems}
}
return name, nil
}
// checkName returns raw trimmed, refusing a name that is empty, too long or
// has no words left once normalized (so it could not be deduplicated).
func checkName(raw string) (string, error) {
name := strings.TrimSpace(raw)
switch {
case name == "":
return "", invalid("name", "must not be empty")
case utf8.RuneCountInString(name) > MaxNameChars:
return "", invalid("name", fmt.Sprintf("must be at most %d characters", MaxNameChars))
case harness.Normalize(name) == "":
return "", invalid("name", "must name a dish, not only filler words")
}
return name, nil
}
// checkRecipeURL parses raw as an absolute https:// link to a recipe page,
// the only kind recipe grounding fetches.
func checkRecipeURL(raw string) (*url.URL, error) {
raw = strings.TrimSpace(raw)
if len(raw) > MaxRecipeURL {
return nil, invalid("recipe_url", fmt.Sprintf("must be at most %d bytes", MaxRecipeURL))
}
u, err := url.Parse(raw)
switch {
case err != nil:
return nil, invalid("recipe_url", "is not a URL")
case u.Scheme != "https" || u.Hostname() == "":
return nil, invalid("recipe_url", "must be an https:// link")
case u.User != nil:
return nil, invalid("recipe_url", "must not carry a user name or password")
}
return u, nil
}
// nameFromURL names a dinner from the last path segment of u: separators
// become spaces, a file extension and words of only digits are dropped and
// the first letter is capitalized, so
// https://example.com/recipes/12345/chicken-tikka-masala/ is
// "Chicken tikka masala". ok is false when no usable name is left.
func nameFromURL(u *url.URL) (name string, ok bool) {
segments := strings.FieldsFunc(u.Path, func(r rune) bool { return r == '/' })
if len(segments) == 0 {
return "", false
}
slug := segments[len(segments)-1]
if i := strings.LastIndexByte(slug, '.'); i > 0 {
slug = slug[:i]
}
var words []string
for _, w := range strings.FieldsFunc(slug, func(r rune) bool { return r == '-' || r == '_' || r == '+' || unicode.IsSpace(r) }) {
if strings.IndexFunc(w, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
words = append(words, strings.ToLower(w))
}
}
name = strings.Join(words, " ")
if r, size := utf8.DecodeRuneInString(name); r != utf8.RuneError {
name = string(unicode.ToUpper(r)) + name[size:]
}
if _, err := checkName(name); err != nil {
return "", false
}
return name, true
}
// checkUnique returns a *DuplicateError when name is harness.Similar to an
// unarchived dinner other than self, or recipeURL is one's recipe link: the
// source_url of the recipe it is grounded in, else the recipe_url still
// awaiting grounding.
func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) error {
rows, err := q.ListUnarchivedDinners(ctx)
if err != nil {
return fmt.Errorf("dinners: list for dedupe: %w", err)
}
for _, row := range rows {
if row.Dinner.ID == self {
continue
}
link := row.Dinner.RecipeUrl
if row.RecipeSourceUrl.Valid {
link = row.RecipeSourceUrl
}
if harness.Similar(name, row.Dinner.Name) || (recipeURL.Valid && link == recipeURL) {
return &DuplicateError{Existing: fromRow(row.Dinner)}
}
}
return nil
}
// PhotoURL is where the API serves a list-sized copy of photo id.
func PhotoURL(id string) string {
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, photoListWidth)
}
func fromRow(r db.Dinner) Dinner {
d := Dinner{
ID: r.ID,
Name: r.Name,
Status: r.Status,
Origin: r.Origin,
Method: nullString(r.Method),
Difficulty: nullString(r.Difficulty),
Cuisine: nullString(r.Cuisine),
IsFavorite: r.IsFavorite == 1,
RecipeID: nullString(r.RecipeID),
RecipeURL: nullString(r.RecipeUrl),
GroundingStatus: r.GroundingStatus,
GroundingError: nullString(r.GroundingError),
UpdatedBy: nullString(r.UpdatedBy),
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
if r.MinutesTotal.Valid {
d.MinutesTotal = &r.MinutesTotal.Int64
}
if r.PhotoID.Valid {
u := PhotoURL(r.PhotoID.String)
d.PhotoURL = &u
}
return d
}
func nullString(s sql.NullString) *string {
if !s.Valid {
return nil
}
return &s.String
}
+33
View File
@@ -0,0 +1,33 @@
package dinners
import (
"net/url"
"testing"
)
func TestNameFromURL(t *testing.T) {
t.Parallel()
tests := []struct {
link string
want string // "" means no usable name
}{
{"https://example.com/recipes/12345/chicken-tikka-masala/", "Chicken tikka masala"},
{"https://example.com/recipe/beef_stew.html", "Beef stew"},
{"https://example.com/2024/05/best-ever-chili-123", "Best ever chili"},
{"https://example.com/crème-brûlée", "Crème brûlée"},
{"https://example.com/recipes/chili?utm=1#notes", "Chili"},
{"https://example.com/", ""},
{"https://example.com/recipe/98765", ""},
{"https://example.com/the-best-easy-recipe", ""},
}
for _, tt := range tests {
u, err := url.Parse(tt.link)
if err != nil {
t.Fatal(err)
}
got, ok := nameFromURL(u)
if got != tt.want || ok != (tt.want != "") {
t.Errorf("nameFromURL(%s) = %q, %v; want %q", tt.link, got, ok, tt.want)
}
}
}
+23
View File
@@ -0,0 +1,23 @@
package dinners
import (
"context"
"testing"
"time"
)
// SetGroundTimeout sets the deadline of grounding by hand until t ends. A
// test calling it must not run in parallel.
func SetGroundTimeout(t *testing.T, d time.Duration) {
old := groundTimeout
groundTimeout = d
t.Cleanup(func() { groundTimeout = old })
}
// RunGroundJob runs the KindGroundDinner handler for p in the caller's
// goroutine, as the job runner would, and returns the grounding status it
// reports.
func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error) {
r, err := s.runGroundJob(ctx, p, nil)
return r.GroundingStatus, err
}
+635
View File
@@ -0,0 +1,635 @@
package dinners
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// KindGroundDinner is the job that grounds a new Regular in a recipe.
const KindGroundDinner = "ground_dinner"
// Grounding statuses (the dinners.grounding_status column).
const (
GroundingPending = "pending"
GroundingGrounded = "grounded"
GroundingFailed = "failed"
GroundingManual = "manual"
)
// Reasons a dinner's background grounding failed, stored in
// dinners.grounding_error and as the KindGroundDinner job's failure code.
const (
// ReasonNoRecipe: no page the search found holds a usable recipe.
ReasonNoRecipe = "no_recipe_found"
// ReasonRecipeUnusable: the dinner's recipe_url holds no usable recipe.
ReasonRecipeUnusable = "recipe_unusable"
// ReasonSearchNotConfigured: recipe search is not set up.
ReasonSearchNotConfigured = "search_not_configured"
// ReasonSearchFailed: the search provider failed.
ReasonSearchFailed = "search_failed"
// ReasonAIFailed: normalize_recipe's model provider could not be built
// or called.
ReasonAIFailed = "ai_failed"
// ReasonInterrupted: the server stopped while grounding.
ReasonInterrupted = "interrupted"
// ReasonGroundingError: any other failure, such as the database's.
ReasonGroundingError = "grounding_error"
)
// Reasons are every grounding failure reason.
var Reasons = []string{
ReasonNoRecipe, ReasonRecipeUnusable, ReasonSearchNotConfigured, ReasonSearchFailed,
ReasonAIFailed, ReasonInterrupted, ReasonGroundingError,
}
// Limits on candidates and on a recipe entered by hand.
const (
MaxCandidates = 3
MaxManualLines = 100
MaxManualLineChar = 500
// manualSourcePrefix starts the source_url of a recipe entered by hand,
// followed by the recipe's id; it is never a fetchable page.
manualSourcePrefix = "mealquest:manual:"
)
// GroundTimeout bounds grounding a candidate or link by hand, which runs in
// the request: the page fetch (recipes.Timeout), its photo download and
// normalize_recipe (harness.DefaultTimeout), with some slack.
const GroundTimeout = 90 * time.Second
// groundTimeout is GroundTimeout; tests shorten it.
var groundTimeout = GroundTimeout
// ErrGroundTimeout is returned by Ground when grounding a candidate or link
// runs past GroundTimeout; the dinner is left as it was.
var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long")
// ErrGroundFailed wraps any unexpected failure grounding a dinner by hand.
// Its text may quote the link and dependency errors, so it is never
// logged.
var ErrGroundFailed = errors.New("dinners: grounding the recipe failed")
// ErrArchived is grounding an archived dinner by hand.
var ErrArchived = errors.New("dinner archived")
// ErrGroundConflict is a candidate or link grounded for a dinner that was
// renamed or grounded some other way while it was grounding.
var ErrGroundConflict = errors.New("dinner changed while its recipe was grounding")
// GroundJob is the KindGroundDinner payload: the dinner to ground and its
// grounding_revision when the job was enqueued.
type GroundJob struct {
DinnerID string `json:"dinner_id"`
Revision int64 `json:"revision"`
}
// groundResult is the KindGroundDinner result: the dinner's grounding
// status once the job ends. It is not grounded by this job when the dinner
// was grounded by hand before the job finished, renamed (a later job
// grounds it) or archived; the job then reports the status it found.
type groundResult struct {
DinnerID string `json:"dinner_id"`
GroundingStatus string `json:"grounding_status"`
}
func (r groundResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// runGroundJob grounds a pending dinner as its creation asked: its
// recipe_url with GroundURL, else its name with Ground. Success records
// the recipe and photo with grounding_status grounded, and the recipe's
// method, minutes and difficulty where a member has not set them; failure
// records grounding_status failed with its Reason, which is also the job's
// failure code. A dinner archived, no longer pending, or at a
// grounding_revision other than p.Revision is not grounded at all, and
// either result is recorded only while the dinner is still unarchived,
// pending and at p.Revision: a dinner grounded by hand meanwhile is left as
// it is, and one renamed or archived meanwhile is left pending — for the
// job its rename enqueued, or for none. Archiving or renaming the dinner
// stops the grounding where it is, so no further search, fetch, photo
// download or model call is made for it.
func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundResult) error) (groundResult, error) {
// Tracked before the dinner is read: an archive or rename committed
// after the read finds this job to stop.
groundCtx, done := s.trackGrounding(ctx, p.DinnerID, p.Revision)
defer done()
row, err := db.New(s.st.Reader).GetDinner(ctx, p.DinnerID)
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", p.DinnerID, err)
}
if row.Status == StatusArchived || row.GroundingStatus != GroundingPending || row.GroundingRevision != p.Revision {
return groundResult{DinnerID: row.ID, GroundingStatus: row.GroundingStatus}, nil
}
var gr recipes.Grounded
if row.RecipeUrl.Valid {
gr, err = s.grounder.GroundURL(groundCtx, row.RecipeUrl.String)
} else {
gr, err = s.grounder.Ground(groundCtx, row.Name, row.Name)
}
if err != nil && ctx.Err() == nil && errors.Is(context.Cause(groundCtx), errGroundingStopped) {
return s.currentGrounding(ctx, row.ID)
}
if err != nil {
reason := reasonFor(err, row.RecipeUrl.Valid)
if ctx.Err() != nil {
reason = ReasonInterrupted
}
// Only the reason is logged: the error may quote a dependency's
// response, which jobs never store, return or log.
s.logger.InfoContext(ctx, "dinner grounding failed", "dinner_id", row.ID, "reason", reason)
write := context.WithoutCancel(ctx)
if _, failErr := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{
GroundingError: sql.NullString{String: reason, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
GroundingRevision: p.Revision,
}); failErr != nil {
return groundResult{}, fmt.Errorf("dinners: ground: record failure of %s: %w", row.ID, failErr)
}
if ctx.Err() != nil {
return groundResult{}, ctx.Err()
}
return groundResult{}, &jobs.Failure{Code: reason}
}
n, err := db.New(s.st.Writer).GroundPendingDinner(ctx, db.GroundPendingDinnerParams{
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
Method: sql.NullString{String: gr.Method, Valid: true},
MinutesTotal: nullInt(gr.MinutesTotal),
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
GroundingRevision: p.Revision,
})
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: record %s: %w", row.ID, err)
}
if n == 0 {
return s.currentGrounding(ctx, row.ID)
}
return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil
}
// currentGrounding is the KindGroundDinner result of a job that did not
// ground dinner id: the grounding status the dinner has now.
func (s *Service) currentGrounding(ctx context.Context, id string) (groundResult, error) {
cur, err := db.New(s.st.Reader).GetDinner(ctx, id)
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", id, err)
}
return groundResult{DinnerID: cur.ID, GroundingStatus: cur.GroundingStatus}, nil
}
// errGroundingStopped is the cause a running KindGroundDinner job's
// grounding is cancelled with when its dinner is archived or renamed.
var errGroundingStopped = errors.New("dinners: grounding stopped: dinner archived or renamed")
// runningGrounding is a KindGroundDinner job running for a dinner at
// revision, with the cancel of its grounding.
type runningGrounding struct {
revision int64
cancel context.CancelCauseFunc
}
// trackGrounding returns a context for grounding dinnerID at revision that
// stopGroundings cancels, and the func to call when the job ends.
func (s *Service) trackGrounding(ctx context.Context, dinnerID string, revision int64) (context.Context, func()) {
groundCtx, cancel := context.WithCancelCause(ctx)
r := &runningGrounding{revision: revision, cancel: cancel}
s.groundingsMu.Lock()
if s.groundings[dinnerID] == nil {
s.groundings[dinnerID] = make(map[*runningGrounding]struct{})
}
s.groundings[dinnerID][r] = struct{}{}
s.groundingsMu.Unlock()
return groundCtx, func() {
s.groundingsMu.Lock()
delete(s.groundings[dinnerID], r)
if len(s.groundings[dinnerID]) == 0 {
delete(s.groundings, dinnerID)
}
s.groundingsMu.Unlock()
cancel(nil)
}
}
// stopGroundings cancels, with errGroundingStopped, the grounding of every
// KindGroundDinner job running for dinnerID at a revision below below. Call
// it once the change that makes those jobs stale is committed.
func (s *Service) stopGroundings(dinnerID string, below int64) {
s.groundingsMu.Lock()
defer s.groundingsMu.Unlock()
for r := range s.groundings[dinnerID] {
if r.revision < below {
r.cancel(errGroundingStopped)
}
}
}
// FailInterruptedGroundings fails, with ReasonInterrupted, every unarchived
// dinner still pending whose KindGroundDinner job is no longer queued or
// running: a previous process stopped before enqueuing the job or before
// its handler recorded a result. An archived dinner is left pending, since
// archiving, not a restart, stopped its grounding. Call it once at boot, after the job runner's
// Start has failed the jobs that process left running, so no dinner stays
// pending with nothing left to ground it. It returns how many it failed.
func (s *Service) FailInterruptedGroundings(ctx context.Context) (int64, error) {
n, err := db.New(s.st.Writer).FailOrphanedPendingDinnerGroundings(ctx, db.FailOrphanedPendingDinnerGroundingsParams{
GroundingError: sql.NullString{String: ReasonInterrupted, Valid: true},
UpdatedAt: store.Now().String(),
Kind: KindGroundDinner,
})
if err != nil {
return 0, fmt.Errorf("dinners: fail interrupted groundings: %w", err)
}
if n > 0 {
s.logger.WarnContext(ctx, "failed dinner groundings interrupted by restart", "count", n)
}
return n, nil
}
// reasonFor is the Reason for a grounding error; fromURL says whether the
// dinner's recipe_url was grounded rather than its name.
func reasonFor(err error, fromURL bool) string {
var ungroundable *recipes.ErrUngroundable
switch {
case errors.As(err, &ungroundable) && fromURL:
return ReasonRecipeUnusable
case errors.As(err, &ungroundable):
return ReasonNoRecipe
case errors.Is(err, recipes.ErrSearchNotConfigured):
return ReasonSearchNotConfigured
case errors.Is(err, recipes.ErrSearchFailed):
return ReasonSearchFailed
case errors.Is(err, recipes.ErrNormalizeFailed):
return ReasonAIFailed
}
return ReasonGroundingError
}
// Candidate is a recipe page offered to ground a dinner in.
type Candidate struct {
// CandidateID is the page's URL; Fix.CandidateID grounds it.
CandidateID string `json:"candidate_id"`
Title string `json:"title"`
SiteName string `json:"site_name"`
// PhotoURL is the cached photo of a page already grounded, else null.
PhotoURL *string `json:"photo_url"`
Minutes *int `json:"minutes"`
}
// Candidates searches for dinner id's name and returns up to MaxCandidates
// recipe pages to ground it in, best first; the list may be empty. A
// search that fails wraps recipes.ErrSearchFailed.
func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error) {
row, err := s.get(ctx, id)
if err != nil {
return nil, err
}
offers, err := s.grounder.Candidates(ctx, row.Name, row.Name, MaxCandidates)
if err != nil {
return nil, err
}
out := make([]Candidate, len(offers))
for i, o := range offers {
out[i] = Candidate{CandidateID: o.URL, Title: o.Title, SiteName: o.SiteName, Minutes: o.Minutes}
if o.PhotoID != "" {
u := PhotoURL(o.PhotoID)
out[i].PhotoURL = &u
}
}
return out, nil
}
// Fix grounds a dinner by hand: exactly one of a candidate from
// Candidates, a pasted recipe link or a recipe typed in.
type Fix struct {
CandidateID *string
RecipeURL *string
Manual *ManualRecipe
}
// ManualRecipe is a recipe typed in by the household.
type ManualRecipe struct {
// Ingredients are the ingredient lines, at least one.
Ingredients []string
// Steps are the instructions, in order; there may be none.
Steps []string
Minutes *int
Method *string
}
// Ground grounds dinner id as f says, recording updatedBy. A candidate or
// link is grounded with recipes.Grounder.GroundURL and sets
// grounding_status grounded, with the recipe's photo, method, minutes and
// difficulty; one a member changed while it was grounding is kept. A
// manual recipe is stored as typed — no model runs — and sets
// grounding_status manual with its method and minutes; it has no photo,
// and the dinner keeps its difficulty. An invalid f is a *ValidationError
// and an archived dinner ErrArchived. A failure grounding a candidate or
// link, or recording it on the dinner, wraps ErrGroundFailed; a page that
// cannot ground the dinner is also a *recipes.ErrUngroundable. A dinner
// renamed, archived or grounded some other way while a candidate or link
// was grounding is ErrGroundConflict or ErrArchived. A missing dinner is
// ErrNotFound; any other failure, by candidate, link or manual recipe,
// wraps ErrGroundFailed. None of these changes the dinner.
func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: ground: no member to record as updated_by")
}
link, manual, err := f.validate()
if err != nil {
return Dinner{}, err
}
if manual != nil {
return s.groundManual(ctx, id, *manual, updatedBy)
}
cur, err := s.get(ctx, id)
if errors.Is(err, ErrNotFound) {
return Dinner{}, err
}
if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
}
if cur.Status == StatusArchived {
return Dinner{}, ErrArchived
}
groundCtx, cancel := context.WithTimeout(ctx, groundTimeout)
defer cancel()
gr, err := s.grounder.GroundURL(groundCtx, link)
if err != nil {
if ctx.Err() == nil && errors.Is(groundCtx.Err(), context.DeadlineExceeded) {
return Dinner{}, ErrGroundTimeout
}
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
}
row, err := db.New(s.st.Writer).GroundDinner(ctx, db.GroundDinnerParams{
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
Method: sql.NullString{String: gr.Method, Valid: true},
MinutesTotal: nullInt(gr.MinutesTotal),
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
GroundingStatus: GroundingGrounded,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
ID: cur.ID,
ObservedName: cur.Name,
ObservedNormalizedName: cur.NormalizedName,
ObservedGroundingStatus: cur.GroundingStatus,
ObservedGroundingRevision: cur.GroundingRevision,
ObservedRecipeID: cur.RecipeID,
ObservedMethod: cur.Method,
ObservedMinutesTotal: cur.MinutesTotal,
ObservedDifficulty: cur.Difficulty,
})
if errors.Is(err, sql.ErrNoRows) {
return Dinner{}, s.groundRejected(ctx, id)
}
if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
}
return fromRow(row), nil
}
// groundRejected is the error for dinner id when it was not as observed
// before grounding: ErrNotFound, ErrArchived or ErrGroundConflict. Failing
// to read it wraps ErrGroundFailed.
func (s *Service) groundRejected(ctx context.Context, id string) error {
cur, err := s.get(ctx, id)
if errors.Is(err, ErrNotFound) {
return err
}
if err != nil {
return fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
}
if cur.Status == StatusArchived {
return ErrArchived
}
return ErrGroundConflict
}
// groundManual stores m as dinner id's recipe, with its ingredients and
// steps, and records it on the dinner, all in one transaction. A missing
// or archived dinner is ErrNotFound or ErrArchived; any other failure
// wraps ErrGroundFailed.
func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, updatedBy string) (Dinner, error) {
var row db.Dinner
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetDinner(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("dinners: read %s: %w", id, err)
}
if cur.Status == StatusArchived {
return ErrArchived
}
ld, err := json.Marshal(manualJSONLD{
Context: "https://schema.org",
Type: "Recipe",
Name: cur.Name,
Ingredients: m.Ingredients,
Instructions: m.Steps,
TotalTime: fmt.Sprintf("PT%dM", *m.Minutes),
})
if err != nil {
return fmt.Errorf("dinners: encode manual recipe: %w", err)
}
now := store.Now().String()
recipeID := store.NewID()
minutes := sql.NullInt64{Int64: int64(*m.Minutes), Valid: true}
method := sql.NullString{String: *m.Method, Valid: true}
rec, err := q.CreateManualRecipe(ctx, db.CreateManualRecipeParams{
ID: recipeID,
SourceUrl: manualSourcePrefix + recipeID,
Title: cur.Name,
TotalMinutes: minutes,
RawJsonld: string(ld),
Method: method,
MinutesTotal: minutes,
FetchedAt: now,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return fmt.Errorf("dinners: store manual recipe: %w", err)
}
for i, line := range m.Ingredients {
if err := q.CreateRecipeIngredient(ctx, db.CreateRecipeIngredientParams{
ID: store.NewID(),
RecipeID: rec.ID,
Position: int64(i),
RawText: line,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("dinners: store manual ingredient %d: %w", i+1, err)
}
}
for i, text := range m.Steps {
if err := q.CreateRecipeStep(ctx, db.CreateRecipeStepParams{
ID: store.NewID(),
RecipeID: rec.ID,
Position: int64(i),
Text: text,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("dinners: store manual step %d: %w", i+1, err)
}
}
row, err = q.GroundDinner(ctx, db.GroundDinnerParams{
RecipeID: sql.NullString{String: rec.ID, Valid: true},
Method: method,
MinutesTotal: minutes,
Difficulty: cur.Difficulty,
GroundingStatus: GroundingManual,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: now,
ID: cur.ID,
ObservedName: cur.Name,
ObservedNormalizedName: cur.NormalizedName,
ObservedGroundingStatus: cur.GroundingStatus,
ObservedGroundingRevision: cur.GroundingRevision,
ObservedRecipeID: cur.RecipeID,
ObservedMethod: cur.Method,
ObservedMinutesTotal: cur.MinutesTotal,
ObservedDifficulty: cur.Difficulty,
})
if err != nil {
return fmt.Errorf("dinners: ground %s: %w", id, err)
}
return nil
})
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrArchived) {
return Dinner{}, err
}
if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
}
return fromRow(row), nil
}
// manualJSONLD is the schema.org Recipe stored as a manual recipe's
// raw_jsonld, so it reads like a fetched page's.
type manualJSONLD struct {
Context string `json:"@context"`
Type string `json:"@type"`
Name string `json:"name"`
Ingredients []string `json:"recipeIngredient"`
Instructions []string `json:"recipeInstructions"`
TotalTime string `json:"totalTime"`
}
// validate checks f and returns the link to ground, or the manual recipe
// with its lines trimmed.
func (f Fix) validate() (link string, manual *ManualRecipe, err error) {
given := 0
for _, set := range []bool{f.CandidateID != nil, f.RecipeURL != nil, f.Manual != nil} {
if set {
given++
}
}
if given != 1 {
return "", nil, invalid("ground", "give exactly one of candidate_id, recipe_url and manual")
}
switch {
case f.CandidateID != nil:
u, err := checkRecipeURL(*f.CandidateID)
if err != nil {
return "", nil, invalid("candidate_id", "is not a candidate from recipe-candidates")
}
return u.String(), nil, nil
case f.RecipeURL != nil:
u, err := checkRecipeURL(*f.RecipeURL)
if err != nil {
return "", nil, err
}
return u.String(), nil, nil
}
m, err := f.Manual.validate()
if err != nil {
return "", nil, err
}
return "", &m, nil
}
// validate checks every field of m and returns it with its lines trimmed.
func (m ManualRecipe) validate() (ManualRecipe, error) {
var problems []Problem
ings, ingProblem := checkLines(m.Ingredients, 1)
if ingProblem != "" {
problems = append(problems, Problem{"manual.ingredients", ingProblem})
}
steps, stepProblem := checkLines(m.Steps, 0)
if stepProblem != "" {
problems = append(problems, Problem{"manual.steps", stepProblem})
}
if m.Minutes == nil || *m.Minutes < 1 || *m.Minutes > MaxMinutes {
problems = append(problems, Problem{"manual.minutes", fmt.Sprintf("must be 1 to %d", MaxMinutes)})
}
if m.Method == nil || !slices.Contains(Methods, *m.Method) {
problems = append(problems, Problem{"manual.method", "must be one of " + strings.Join(Methods, ", ")})
}
if problems != nil {
return ManualRecipe{}, &ValidationError{Problems: problems}
}
return ManualRecipe{Ingredients: ings, Steps: steps, Minutes: m.Minutes, Method: m.Method}, nil
}
// checkLines returns lines trimmed, or why they are refused: fewer than
// least, more than MaxManualLines, or one blank or over MaxManualLineChar.
func checkLines(lines []string, least int) ([]string, string) {
if len(lines) < least {
return nil, fmt.Sprintf("must have at least %d line", least)
}
if len(lines) > MaxManualLines {
return nil, fmt.Sprintf("must have at most %d lines", MaxManualLines)
}
out := make([]string, len(lines))
for i, line := range lines {
line = strings.TrimSpace(line)
switch {
case line == "":
return nil, fmt.Sprintf("line %d is blank", i+1)
case utf8.RuneCountInString(line) > MaxManualLineChar:
return nil, fmt.Sprintf("line %d is over %d characters", i+1, MaxManualLineChar)
}
out[i] = line
}
return out, ""
}
// get reads dinner id, or ErrNotFound.
func (s *Service) get(ctx context.Context, id string) (db.Dinner, error) {
row, err := db.New(s.st.Reader).GetDinner(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return db.Dinner{}, ErrNotFound
}
if err != nil {
return db.Dinner{}, fmt.Errorf("dinners: read %s: %w", id, err)
}
return row, nil
}
func nullInt(v *int) sql.NullInt64 {
if v == nil {
return sql.NullInt64{}
}
return sql.NullInt64{Int64: int64(*v), Valid: true}
}
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -11,7 +11,8 @@ import (
)
// DefaultPersonaPrompt is the default AI system prompt (CTXD PROMPTS.MD §4).
// SeedAISettings stores it on first boot and the Admin reset restores it.
// SeedAISettings stores it on first boot and aisettings.Service.ResetPrompt
// restores it.
const DefaultPersonaPrompt = `You are MealQuest, a dinner planning assistant for one household. Suggest dinners
similar to their regular rotation, nudging them to try something a little new each
week. Prefer weeknight-friendly recipes with common grocery-store ingredients.
+28 -1
View File
@@ -68,8 +68,18 @@ type SettingsPatch struct {
SuggestionsTime *string
ChatAutosendDow *int
ChatAutosendTime *string
// OnboardingCompleted, when true, records that the household finished
// onboarding: onboarding_completed_at becomes the time of the update,
// unless it is already set, when the first time is kept. Onboarding is
// never undone, so false is invalid, and it cannot first be completed
// with fewer than MinRegulars Regulars.
OnboardingCompleted *bool
}
// MinRegulars is how many Regulars the household must have to complete
// onboarding.
const MinRegulars = 3
// Problem is one invalid field of a SettingsPatch, named by its JSON key.
type Problem struct {
Field string
@@ -162,7 +172,8 @@ func (s *SettingsService) Get(ctx context.Context) (Settings, error) {
// Update validates patch, applies it, records updatedBy (the signed-in
// member's subject, which must not be empty) and publishes the new settings
// to every subscriber. An invalid patch is a *ValidationError and changes
// nothing.
// nothing, as does completing onboarding with fewer than MinRegulars
// Regulars.
func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updatedBy string) (Settings, error) {
if updatedBy == "" {
return Settings{}, errors.New("household: update settings: no member to record as updated_by")
@@ -208,6 +219,19 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
setString(&p.SuggestionsTime, patch.SuggestionsTime)
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid {
n, err := q.CountRegularDinners(ctx)
if err != nil {
return fmt.Errorf("household: count Regulars: %w", err)
}
if n < MinRegulars {
return &ValidationError{Problems: []Problem{{
Field: "onboarding_completed",
Reason: fmt.Sprintf("add at least %d Regulars first (have %d)", MinRegulars, n),
}}}
}
p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true}
}
row, err = q.UpsertHouseholdSettings(ctx, p)
if err != nil {
return fmt.Errorf("household: update settings: %w", err)
@@ -291,6 +315,9 @@ func (p SettingsPatch) validate() error {
fail(d.field, fmt.Sprintf("invalid day of the week %d (want 0-6, Sunday = 0)", *d.value))
}
}
if p.OnboardingCompleted != nil && !*p.OnboardingCompleted {
fail("onboarding_completed", "onboarding cannot be undone (want true)")
}
if len(problems) > 0 {
return &ValidationError{Problems: problems}
}
+91 -1
View File
@@ -3,10 +3,13 @@ package household
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"sync"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
@@ -34,12 +37,34 @@ func migratedStore(t *testing.T, dir string) *store.Store {
// seededService returns a settings service over a migrated temp database
// seeded with zone America/New_York.
func seededService(t *testing.T) *SettingsService {
t.Helper()
svc, _ := seededServiceStore(t)
return svc
}
// seededServiceStore is seededService with its store.
func seededServiceStore(t *testing.T) (*SettingsService, *store.Store) {
t.Helper()
st := migratedStore(t, t.TempDir())
if err := SeedSettings(context.Background(), st, "America/New_York"); err != nil {
t.Fatal(err)
}
return NewSettingsService(st)
return NewSettingsService(st), st
}
// addDinners stores n dinners with status.
func addDinners(t *testing.T, st *store.Store, status string, n int) {
t.Helper()
for range n {
id := store.NewID()
now := store.Now().String()
if _, err := db.New(st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
ID: id, Name: "Dinner " + id, NormalizedName: "dinner " + strings.ToLower(id),
Status: status, Origin: "regular", GroundingStatus: "pending", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
}
}
func ptr[T any](v T) *T { return &v }
@@ -217,6 +242,70 @@ func TestUpdateAppliesPatchAndRecordsUpdatedBy(t *testing.T) {
}
}
func TestUpdateCompletesOnboardingOnce(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc, st := seededServiceStore(t)
addDinners(t, st, "regular", MinRegulars)
got, err := svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "sam")
if err != nil {
t.Fatal(err)
}
if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != got.UpdatedAt {
t.Fatalf("onboarding_completed_at = %v, want the update time %s", got.OnboardingCompletedAt, got.UpdatedAt)
}
first := *got.OnboardingCompletedAt
// Completing again keeps the first time.
got, err = svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "alex")
if err != nil {
t.Fatal(err)
}
if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != first {
t.Errorf("second completion: onboarding_completed_at = %v, want %s", got.OnboardingCompletedAt, first)
}
// Other changes leave it alone.
got, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, "sam")
if err != nil {
t.Fatal(err)
}
if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != first {
t.Errorf("after another change: onboarding_completed_at = %v, want %s", got.OnboardingCompletedAt, first)
}
}
// TestUpdateOnboardingNeedsMinRegulars: onboarding cannot be completed
// with fewer than MinRegulars Regulars; archived and Quest log dinners do
// not count.
func TestUpdateOnboardingNeedsMinRegulars(t *testing.T) {
t.Parallel()
for regulars := range MinRegulars {
t.Run(fmt.Sprintf("%d Regulars", regulars), func(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc, st := seededServiceStore(t)
addDinners(t, st, "regular", regulars)
addDinners(t, st, "archived", MinRegulars)
addDinners(t, st, "quest_log", MinRegulars)
_, err := svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "sam")
var verr *ValidationError
if !errors.As(err, &verr) || len(verr.Problems) != 1 || verr.Problems[0].Field != "onboarding_completed" {
t.Fatalf("Update = %v, want a ValidationError on onboarding_completed", err)
}
got, err := svc.Get(ctx)
if err != nil {
t.Fatal(err)
}
if got.OnboardingCompletedAt != nil {
t.Errorf("onboarding_completed_at = %s after a refused completion, want null", *got.OnboardingCompletedAt)
}
})
}
}
func TestUpdateRequiresMember(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -259,6 +348,7 @@ func TestUpdateValidation(t *testing.T) {
{"dow 7", SettingsPatch{PlanDraftDow: ptr(7)}, []string{"plan_draft_dow"}},
{"dow -1", SettingsPatch{SuggestionsDow: ptr(-1)}, []string{"suggestions_dow"}},
{"dow 100", SettingsPatch{ChatAutosendDow: ptr(100)}, []string{"chat_autosend_dow"}},
{"onboarding undone", SettingsPatch{OnboardingCompleted: ptr(false)}, []string{"onboarding_completed"}},
{
"every problem is reported",
SettingsPatch{Timezone: ptr("Nowhere"), DinnerTime: ptr("5pm"), PlanDraftDow: ptr(9)},
+17 -5
View File
@@ -564,9 +564,11 @@ func TestFetchCacheIsNotSharedWithCallers(t *testing.T) {
}
}
// TestFetchCanceledWaitHoldsNoSlot covers a burst of fetches whose
// TestFetchCanceledWaitHoldsNoSlot covers a burst of requests whose
// rate-limit waits end with their context: they must not push back the
// next request that is allowed through.
// next request that is allowed through. The burst calls waitTurn, the wait
// turnConn's first Write makes, directly: through Fetch, net/http races the
// failed write against its read loop, so the error it returns varies.
func TestFetchCanceledWaitHoldsNoSlot(t *testing.T) {
t.Parallel()
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -577,14 +579,24 @@ func TestFetchCanceledWaitHoldsNoSlot(t *testing.T) {
if _, err := env.f.Fetch(t.Context(), "https://example.com/first"); err != nil {
t.Fatalf("first Fetch: %v", err)
}
firstSent := clock.now()
env.f.sleep = func(context.Context, time.Duration) error { return context.DeadlineExceeded }
for i := range 5 {
if _, err := env.f.Fetch(t.Context(), fmt.Sprintf("https://example.com/burst/%d", i)); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("burst Fetch %d error = %v, want context.DeadlineExceeded", i, err)
// Each request comes later in the interval, so a slot it took
// would delay the request after the burst.
clock.advance(HostInterval / 10)
if _, err := env.f.waitTurn(t.Context(), "example.com"); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("burst waitTurn %d error = %v, want context.DeadlineExceeded", i, err)
}
}
env.f.mu.Lock()
last := env.f.lastSent["example.com"]
env.f.mu.Unlock()
if !last.Equal(firstSent) {
t.Errorf("host slot taken at %s after the burst, want the first send at %s", last, firstSent)
}
env.f.sleep = clock.sleep
clock.advance(HostInterval)
clock.advance(firstSent.Add(HostInterval).Sub(clock.now()))
if _, err := env.f.Fetch(t.Context(), "https://example.com/after"); err != nil {
t.Fatalf("Fetch after the burst: %v", err)
}
+104 -34
View File
@@ -25,6 +25,13 @@ var (
// ErrTotalTimeNotPositive rejects a recipe whose page gives a total
// time of zero minutes, which normalize_recipe refuses.
ErrTotalTimeNotPositive = errors.New("recipes: recipe total time is not positive")
// ErrSearchFailed wraps every failure to search: the search provider
// could not be built (ErrSearchNotConfigured among them) or its search
// failed.
ErrSearchFailed = errors.New("recipes: recipe search failed")
// ErrNormalizeFailed wraps a normalize_recipe failure that is not the
// recipe's: the model provider could not be built or called.
ErrNormalizeFailed = errors.New("recipes: normalize_recipe failed")
)
// ErrUngroundable is returned when no recipe page could ground a dish or a
@@ -118,43 +125,16 @@ func (g *Grounder) Ground(ctx context.Context, dishName, query string) (Grounded
if strings.TrimSpace(dishName) == "" {
return Grounded{}, errors.New("recipes: ground: dish name is empty")
}
provider, err := g.search.Provider(ctx)
found, err := g.searchRanked(ctx, dishName, query)
if err != nil {
return Grounded{}, fmt.Errorf("recipes: ground %q: %w", dishName, err)
}
cands, err := provider.Search(ctx, query, RankDepth)
if err != nil {
return Grounded{}, fmt.Errorf("recipes: ground %q: search: %w", dishName, err)
}
if len(cands) == 0 {
return Grounded{}, &ErrUngroundable{Target: dishName, Reasons: []error{ErrNoCandidates}}
}
cands = cands[:min(len(cands), RankDepth)]
stored := make(map[string]storedRecipe)
for _, c := range cands {
sr, ok, err := g.storedRecipe(ctx, c.URL)
if err != nil {
return Grounded{}, fmt.Errorf("recipes: ground %q: %w", dishName, err)
}
if ok {
stored[c.URL] = sr
}
}
ranked, rankErr := rank(ctx, dishName, cands, func(ctx context.Context, c Candidate) (Recipe, error) {
if sr, ok := stored[c.URL]; ok {
return sr.recipe, nil
}
return fetchRecipe(ctx, g.pages, c.URL)
})
if err := ctx.Err(); err != nil {
return Grounded{}, err
}
var reasons []error
if rankErr != nil {
reasons = append(reasons, rankErr)
if found.rankErr != nil {
reasons = append(reasons, found.rankErr)
}
for _, r := range ranked {
if sr, ok := stored[r.Candidate.URL]; ok {
for _, r := range found.ranked {
if sr, ok := found.stored[r.Candidate.URL]; ok {
return sr.grounded, nil
}
gr, reason, err := g.ground(ctx, r.Candidate.URL, r.Recipe)
@@ -169,6 +149,96 @@ func (g *Grounder) Ground(ctx context.Context, dishName, query string) (Grounded
return Grounded{}, &ErrUngroundable{Target: dishName, Reasons: reasons}
}
// rankedSearch is a search's candidates ranked: the ranked ones best first,
// those already stored by URL, and why any were not ranked.
type rankedSearch struct {
ranked []Ranked
stored map[string]storedRecipe
// rankErr is Rank's error, or ErrNoCandidates when the search found
// nothing.
rankErr error
}
// searchRanked searches for query and ranks the top RankDepth candidates
// for dishName as Ground describes. A search, database or context failure
// is its error; candidates that do not rank are only rankErr's.
func (g *Grounder) searchRanked(ctx context.Context, dishName, query string) (rankedSearch, error) {
provider, err := g.search.Provider(ctx)
if err != nil {
return rankedSearch{}, fmt.Errorf("%w: %w", ErrSearchFailed, err)
}
cands, err := provider.Search(ctx, query, RankDepth)
if err != nil {
return rankedSearch{}, fmt.Errorf("%w: %w", ErrSearchFailed, err)
}
if len(cands) == 0 {
return rankedSearch{rankErr: ErrNoCandidates}, nil
}
cands = cands[:min(len(cands), RankDepth)]
stored := make(map[string]storedRecipe)
for _, c := range cands {
sr, ok, err := g.storedRecipe(ctx, c.URL)
if err != nil {
return rankedSearch{}, err
}
if ok {
stored[c.URL] = sr
}
}
ranked, rankErr := rank(ctx, dishName, cands, func(ctx context.Context, c Candidate) (Recipe, error) {
if sr, ok := stored[c.URL]; ok {
return sr.recipe, nil
}
return fetchRecipe(ctx, g.pages, c.URL)
})
if err := ctx.Err(); err != nil {
return rankedSearch{}, err
}
return rankedSearch{ranked: ranked, stored: stored, rankErr: rankErr}, nil
}
// Offer is a ranked candidate offered for the household to ground a dish
// in, with GroundURL on its URL.
type Offer struct {
URL string
Title string
SiteName string
// PhotoID is the cached photo of a candidate already stored, "" for
// one that is not: a candidate's photo is cached only when it grounds.
PhotoID string
// Minutes is a stored candidate's normalized minutes_total, else its
// page's total time; nil when unknown.
Minutes *int
}
// Candidates searches for query and returns up to n of the candidates
// Ground would try for dishName, best first. It fetches and extracts pages
// but downloads no photo and runs no model. Candidates that hold no usable
// recipe are not offered, so the list may be empty; a search that fails is
// an error wrapping ErrSearchFailed.
func (g *Grounder) Candidates(ctx context.Context, dishName, query string, n int) ([]Offer, error) {
if strings.TrimSpace(dishName) == "" {
return nil, errors.New("recipes: candidates: dish name is empty")
}
if n < 1 {
return nil, fmt.Errorf("recipes: candidates: n is %d, want at least 1", n)
}
found, err := g.searchRanked(ctx, dishName, query)
if err != nil {
return nil, fmt.Errorf("recipes: candidates for %q: %w", dishName, err)
}
offers := make([]Offer, 0, min(n, len(found.ranked)))
for _, r := range found.ranked[:min(n, len(found.ranked))] {
o := Offer{URL: r.Candidate.URL, Title: r.Recipe.Title, SiteName: r.Recipe.SiteName, Minutes: r.Recipe.TotalMinutes}
if sr, ok := found.stored[r.Candidate.URL]; ok {
o.PhotoID = sr.grounded.PhotoID
o.Minutes = sr.grounded.MinutesTotal
}
offers = append(offers, o)
}
return offers, nil
}
// GroundURL grounds the recipe on the page at rawURL, a pasted URL, as
// Ground does a candidate. An already stored rawURL is returned without
// fetching it. A page that cannot be fetched, holds no usable recipe, or
@@ -196,7 +266,7 @@ func (g *Grounder) GroundURL(ctx context.Context, rawURL string) (Grounded, erro
}
gr, reason, err := g.ground(ctx, rawURL, r)
if err != nil {
return Grounded{}, fmt.Errorf("recipes: ground %s: %w", rawURL, err)
return Grounded{}, fmt.Errorf("recipes: ground pasted link: %w", err)
}
if reason != nil {
return ungroundable(reason)
@@ -344,7 +414,7 @@ func (g *Grounder) ground(ctx context.Context, sourceURL string, r Recipe) (gr G
return Grounded{}, fmt.Errorf("normalize: %w", err), nil
}
if err != nil {
return Grounded{}, nil, fmt.Errorf("normalize: %w", err)
return Grounded{}, nil, fmt.Errorf("%w: %w", ErrNormalizeFailed, err)
}
if len(norm.Ingredients) != len(r.Ingredients) {
return Grounded{}, nil, fmt.Errorf("normalize: %d ingredients for %d lines", len(norm.Ingredients), len(r.Ingredients))
+35
View File
@@ -516,3 +516,38 @@ func TestGroundManyLimitsConcurrency(t *testing.T) {
t.Errorf("max in flight %d of %d calls, want %d of 5", search.maxActive, search.callCount(), GroundConcurrency)
}
}
func TestCandidatesOffersBestFirstUpToN(t *testing.T) {
t.Parallel()
var cands []Candidate
pages := map[string][]byte{}
for i, title := range []string{"Fish Tacos", "Fish Taco Bowls", "Baja Fish Tacos", "Tacos"} {
u := fmt.Sprintf("https://site%d.example/tacos", i)
cands = append(cands, Candidate{URL: u})
pages[u] = groundPage(title, "/img/tacos.jpg")
}
search := &fakeSearch{results: map[string][]Candidate{"fish tacos": cands}}
env := newGroundEnv(t, pages, nil, search)
got, err := env.g.Candidates(t.Context(), "Fish Tacos", "fish tacos", 3)
if err != nil {
t.Fatal(err)
}
if len(got) != 3 || got[0].URL != cands[0].URL || got[0].Title != "Fish Tacos" || got[0].SiteName != "Example Kitchen" ||
got[0].PhotoID != "" || got[0].Minutes == nil || *got[0].Minutes != 40 {
t.Fatalf("Candidates = %+v, want 3 with Fish Tacos first", got)
}
if env.images.requests.Load() != 0 || len(env.llm.Requests()) != 0 {
t.Errorf("Candidates downloaded a photo or ran the model")
}
}
func TestCandidatesSearchFailureWrapsErrSearchFailed(t *testing.T) {
t.Parallel()
search := &fakeSearch{errs: map[string]error{"stew": ErrSearchRateLimited}}
env := newGroundEnv(t, nil, nil, search)
_, err := env.g.Candidates(t.Context(), "Stew", "stew", 3)
if !errors.Is(err, ErrSearchFailed) || !errors.Is(err, ErrSearchRateLimited) {
t.Errorf("err = %v, want ErrSearchFailed wrapping ErrSearchRateLimited", err)
}
}
+16 -10
View File
@@ -65,22 +65,28 @@ var photoDecoders = map[string]func([]byte) (image.Image, error){
"webp": func(b []byte) (image.Image, error) { return webp.Decode(bytes.NewReader(b)) },
}
// PhotoCache downloads photos through a Fetcher and stores each at every
// ImageFetcher fetches an image; *Fetcher satisfies it.
type ImageFetcher interface {
FetchImage(ctx context.Context, rawURL string) (Page, error)
}
// PhotoCache downloads photos through an ImageFetcher and stores each at every
// PhotoSizes length as {id}-{size}.jpg under PhotoDir, with a photos row.
// Photos are deduplicated by the SHA-256 of the downloaded bytes. A
// PhotoCache is safe for concurrent use.
type PhotoCache struct {
fetcher *Fetcher
store *store.Store
images ImageFetcher
store *store.Store
// dir is the absolute photo directory.
dir string
}
// NewPhotoCache returns a PhotoCache storing photos under dataDir, creating
// the photo directory if it is absent.
func NewPhotoCache(fetcher *Fetcher, st *store.Store, dataDir string) (*PhotoCache, error) {
if fetcher == nil {
return nil, errors.New("recipes: nil fetcher")
// NewPhotoCache returns a PhotoCache downloading images with images and
// storing photos under dataDir, creating the photo directory if it is
// absent.
func NewPhotoCache(images ImageFetcher, st *store.Store, dataDir string) (*PhotoCache, error) {
if images == nil {
return nil, errors.New("recipes: nil image fetcher")
}
if st == nil {
return nil, errors.New("recipes: nil store")
@@ -92,7 +98,7 @@ func NewPhotoCache(fetcher *Fetcher, st *store.Store, dataDir string) (*PhotoCac
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("recipes: create photo directory: %w", err)
}
return &PhotoCache{fetcher: fetcher, store: st, dir: dir}, nil
return &PhotoCache{images: images, store: st, dir: dir}, nil
}
// DownloadedPhoto is an image DownloadPhoto has downloaded and decoded,
@@ -108,7 +114,7 @@ type DownloadedPhoto struct {
// whose shorter side is under PhotoMinShortSide. It records nothing, so a
// caller can try another image before opening a transaction.
func (c *PhotoCache) DownloadPhoto(ctx context.Context, rawURL string) (DownloadedPhoto, error) {
page, err := c.fetcher.FetchImage(ctx, rawURL)
page, err := c.images.FetchImage(ctx, rawURL)
if err != nil {
return DownloadedPhoto{}, err
}
+183
View File
@@ -0,0 +1,183 @@
// Package recipestest provides fakes for tests that ground dinners outside
// package recipes: a search, page and image fetchers serving fixtures, and
// a Grounder over them that normalizes through the real harness and
// normalize_recipe skill with a scripted model.
package recipestest
import (
"bytes"
"context"
"encoding/json"
"fmt"
"image"
"image/color"
"image/jpeg"
"sync"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// Search is a recipes.SearchSource and recipes.SearchProvider answering
// each query from Results. Err, when set, fails every search. Gate, when
// set, makes every search wait for it to close.
type Search struct {
Results map[string][]recipes.Candidate
Err error
Gate chan struct{}
mu sync.Mutex
queries []string
}
func (s *Search) Provider(context.Context) (recipes.SearchProvider, error) { return s, nil }
func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Candidate, error) {
s.mu.Lock()
s.queries = append(s.queries, query)
s.mu.Unlock()
if s.Gate != nil {
select {
case <-s.Gate:
case <-ctx.Done():
return nil, ctx.Err()
}
}
if s.Err != nil {
return nil, s.Err
}
c := s.Results[query]
return c[:min(n, len(c))], nil
}
// Queries returns every query searched, in order.
func (s *Search) Queries() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.queries...)
}
// Pages is a recipes.PageFetcher serving Bodies by URL; any other URL is a
// 404. Gate, when set, makes every fetch wait for it to close.
type Pages struct {
Bodies map[string][]byte
Gate chan struct{}
mu sync.Mutex
fetched []string
}
func (p *Pages) Fetch(ctx context.Context, rawURL string) (recipes.Page, error) {
p.mu.Lock()
p.fetched = append(p.fetched, rawURL)
p.mu.Unlock()
if p.Gate != nil {
select {
case <-p.Gate:
case <-ctx.Done():
return recipes.Page{}, ctx.Err()
}
}
body, ok := p.Bodies[rawURL]
if !ok {
return recipes.Page{}, &recipes.StatusError{URL: rawURL, Status: 404}
}
return recipes.Page{URL: rawURL, ContentType: "text/html", Body: body}, nil
}
// Fetched returns every URL fetched, in order.
func (p *Pages) Fetched() []string {
p.mu.Lock()
defer p.mu.Unlock()
return append([]string(nil), p.fetched...)
}
// Images is a recipes.ImageFetcher serving JPEGs by URL; any other URL is
// a 404.
type Images struct {
JPEGs map[string][]byte
}
func (i *Images) FetchImage(_ context.Context, rawURL string) (recipes.Page, error) {
body, ok := i.JPEGs[rawURL]
if !ok {
return recipes.Page{}, &recipes.StatusError{URL: rawURL, Status: 404}
}
return recipes.Page{URL: rawURL, ContentType: "image/jpeg", Body: body}, nil
}
// JPEG is a w×h JPEG whose pixels depend on seed, so different seeds give
// different photos.
func JPEG(t testing.TB, w, h int, seed uint8) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
img.Set(x, y, color.RGBA{uint8(x), uint8(y), seed, 255})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, nil); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// RecipePage is a page whose schema.org Recipe is title, from site, with
// two ingredients, two steps, a total time of minutes and the image at
// imageURL.
func RecipePage(title, site, imageURL string, minutes int) []byte {
return fmt.Appendf(nil, `<!DOCTYPE html><html><head>
<meta property="og:site_name" content=%q>
<script type="application/ld+json">{
"@context": "https://schema.org",
"@type": "Recipe",
"name": %q,
"image": %q,
"recipeIngredient": ["1 onion", "2 cloves garlic, minced"],
"recipeInstructions": ["Chop the onion.", "Fry with the garlic."],
"totalTime": "PT%dM"
}</script></head><body></body></html>`, site, title, imageURL, minutes)
}
// NormalizedReply is a usable normalize_recipe reply for a RecipePage with
// method and minutes.
func NormalizedReply(method string, minutes int) llm.GenerateResult {
js := fmt.Sprintf(`{"ingredients":[
{"name":"onion","qty":"1","unit":"","note":""},
{"name":"garlic","qty":"2","unit":"cloves","note":"minced"}
],"method":%q,"difficulty":null,"minutes_total":%d}`, method, minutes)
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
}
// NewGrounder returns a Grounder storing in st, with photos cached under
// dataDir, normalizing through the real harness with model.
func NewGrounder(t testing.TB, st *store.Store, dataDir string, search recipes.SearchSource, pages recipes.PageFetcher, images recipes.ImageFetcher, model llm.Provider) *recipes.Grounder {
t.Helper()
photos, err := recipes.NewPhotoCache(images, st, dataDir)
if err != nil {
t.Fatal(err)
}
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
t.Fatal(err)
}
cfg := harness.Config{
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test"},
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return model, nil },
Store: st,
Guard: guard,
}
normalize := func(ctx context.Context, scope string, in skills.RecipeText) (skills.NormalizedRecipe, error) {
return skills.NormalizeRecipe(ctx, cfg, scope, in)
}
g, err := recipes.NewGrounder(st, search, pages, photos, normalize)
if err != nil {
t.Fatal(err)
}
return g
}
+40 -17
View File
@@ -30,10 +30,15 @@ var ErrSearchNotConfigured = errors.New("recipes: recipe search is not configure
type SearchSettings struct {
// Provider is "searxng" or "brave", nil until first chosen.
Provider *string `json:"provider"`
// BaseURL is the SearXNG instance's URL, nil when not set.
BaseURL *string `json:"base_url"`
APIKeySet bool `json:"api_key_set"`
APIKeyLast4 *string `json:"api_key_last4"`
// BaseURL is the saved SearXNG instance's URL, nil when not set.
BaseURL *string `json:"base_url"`
// DefaultBaseURL is the deployment's SearXNG URL
// (MEALQUEST_SEARXNG_URL), for the client to suggest while BaseURL is
// not set. It is never saved by itself: only a PATCH of base_url saves
// a URL. Nil when the deployment names none.
DefaultBaseURL *string `json:"default_base_url"`
APIKeySet bool `json:"api_key_set"`
APIKeyLast4 *string `json:"api_key_last4"`
// UpdatedAt is nil until the settings are first saved.
UpdatedAt *string `json:"updated_at"`
}
@@ -75,32 +80,49 @@ func (e *SearchSettingsError) Error() string {
type SearchSettingsService struct {
st *store.Store
box *secretbox.Box
// defaultBaseURL is SearchSettings.DefaultBaseURL, "" for none.
defaultBaseURL string
// newProvider is NewSearchProvider; tests point Brave elsewhere.
newProvider func(db.RecipeSearchSetting, *secretbox.Box) (SearchProvider, error)
}
// NewSearchSettingsService returns the service over st, sealing keys with
// box.
func NewSearchSettingsService(st *store.Store, box *secretbox.Box) (*SearchSettingsService, error) {
// box. defaultBaseURL is the deployment's SearXNG URL, "" for none; a set
// one must be a valid SearXNG URL.
func NewSearchSettingsService(st *store.Store, box *secretbox.Box, defaultBaseURL string) (*SearchSettingsService, error) {
if st == nil {
return nil, errors.New("recipes: nil store")
}
if box == nil {
return nil, errors.New("recipes: nil secret box")
}
return &SearchSettingsService{st: st, box: box, newProvider: NewSearchProvider}, nil
if defaultBaseURL != "" {
if _, err := NewSearXNG(defaultBaseURL); err != nil {
return nil, fmt.Errorf("recipes: default SearXNG URL: %s", strings.TrimPrefix(err.Error(), "recipes: "))
}
}
return &SearchSettingsService{st: st, box: box, defaultBaseURL: defaultBaseURL, newProvider: NewSearchProvider}, nil
}
// Get returns the settings; before any are saved every field is unset.
// Get returns the settings; before any are saved every field but
// DefaultBaseURL is unset.
func (s *SearchSettingsService) Get(ctx context.Context) (SearchSettings, error) {
row, err := db.New(s.st.Reader).GetRecipeSearchSettings(ctx)
if errors.Is(err, sql.ErrNoRows) {
return SearchSettings{}, nil
return SearchSettings{DefaultBaseURL: s.defaultBase()}, nil
}
if err != nil {
return SearchSettings{}, fmt.Errorf("recipes: read recipe search settings: %w", err)
}
return searchSettingsFrom(row), nil
return s.settingsFrom(row), nil
}
func (s *SearchSettingsService) defaultBase() *string {
if s.defaultBaseURL == "" {
return nil
}
u := s.defaultBaseURL
return &u
}
// Update applies patch and returns the settings it leaves. The first update
@@ -141,7 +163,7 @@ func (s *SearchSettingsService) Update(ctx context.Context, patch SearchSettings
if err != nil {
return SearchSettings{}, err
}
return searchSettingsFrom(saved), nil
return s.settingsFrom(saved), nil
}
// apply returns row with patch applied, validated.
@@ -226,14 +248,15 @@ func (s *SearchSettingsService) TestSearch(ctx context.Context) (int, error) {
return len(cands), nil
}
func searchSettingsFrom(row db.RecipeSearchSetting) SearchSettings {
func (s *SearchSettingsService) settingsFrom(row db.RecipeSearchSetting) SearchSettings {
provider, updated := row.Provider, row.UpdatedAt
return SearchSettings{
Provider: &provider,
BaseURL: nullString(row.BaseUrl),
APIKeySet: len(row.ApiKeyCt) > 0,
APIKeyLast4: nullString(row.ApiKeyLast4),
UpdatedAt: &updated,
Provider: &provider,
BaseURL: nullString(row.BaseUrl),
DefaultBaseURL: s.defaultBase(),
APIKeySet: len(row.ApiKeyCt) > 0,
APIKeyLast4: nullString(row.ApiKeyLast4),
UpdatedAt: &updated,
}
}
+76 -1
View File
@@ -19,6 +19,13 @@ import (
const testBraveKey = "BSA-secret-brave-key-9f3c"
func newTestSearchSettings(t *testing.T) *SearchSettingsService {
t.Helper()
return newTestSearchSettingsWithDefault(t, "")
}
// newTestSearchSettingsWithDefault is newTestSearchSettings with
// defaultBaseURL as the deployment's SearXNG URL.
func newTestSearchSettingsWithDefault(t *testing.T, defaultBaseURL string) *SearchSettingsService {
t.Helper()
st, err := store.Open(t.Context(), t.TempDir())
if err != nil {
@@ -38,7 +45,7 @@ func newTestSearchSettings(t *testing.T) *SearchSettingsService {
if err != nil {
t.Fatal(err)
}
svc, err := NewSearchSettingsService(st, box)
svc, err := NewSearchSettingsService(st, box, defaultBaseURL)
if err != nil {
t.Fatal(err)
}
@@ -124,6 +131,74 @@ func TestSearchSettingsUpdate(t *testing.T) {
}
}
func TestSearchSettingsDefaultBaseURL(t *testing.T) {
t.Parallel()
svc := newTestSearchSettingsWithDefault(t, "http://searxng:8080")
got, err := svc.Get(t.Context())
if err != nil {
t.Fatal(err)
}
if got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" || got.BaseURL != nil || got.Provider != nil {
t.Errorf("Get before any update = %+v, want only default_base_url set", got)
}
if _, err := svc.Provider(t.Context()); !errors.Is(err, ErrSearchNotConfigured) {
t.Errorf("Provider with only a default error = %v, want ErrSearchNotConfigured", err)
}
// The default is a suggestion: SearXNG still needs a saved URL.
if _, err := svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng")}); err == nil {
t.Error("choosing SearXNG without a URL succeeded with a default set")
}
got, err = svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng"), BaseURL: ptr("https://search.example")})
if err != nil {
t.Fatal(err)
}
if *got.BaseURL != "https://search.example" || got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" {
t.Errorf("Update result = %+v, want the saved URL beside the default", got)
}
got, err = svc.Get(t.Context())
if err != nil {
t.Fatal(err)
}
if *got.BaseURL != "https://search.example" {
t.Errorf("Get base_url = %q, want the saved URL, not the default", *got.BaseURL)
}
row, err := db.New(svc.st.Reader).GetRecipeSearchSettings(t.Context())
if err != nil {
t.Fatal(err)
}
if row.BaseUrl.String != "https://search.example" {
t.Errorf("stored base_url = %q", row.BaseUrl.String)
}
}
func TestNewSearchSettingsServiceRejectsBadDefault(t *testing.T) {
t.Parallel()
st, err := store.Open(t.Context(), t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatal(err)
}
for _, u := range []string{"searxng:8080", "ftp://searxng", "http://user:pw@searxng:8080"} {
if _, err := NewSearchSettingsService(st, box, u); err == nil {
t.Errorf("NewSearchSettingsService(%q) succeeded", u)
} else if strings.Contains(err.Error(), "pw") {
t.Errorf("error for %q echoes the URL: %v", u, err)
}
}
}
func TestSearchSettingsTestSearch(t *testing.T) {
t.Parallel()
searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+145
View File
@@ -0,0 +1,145 @@
package server
import (
"errors"
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// apiKeyBody is the PUT /settings/ai/api-key body. An empty api_key removes
// the stored key.
type apiKeyBody struct {
APIKey *string `json:"api_key"`
}
// modelsResponse is the GET /settings/ai/models body.
type modelsResponse struct {
Models []aisettings.Model `json:"models"`
}
// No response from these handlers holds the API key: the settings carry
// only its last four characters, and provider errors and model lists are
// redacted by package aisettings.
func getAISettings(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
settings, err := svc.Get(r.Context())
if err != nil {
logger.ErrorContext(r.Context(), "get AI settings", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusOK, settings)
}
}
func patchAISettings(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
patch, err := decodePatch[aisettings.Patch](http.MaxBytesReader(w, r.Body, maxAISettingsBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
writeAISettings(w, r, logger, "update AI settings")(svc.Update(r.Context(), patch))
}
}
// putAIKey takes the API key in api_key; the response, like GET's, holds
// only its last four characters.
func putAIKey(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := decodePatch[apiKeyBody](http.MaxBytesReader(w, r.Body, maxSettingsBody))
if err == nil && body.APIKey == nil {
err = errors.New("api_key: is required")
}
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
writeAISettings(w, r, logger, "store AI API key")(svc.SetAPIKey(r.Context(), *body.APIKey))
}
}
func postAIResetPrompt(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
writeAISettings(w, r, logger, "reset AI system prompt")(svc.ResetPrompt(r.Context()))
}
}
// postAIVerify checks the stored key with the provider and returns the
// settings with key_verified_at, or the failure (see writeAIFailure).
func postAIVerify(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
settings, err := svc.Verify(r.Context())
if err != nil {
writeAIFailure(w, r, logger, "verify AI key", err)
return
}
writeJSON(w, r, logger, http.StatusOK, settings)
}
}
// getAIModels returns the provider's live model list, or the failure (see
// writeAIFailure).
func getAIModels(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
models, err := svc.Models(r.Context())
if err != nil {
writeAIFailure(w, r, logger, "list AI models", err)
return
}
writeJSON(w, r, logger, http.StatusOK, modelsResponse{Models: models})
}
}
// writeAISettings returns a func writing a Service result: the settings,
// 400 invalid_settings for a *aisettings.ValidationError, or 500.
func writeAISettings(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string) func(aisettings.Settings, error) {
return func(settings aisettings.Settings, err error) {
var invalid *aisettings.ValidationError
switch {
case errors.As(err, &invalid):
writeError(w, r, logger, http.StatusBadRequest, "invalid_settings", invalid.Error())
case err != nil:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
default:
writeJSON(w, r, logger, http.StatusOK, settings)
}
}
}
// writeAIFailure writes a Verify or Models error: 409 not_configured when
// the settings cannot build a provider, 409 settings_changed when they
// changed during verification, and for a failed provider call a 502 (504
// for a timeout) whose code names the llm typed error: auth_failed,
// rate_limited, timeout, bad_output or provider_error. Rate limits and
// timeouts are retryable. Anything else is 500.
func writeAIFailure(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
switch {
case errors.Is(err, aisettings.ErrNotConfigured):
writeError(w, r, logger, http.StatusConflict, "not_configured", err.Error())
case errors.Is(err, aisettings.ErrChanged):
writeError(w, r, logger, http.StatusConflict, "settings_changed", err.Error())
case errors.Is(err, aisettings.ErrProviderFailed):
logger.WarnContext(r.Context(), action+" failed", "error", err)
status, e := http.StatusBadGateway, apiError{Code: "provider_error", Message: err.Error()}
switch {
case errors.Is(err, llm.ErrAuth):
e.Code = "auth_failed"
case errors.Is(err, llm.ErrRateLimited):
e.Code, e.Retryable = "rate_limited", true
case errors.Is(err, llm.ErrTimeout):
status, e.Code, e.Retryable = http.StatusGatewayTimeout, "timeout", true
case errors.Is(err, llm.ErrBadOutput):
e.Code = "bad_output"
}
writeAPIError(w, r, logger, status, e)
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
+261
View File
@@ -0,0 +1,261 @@
package server
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"testing/fstest"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
)
const (
aiPath = "/api/v1/settings/ai"
aiKeyPath = "/api/v1/settings/ai/api-key"
aiVerifyPath = "/api/v1/settings/ai/verify"
aiModelsPath = "/api/v1/settings/ai/models"
aiResetPromptPath = "/api/v1/settings/ai/reset-prompt"
testAIKey = "sk-test-secret-ai-key-7d21"
testWrongAIKey = "sk-test-wrong-ai-key-0b9e"
)
// newAIRouter returns a router with a fresh, seeded AI settings service.
func newAIRouter(t *testing.T) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t),
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
return r
}
// newModelsServer is an OpenAI-compatible server whose GET /models accepts
// only testAIKey. While echo is set it answers 500 with a body quoting the
// Authorization header it received, so a response that passed the vendor
// body through unredacted would leak the key. Its model list holds a model
// whose ID and name quote the key.
func newModelsServer(t *testing.T, echo *atomic.Bool) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
switch {
case r.URL.Path != "/v1/models":
http.NotFound(w, r)
case echo.Load():
w.WriteHeader(http.StatusInternalServerError)
io.WriteString(w, `{"error":{"message":"upstream failed for `+auth+`"}}`)
case auth != "Bearer "+testAIKey:
w.WriteHeader(http.StatusUnauthorized)
io.WriteString(w, `{"error":{"message":"invalid key `+auth+`"}}`)
default:
io.WriteString(w, `{"data":[{"id":"gpt-test"},{"id":"gpt-test-mini","name":"GPT Test Mini"},`+
`{"id":"gpt-`+testAIKey+`","name":"Leaky `+testAIKey+`"}]}`)
}
}))
t.Cleanup(srv.Close)
return srv
}
func errorCode(body map[string]any) any {
e, _ := body["error"].(map[string]any)
return e["code"]
}
// TestAISettingsAPI drives every /settings/ai route and scans every
// response body for the API keys.
func TestAISettingsAPI(t *testing.T) {
t.Parallel()
router := newAIRouter(t)
var echo atomic.Bool
vendor := newModelsServer(t, &echo)
var bodies []string
call := func(method, target, body string) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
rec, decoded := serve(t, router, signedIn(method, target, r))
bodies = append(bodies, rec.Body.String())
return rec, decoded
}
// The seed: anthropic, the default persona, no key.
rec, body := call(http.MethodGet, aiPath, "")
if rec.Code != http.StatusOK || body["provider"] != "anthropic" || body["system_prompt"] != household.DefaultPersonaPrompt ||
body["temperature"] != 0.7 || body["api_key_last4"] != nil || body["key_verified_at"] != nil {
t.Errorf("GET seed = %d %v", rec.Code, body)
}
for _, field := range []string{"provider", "base_url", "model", "temperature", "system_prompt", "api_key_last4", "key_verified_at"} {
if _, ok := body[field]; !ok {
t.Errorf("GET has no %s field: %v", field, body)
}
}
for _, path := range []string{aiVerifyPath, aiModelsPath} {
method := http.MethodPost
if path == aiModelsPath {
method = http.MethodGet
}
if rec, body := call(method, path, ""); rec.Code != http.StatusConflict || errorCode(body) != "not_configured" {
t.Errorf("%s %s without a key = %d %v, want 409 not_configured", method, path, rec.Code, body)
}
}
for _, bad := range []string{
`{}`,
`{"provider":"google"}`,
`{"provider":"openai_compatible","base_url":"not a url"}`,
`{"base_url":"https://evil.example"}`,
`{"temperature":1.1}`,
`{"temperature":-0.1}`,
`{"temperature":0.25}`,
`{"temperature":"0.5"}`,
`{"model":""}`,
`{"model":null}`,
`{"system_prompt":"` + strings.Repeat("é", 8001) + `"}`,
`{"system_prompt":"` + strings.Repeat("€", 8001) + `"}`,
`{"system_prompt":"` + strings.Repeat("a", maxAISettingsBody) + `"}`,
`{"api_key":"` + testAIKey + `"}`,
} {
if rec, body := call(http.MethodPatch, aiPath, bad); rec.Code != http.StatusBadRequest {
t.Errorf("PATCH %.80s = %d %v, want 400", bad, rec.Code, body)
}
}
// The longest prompt fits the body limit however its 8,000 characters
// are written: three-byte, four-byte, or as \u-escaped surrogate pairs.
for _, char := range []string{"€", "🍲", `\ud83c\udf72`} {
rec, body := call(http.MethodPatch, aiPath, `{"system_prompt":"`+strings.Repeat(char, 8000)+`"}`)
if prompt, _ := body["system_prompt"].(string); rec.Code != http.StatusOK || utf8.RuneCountInString(prompt) != 8000 {
t.Errorf("PATCH 8,000 × %s = %d, %d characters", char, rec.Code, utf8.RuneCountInString(prompt))
}
}
rec, body = call(http.MethodPatch, aiPath, `{"provider":"openai_compatible","base_url":"`+vendor.URL+`/v1","model":"gpt-test","temperature":0.3,"system_prompt":"`+strings.Repeat("é", 8000)+`"}`)
if rec.Code != http.StatusOK || body["provider"] != "openai_compatible" || body["base_url"] != vendor.URL+"/v1" ||
body["model"] != "gpt-test" || body["temperature"] != 0.3 {
t.Errorf("PATCH openai_compatible = %d %v", rec.Code, body)
}
for _, bad := range []string{`{}`, `{"api_key":"short"}`, `{"api_key":"sk-line\nbreak"}`, `{"api_key":null}`, `{"key":"` + testAIKey + `"}`} {
if rec, body := call(http.MethodPut, aiKeyPath, bad); rec.Code != http.StatusBadRequest {
t.Errorf("PUT api-key %s = %d %v, want 400", bad, rec.Code, body)
}
}
// A rejected key is a typed auth failure and records nothing.
rec, body = call(http.MethodPut, aiKeyPath, `{"api_key":"`+testWrongAIKey+`"}`)
if rec.Code != http.StatusOK || body["api_key_last4"] != "0b9e" || body["key_verified_at"] != nil {
t.Errorf("PUT wrong key = %d %v", rec.Code, body)
}
if rec, body := call(http.MethodPost, aiVerifyPath, ""); rec.Code != http.StatusBadGateway || errorCode(body) != "auth_failed" {
t.Errorf("POST verify with the wrong key = %d %v, want 502 auth_failed", rec.Code, body)
}
if rec, body := call(http.MethodGet, aiModelsPath, ""); rec.Code != http.StatusBadGateway || errorCode(body) != "auth_failed" {
t.Errorf("GET models with the wrong key = %d %v, want 502 auth_failed", rec.Code, body)
}
rec, body = call(http.MethodPut, aiKeyPath, `{"api_key":"`+testAIKey+`"}`)
if rec.Code != http.StatusOK || body["api_key_last4"] != "7d21" {
t.Errorf("PUT key = %d %v", rec.Code, body)
}
if _, ok := body["api_key"]; ok {
t.Errorf("PUT response has an api_key field: %v", body)
}
// The vendor quoting the key in an error body: the body excerpt
// reaches the response redacted.
echo.Store(true)
for _, c := range []struct{ method, path string }{{http.MethodPost, aiVerifyPath}, {http.MethodGet, aiModelsPath}} {
rec, body := call(c.method, c.path, "")
if rec.Code != http.StatusBadGateway || errorCode(body) != "provider_error" || !strings.Contains(rec.Body.String(), "upstream failed for Bearer [REDACTED]") {
t.Errorf("%s %s against an echoing vendor = %d %v, want 502 provider_error", c.method, c.path, rec.Code, body)
}
}
echo.Store(false)
rec, body = call(http.MethodPost, aiVerifyPath, "")
if rec.Code != http.StatusOK || body["key_verified_at"] == nil || body["api_key_last4"] != "7d21" {
t.Errorf("POST verify = %d %v", rec.Code, body)
}
rec, body = call(http.MethodGet, aiPath, "")
if rec.Code != http.StatusOK || body["key_verified_at"] == nil || body["model"] != "gpt-test" || body["temperature"] != 0.3 {
t.Errorf("GET after verify = %d %v", rec.Code, body)
}
rec, body = call(http.MethodGet, aiModelsPath, "")
models, _ := body["models"].([]any)
if rec.Code != http.StatusOK || len(models) != 3 {
t.Fatalf("GET models = %d %v", rec.Code, body)
}
if m := models[1].(map[string]any); m["id"] != "gpt-test-mini" || m["display_name"] != "GPT Test Mini" {
t.Errorf("GET models[1] = %v", m)
}
if m := models[2].(map[string]any); m["id"] != "gpt-[REDACTED]" || m["display_name"] != "Leaky [REDACTED]" {
t.Errorf("GET models[2] = %v, want the key redacted", m)
}
rec, body = call(http.MethodPatch, aiPath, `{"system_prompt":"Be brief."}`)
if rec.Code != http.StatusOK || body["system_prompt"] != "Be brief." || body["key_verified_at"] == nil {
t.Errorf("PATCH system_prompt = %d %v", rec.Code, body)
}
rec, body = call(http.MethodPost, aiResetPromptPath, "")
if rec.Code != http.StatusOK || body["system_prompt"] != household.DefaultPersonaPrompt {
t.Errorf("POST reset-prompt = %d %v", rec.Code, body)
}
// The stored key is never pointed at another server or provider.
for _, patch := range []string{`{"base_url":"https://attacker.example/v1"}`, `{"provider":"openai","base_url":""}`} {
if rec, body := call(http.MethodPatch, aiPath, patch); rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_settings" {
t.Errorf("PATCH %s with a key stored = %d %v, want 400 invalid_settings", patch, rec.Code, body)
}
}
if rec, body := call(http.MethodPut, aiKeyPath, `{"api_key":""}`); rec.Code != http.StatusOK || body["api_key_last4"] != nil || body["key_verified_at"] != nil {
t.Errorf("PUT empty key = %d %v", rec.Code, body)
}
rec, body = call(http.MethodPatch, aiPath, `{"base_url":"https://llm.example/v1"}`)
if rec.Code != http.StatusOK || body["base_url"] != "https://llm.example/v1" {
t.Errorf("PATCH base_url without a key = %d %v", rec.Code, body)
}
if len(bodies) < 30 {
t.Fatalf("scanned only %d response bodies", len(bodies))
}
for _, b := range bodies {
for _, key := range []string{testAIKey, testWrongAIKey} {
if strings.Contains(b, key) {
t.Errorf("response holds an API key: %s", b)
}
}
}
}
func TestAISettingsWritesNeedCSRF(t *testing.T) {
t.Parallel()
router := newAIRouter(t)
for _, c := range []struct{ method, path, body string }{
{http.MethodPatch, aiPath, `{"temperature":0.5}`},
{http.MethodPut, aiKeyPath, `{"api_key":"` + testAIKey + `"}`},
{http.MethodPost, aiVerifyPath, ""},
{http.MethodPost, aiResetPromptPath, ""},
} {
req := httptest.NewRequest(c.method, c.path, strings.NewReader(c.body))
req.AddCookie(validSessionCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("%s %s without the CSRF header = %d, want 403", c.method, c.path, rec.Code)
}
}
rec, body := serve(t, router, signedIn(http.MethodGet, aiPath, nil))
if rec.Code != http.StatusOK || body["api_key_last4"] != nil || body["temperature"] != 0.7 {
t.Errorf("GET after refused writes = %d %v, want the seed", rec.Code, body)
}
}
+250
View File
@@ -0,0 +1,250 @@
package server
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
)
// maxDinnerBody bounds a POST or PATCH /dinners request body.
const maxDinnerBody = 16 << 10
// maxGroundBody bounds a POST /dinners/{id}/ground body, which may carry a
// manual recipe of dinners.MaxManualLines lines of each kind.
const maxGroundBody = 512 << 10
// dinnerCreate is the POST /dinners body: a name or a recipe_url.
type dinnerCreate struct {
Name *string `json:"name"`
RecipeURL *string `json:"recipe_url"`
}
// dinnerPatch is the PATCH /dinners/{id} body. Every field is optional;
// unknown fields and null values are refused.
type dinnerPatch struct {
Name *string `json:"name"`
Method *string `json:"method"`
MinutesTotal *int `json:"minutes_total"`
Difficulty *string `json:"difficulty"`
}
type dinnerList struct {
Dinners []dinners.Dinner `json:"dinners"`
}
// dinnerGround is the POST /dinners/{id}/ground body: exactly one of
// candidate_id, recipe_url and manual.
type dinnerGround struct {
CandidateID *string `json:"candidate_id"`
RecipeURL *string `json:"recipe_url"`
Manual *dinnerManualRecipe `json:"manual"`
}
type dinnerManualRecipe struct {
Ingredients []string `json:"ingredients"`
Steps []string `json:"steps"`
Minutes *int `json:"minutes"`
Method *string `json:"method"`
}
type candidateList struct {
Candidates []dinners.Candidate `json:"candidates"`
}
// getDinners answers GET /dinners?status=regular|quest_log.
func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
list, err := svc.List(r.Context(), r.URL.Query().Get("status"))
if err != nil {
writeDinnerError(w, r, logger, "list dinners", err)
return
}
writeJSON(w, r, logger, http.StatusOK, dinnerList{Dinners: list})
}
}
// postDinner adds a Regular, recording the session's actor as updated_by,
// and answers 201 with it and its grounding_job_id without waiting for the
// job. When the job cannot be enqueued it still answers 201, with the dinner
// grounding_status failed and grounding_job_id null.
func postDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
body, err := decodePatch[dinnerCreate](http.MaxBytesReader(w, r.Body, maxDinnerBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
d, err := svc.CreateRegular(r.Context(), dinners.NewRegular(body), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "create dinner", err)
return
}
writeJSON(w, r, logger, http.StatusCreated, d)
}
}
// patchDinner edits a dinner, recording the session's actor as updated_by.
func patchDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
patch, err := decodePatch[dinnerPatch](http.MaxBytesReader(w, r.Body, maxDinnerBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
d, err := svc.Update(r.Context(), chi.URLParam(r, "id"), dinners.Patch(patch), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "update dinner", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// getDinnerCandidates answers GET /dinners/{id}/recipe-candidates with up
// to dinners.MaxCandidates recipe pages for the dinner's name.
func getDinnerCandidates(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
list, err := svc.Candidates(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeDinnerError(w, r, logger, "list recipe candidates", err)
return
}
writeJSON(w, r, logger, http.StatusOK, candidateList{Candidates: list})
}
}
// postDinnerGround grounds a dinner by hand in a candidate, a pasted link
// or a recipe typed in, recording the session's actor as updated_by.
func postDinnerGround(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
body, err := decodePatch[dinnerGround](http.MaxBytesReader(w, r.Body, maxGroundBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
fix := dinners.Fix{CandidateID: body.CandidateID, RecipeURL: body.RecipeURL}
if body.Manual != nil {
m := dinners.ManualRecipe(*body.Manual)
fix.Manual = &m
}
d, err := svc.Ground(r.Context(), chi.URLParam(r, "id"), fix, actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "ground dinner", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// deleteDinner archives a dinner; the row is never deleted.
func deleteDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
d, err := svc.Archive(r.Context(), chi.URLParam(r, "id"), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "archive dinner", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// postDinnerFavorite toggles a dinner's is_favorite.
func postDinnerFavorite(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
d, err := svc.ToggleFavorite(r.Context(), chi.URLParam(r, "id"), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "toggle dinner favorite", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// actorOrFail returns the session's actor, or answers 500 when the request
// has none, which behind requireSession is a wiring bug.
func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (auth.Actor, bool) {
actor, ok := auth.ActorFrom(r.Context())
if !ok {
logger.ErrorContext(r.Context(), r.Method+" "+r.URL.Path+" without a session")
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
return actor, ok
}
// writeDinnerError maps a dinners.Service error to its API error. A page
// that cannot ground the dinner is 422 ungroundable; recipe search not set
// up is 409 not_configured; a search or model failure is 502; grounding by
// hand that runs past dinners.GroundTimeout is 504 ground_timeout; grounding
// an archived dinner is 409 dinner_archived and one changed while grounding
// 409 ground_conflict. Any other grounding failure, by candidate, link or
// manual recipe, wraps dinners.ErrGroundFailed and is 500, logged by reason
// and dinner id only.
func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var invalid *dinners.ValidationError
var dup *dinners.DuplicateError
var ungroundable *recipes.ErrUngroundable
switch {
case errors.As(err, &ungroundable):
// The reasons quote dependency errors, so they are neither returned
// nor logged.
writeError(w, r, logger, http.StatusUnprocessableEntity, "ungroundable", "no usable recipe was found at that link")
case errors.Is(err, recipes.ErrSearchNotConfigured):
writeError(w, r, logger, http.StatusConflict, "not_configured", "recipe search is not configured")
case errors.Is(err, recipes.ErrSearchFailed):
// The error may quote the search provider's response, so only the
// reason is logged.
logger.WarnContext(r.Context(), action, "reason", "search_failed")
writeError(w, r, logger, http.StatusBadGateway, "search_failed", "recipe search failed")
case errors.Is(err, recipes.ErrNormalizeFailed):
logger.WarnContext(r.Context(), action, "reason", "normalize_recipe failed")
writeError(w, r, logger, http.StatusBadGateway, "ai_failed", "the AI provider failed to read the recipe")
case errors.Is(err, dinners.ErrGroundFailed):
// The error may quote the pasted link, which can carry a personal
// token, and dependency errors, so only the reason and dinner are
// logged.
logger.ErrorContext(r.Context(), action, "reason", "ground_failed", "dinner_id", chi.URLParam(r, "id"))
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
case errors.Is(err, dinners.ErrArchived):
writeError(w, r, logger, http.StatusConflict, "dinner_archived", "the dinner is archived")
case errors.Is(err, dinners.ErrGroundConflict):
writeError(w, r, logger, http.StatusConflict, "ground_conflict", "the dinner changed while its recipe was grounding; reload and try again")
case errors.Is(err, dinners.ErrGroundTimeout):
logger.WarnContext(r.Context(), action, "reason", "ground_timeout")
writeError(w, r, logger, http.StatusGatewayTimeout, "ground_timeout", "grounding the recipe took too long; try again")
case errors.As(err, &invalid):
writeError(w, r, logger, http.StatusBadRequest, "invalid_dinner", invalid.Error())
case errors.As(err, &dup):
writeError(w, r, logger, http.StatusConflict, "duplicate_dinner", dup.Error())
case errors.Is(err, dinners.ErrNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
+541
View File
@@ -0,0 +1,541 @@
package server
import (
"bytes"
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"testing/fstest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const dinnersPath = "/api/v1/dinners"
// newDinnersRouter returns a router whose dinners service runs over the
// returned store. Its job runner is not started, so grounding jobs stay
// queued.
func newDinnersRouter(t *testing.T) (http.Handler, *store.Store) {
t.Helper()
e := newDinnersEnv(t)
return e.h, e.st
}
// dinnersEnv is a router whose dinners service, job runner and grounder
// share one store, grounding with fake search, pages, images and model.
type dinnersEnv struct {
h http.Handler
st *store.Store
runner *jobs.Runner
search *recipestest.Search
pages *recipestest.Pages
images *recipestest.Images
model *llm.Fake
// logs holds everything the router and services log.
logs *logBuffer
}
// logBuffer is a bytes.Buffer safe for concurrent log writes.
type logBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *logBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *logBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
func newDinnersEnv(t *testing.T) *dinnersEnv {
t.Helper()
st := newTestStore(t)
e := &dinnersEnv{
st: st,
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
images: &recipestest.Images{JPEGs: map[string][]byte{}},
model: &llm.Fake{},
logs: &logBuffer{},
}
logger := slog.New(slog.NewTextHandler(e.logs, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
e.runner = runner
g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model)
svc, err := dinners.NewService(st, g, runner, logger)
if err != nil {
t.Fatal(err)
}
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
return e
}
// start starts the env's job runner, stopping it when the test ends.
func (e *dinnersEnv) start(t *testing.T) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
if err := e.runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
e.runner.Wait()
})
}
// callDinners sends a signed-in request with body (none when empty).
func callDinners(t *testing.T, h http.Handler, method, target, body string) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
return serve(t, h, signedIn(method, target, r))
}
// createDinner adds a Regular and returns its id.
func createDinner(t *testing.T, h http.Handler, body string) string {
t.Helper()
rec, got := callDinners(t, h, http.MethodPost, dinnersPath, body)
if rec.Code != http.StatusCreated {
t.Fatalf("POST %s = %d: %s", body, rec.Code, rec.Body)
}
return got["id"].(string)
}
// listNames returns the names GET /dinners?status=status lists, in order.
func listNames(t *testing.T, h http.Handler, status string) []string {
t.Helper()
rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"?status="+status, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET status=%s = %d: %s", status, rec.Code, rec.Body)
}
list, ok := body["dinners"].([]any)
if !ok {
t.Fatalf("GET status=%s has no dinners list: %v", status, body)
}
names := make([]string, len(list))
for i, d := range list {
names[i] = d.(map[string]any)["name"].(string)
}
return names
}
func TestCreateDinnerByName(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":" Chicken Tikka Masala "}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
}
for k, v := range map[string]any{
"name": "Chicken Tikka Masala",
"status": "regular",
"origin": "regular",
"grounding_status": "pending",
"is_favorite": false,
"recipe_url": nil,
"photo_url": nil,
"method": nil,
"minutes_total": nil,
"difficulty": nil,
"updated_by": testSession.Subject,
} {
if got, ok := body[k]; !ok || got != v {
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
}
}
var normalized, updatedBy string
if err := st.Reader.QueryRow(`SELECT normalized_name, updated_by FROM dinners WHERE id = ?`, body["id"]).Scan(&normalized, &updatedBy); err != nil {
t.Fatal(err)
}
if normalized != "chicken tikka masala" || updatedBy != testSession.Subject {
t.Errorf("stored normalized_name, updated_by = %q, %q", normalized, updatedBy)
}
}
func TestCreateDinnerByURL(t *testing.T) {
t.Parallel()
router, _ := newDinnersRouter(t)
link := "https://www.example.com/recipes/12345/beef-and-broccoli-stir-fry/"
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"`+link+`"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
}
for k, v := range map[string]any{
"name": "Beef and broccoli stir fry",
"recipe_url": link,
"status": "regular",
"origin": "regular",
"grounding_status": "pending",
"updated_by": testSession.Subject,
} {
if body[k] != v {
t.Errorf("%s = %#v, want %#v", k, body[k], v)
}
}
// The same link again is a duplicate even though its name would differ.
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Something else entirely"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("unrelated name = %d: %s", rec.Code, rec.Body)
}
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"`+link+`"}`)
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
t.Errorf("same link again = %d %v, want 409 duplicate_dinner", rec.Code, body)
}
for _, tc := range []struct{ name, body string }{
{"plain http", `{"recipe_url":"http://example.com/recipes/chili"}`},
{"not a URL", `{"recipe_url":"chili"}`},
{"credentials", `{"recipe_url":"https://me:pw@example.com/recipes/chili"}`},
{"no name in path", `{"recipe_url":"https://example.com/"}`},
{"numeric slug", `{"recipe_url":"https://example.com/recipe/98765"}`},
{"both fields", `{"name":"Chili","recipe_url":"https://example.com/recipes/chili"}`},
{"neither field", `{}`},
} {
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, tc.body)
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_dinner" {
t.Errorf("%s: %d %v, want 400 invalid_dinner", tc.name, rec.Code, body)
}
}
}
func TestCreateDinnerDuplicate(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
createDinner(t, router, `{"name":"Chicken Tikka Masala"}`)
for _, name := range []string{
"chicken tikka masala",
"Easy Chicken Tikka-Masala!",
"The Best Homemade Chicken Tikka Masala Recipe",
} {
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"`+name+`"}`)
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
t.Errorf("%q: %d %v, want 409 duplicate_dinner", name, rec.Code, body)
}
}
// A slug naming the same dish is a duplicate too.
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"https://example.com/easy-chicken-tikka-masala.html"}`)
if rec.Code != http.StatusConflict {
t.Errorf("URL slug duplicate = %d %v, want 409", rec.Code, body)
}
var n int
if err := st.Reader.QueryRow(`SELECT count(*) FROM dinners`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("dinners rows = %d, want 1", n)
}
// A different dish sharing words is not a duplicate.
createDinner(t, router, `{"name":"Chicken Tikka Wraps"}`)
}
func TestCreateDinnerRejected(t *testing.T) {
t.Parallel()
router, _ := newDinnersRouter(t)
for _, tc := range []struct{ name, body, code string }{
{"empty name", `{"name":" "}`, "invalid_dinner"},
{"filler only", `{"name":"The Best Easy Recipe"}`, "invalid_dinner"},
{"too long", `{"name":"` + strings.Repeat("x", dinners.MaxNameChars+1) + `"}`, "invalid_dinner"},
{"null name", `{"name":null}`, "invalid_request"},
{"unknown field", `{"name":"Chili","status":"quest_log"}`, "invalid_request"},
{"not an object", `"Chili"`, "invalid_request"},
} {
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, tc.body)
if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code {
t.Errorf("%s: %d %v, want 400 %s", tc.name, rec.Code, body, tc.code)
}
}
}
func TestListDinnersFiltersByStatus(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
tacos := createDinner(t, router, `{"name":"Tacos"}`)
createDinner(t, router, `{"name":"Lasagna"}`)
pho := createDinner(t, router, `{"name":"Pho"}`)
gone := createDinner(t, router, `{"name":"Meatloaf"}`)
ctx := context.Background()
if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, pho); err != nil {
t.Fatal(err)
}
if _, err := st.Writer.ExecContext(ctx,
`INSERT INTO photos (id, source_url, local_path, width, height, sha256, fetched_at, created_at, updated_at)
VALUES ('photo1', 'https://example.com/tacos.jpg', 'media/photos/photo1', 1200, 800, 'abc', 't', 't', 't')`); err != nil {
t.Fatal(err)
}
if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET photo_id = 'photo1', grounding_status = 'grounded' WHERE id = ?`, tacos); err != nil {
t.Fatal(err)
}
if rec, _ := callDinners(t, router, http.MethodDelete, dinnersPath+"/"+gone, ""); rec.Code != http.StatusOK {
t.Fatalf("DELETE = %d", rec.Code)
}
if got := strings.Join(listNames(t, router, "regular"), ","); got != "Lasagna,Tacos" {
t.Errorf("regular = %s, want Lasagna,Tacos", got)
}
if got := strings.Join(listNames(t, router, "quest_log"), ","); got != "Pho" {
t.Errorf("quest_log = %s, want Pho", got)
}
_, body := callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
for _, d := range body["dinners"].([]any) {
d := d.(map[string]any)
switch d["name"] {
case "Tacos":
if d["photo_url"] != "/media/photos/photo1-400.jpg" || d["grounding_status"] != "grounded" {
t.Errorf("Tacos photo_url, grounding_status = %v, %v", d["photo_url"], d["grounding_status"])
}
case "Lasagna":
if d["photo_url"] != nil || d["grounding_status"] != "pending" {
t.Errorf("Lasagna photo_url, grounding_status = %v, %v", d["photo_url"], d["grounding_status"])
}
}
}
// An empty list is [] rather than null.
empty, _ := newDinnersRouter(t)
rec, _ := callDinners(t, empty, http.MethodGet, dinnersPath+"?status=regular", "")
if got := rec.Body.String(); got != `{"dinners":[]}` {
t.Errorf("empty list = %s", got)
}
for _, q := range []string{"", "?status=archived", "?status=suggested", "?status=bogus"} {
rec, body := callDinners(t, router, http.MethodGet, dinnersPath+q, "")
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_dinner" {
t.Errorf("GET %q = %d %v, want 400 invalid_dinner", q, rec.Code, body)
}
}
}
func TestPatchDinner(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
id := createDinner(t, router, `{"name":"Chili"}`)
createDinner(t, router, `{"name":"Pot Roast"}`)
if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id,
`{"name":"White Chicken Chili","method":"crockpot","minutes_total":480,"difficulty":"easy"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d: %s", rec.Code, rec.Body)
}
for k, v := range map[string]any{
"name": "White Chicken Chili",
"method": "crockpot",
"minutes_total": float64(480),
"difficulty": "easy",
"status": "regular",
"updated_by": testSession.Subject,
} {
if body[k] != v {
t.Errorf("%s = %#v, want %#v", k, body[k], v)
}
}
var normalized string
if err := st.Reader.QueryRow(`SELECT normalized_name FROM dinners WHERE id = ?`, id).Scan(&normalized); err != nil {
t.Fatal(err)
}
if normalized != "white chicken chili" {
t.Errorf("normalized_name = %q", normalized)
}
// A partial patch leaves the other fields.
rec, body = callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"difficulty":"medium"}`)
if rec.Code != http.StatusOK || body["method"] != "crockpot" || body["difficulty"] != "medium" || body["name"] != "White Chicken Chili" {
t.Errorf("partial patch = %d %v", rec.Code, body)
}
// Renaming to its own name, respelled, is not a duplicate of itself.
if rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"name":"Easy white chicken chili"}`); rec.Code != http.StatusOK {
t.Errorf("self rename = %d %v", rec.Code, body)
}
// Renaming onto another dinner is.
rec, body = callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"name":"The Best Pot Roast"}`)
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
t.Errorf("rename onto Pot Roast = %d %v, want 409", rec.Code, body)
}
}
func TestPatchDinnerValidation(t *testing.T) {
t.Parallel()
router, _ := newDinnersRouter(t)
id := createDinner(t, router, `{"name":"Chili"}`)
for _, tc := range []struct {
name, body, code, in string
}{
{"bad method", `{"method":"microwave"}`, "invalid_dinner", "method"},
{"crock pot spelling", `{"method":"crock pot"}`, "invalid_dinner", "method"},
{"bad difficulty", `{"difficulty":"expert"}`, "invalid_dinner", "difficulty"},
{"zero minutes", `{"minutes_total":0}`, "invalid_dinner", "minutes_total"},
{"negative minutes", `{"minutes_total":-5}`, "invalid_dinner", "minutes_total"},
{"too many minutes", `{"minutes_total":1441}`, "invalid_dinner", "minutes_total"},
{"empty name", `{"name":""}`, "invalid_dinner", "name"},
{"every problem", `{"method":"x","difficulty":"y","minutes_total":0}`, "invalid_dinner", "method: must be one of stovetop, oven, crockpot, grill, other; minutes_total"},
{"empty patch", `{}`, "invalid_dinner", "no fields"},
{"fractional minutes", `{"minutes_total":30.5}`, "invalid_request", "minutes_total"},
{"string minutes", `{"minutes_total":"30"}`, "invalid_request", "minutes_total"},
{"null method", `{"method":null}`, "invalid_request", "method"},
{"read-only field", `{"status":"archived"}`, "invalid_request", "status"},
{"favorite field", `{"is_favorite":true}`, "invalid_request", "is_favorite"},
} {
rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, tc.body)
msg, _ := body["error"].(map[string]any)["message"].(string)
if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code || !strings.Contains(msg, tc.in) {
t.Errorf("%s: %d %v, want 400 %s containing %q", tc.name, rec.Code, body, tc.code, tc.in)
}
}
_, body := callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
d := body["dinners"].([]any)[0].(map[string]any)
if d["method"] != nil || d["difficulty"] != nil || d["minutes_total"] != nil || d["name"] != "Chili" {
t.Errorf("a rejected patch changed the dinner: %v", d)
}
rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/nope", `{"method":"oven"}`)
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("unknown id = %d %v, want 404", rec.Code, body)
}
}
func TestDeleteDinnerArchives(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
id := createDinner(t, router, `{"name":"Shepherd's Pie"}`)
if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, router, http.MethodDelete, dinnersPath+"/"+id, "")
if rec.Code != http.StatusOK || body["status"] != "archived" || body["updated_by"] != testSession.Subject {
t.Fatalf("DELETE = %d %v", rec.Code, body)
}
var status, name, updatedBy string
if err := st.Reader.QueryRow(`SELECT status, name, updated_by FROM dinners WHERE id = ?`, id).Scan(&status, &name, &updatedBy); err != nil {
t.Fatalf("archived row is gone: %v", err)
}
if status != "archived" || name != "Shepherd's Pie" || updatedBy != testSession.Subject {
t.Errorf("row = %s %q %s", status, name, updatedBy)
}
if got := listNames(t, router, "regular"); len(got) != 0 {
t.Errorf("regular after archive = %v", got)
}
// An archived name may be added again.
createDinner(t, router, `{"name":"Shepherds Pie"}`)
rec, body = callDinners(t, router, http.MethodDelete, dinnersPath+"/nope", "")
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("unknown id = %d %v, want 404", rec.Code, body)
}
}
func TestFavoriteDinnerToggles(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
id := createDinner(t, router, `{"name":"Fish Tacos"}`)
if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil {
t.Fatal(err)
}
for _, want := range []bool{true, false, true} {
rec, body := callDinners(t, router, http.MethodPost, dinnersPath+"/"+id+"/favorite", "")
if rec.Code != http.StatusOK || body["is_favorite"] != want || body["updated_by"] != testSession.Subject {
t.Fatalf("favorite = %d %v, want is_favorite %v", rec.Code, body, want)
}
}
var fav int
if err := st.Reader.QueryRow(`SELECT is_favorite FROM dinners WHERE id = ?`, id).Scan(&fav); err != nil {
t.Fatal(err)
}
if fav != 1 {
t.Errorf("stored is_favorite = %d, want 1", fav)
}
rec, body := callDinners(t, router, http.MethodPost, dinnersPath+"/nope/favorite", "")
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("unknown id = %d %v, want 404", rec.Code, body)
}
}
// TestDinnerWritesRequireCSRF sends every dinners write without the CSRF
// header and with a wrong one; each is refused and changes nothing.
func TestDinnerWritesRequireCSRF(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
id := createDinner(t, router, `{"name":"Chili"}`)
var before string
snapshot := `SELECT group_concat(id || name || status || is_favorite || coalesce(method, '') || updated_at, '|') FROM dinners`
if err := st.Reader.QueryRow(snapshot).Scan(&before); err != nil {
t.Fatal(err)
}
writes := []struct{ method, target, body string }{
{http.MethodPost, dinnersPath, `{"name":"Lasagna"}`},
{http.MethodPatch, dinnersPath + "/" + id, `{"method":"oven"}`},
{http.MethodDelete, dinnersPath + "/" + id, ""},
{http.MethodPost, dinnersPath + "/" + id + "/favorite", ""},
}
for _, w := range writes {
for _, header := range []*string{nil, ptr("wrong-token")} {
req := httptest.NewRequest(w.method, w.target, strings.NewReader(w.body))
req.AddCookie(validSessionCookie)
if header != nil {
req.Header.Set(CSRFHeader, *header)
}
rec, body := serve(t, router, req)
if rec.Code != http.StatusForbidden || errorCode(body) != "csrf" {
t.Errorf("%s %s (header %v) = %d %v, want 403 csrf", w.method, w.target, header != nil, rec.Code, body)
}
}
}
var after string
if err := st.Reader.QueryRow(snapshot).Scan(&after); err != nil {
t.Fatal(err)
}
if after != before {
t.Errorf("refused writes changed dinners:\n before %s\n after %s", before, after)
}
// Without a session, the list is 401 too.
req := httptest.NewRequest(http.MethodGet, dinnersPath+"?status=regular", nil)
if rec, body := serve(t, router, req); rec.Code != http.StatusUnauthorized || errorCode(body) != "unauthenticated" {
t.Errorf("GET without a session = %d %v, want 401", rec.Code, body)
}
}
func ptr[T any](v T) *T { return &v }
+412
View File
@@ -0,0 +1,412 @@
package server
import (
"fmt"
"net/http"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
)
// Fixture pages: two curries found by searching "Chicken curry" and a stew
// at a pasted link.
const (
curryPage = "https://a.example/chicken-curry"
curry2Page = "https://b.example/curry"
stewPage = "https://c.example/recipes/beef-stew"
)
// withRecipes adds the fixture pages, their photos and the search for
// "Chicken curry" to e.
func (e *dinnersEnv) withRecipes(t *testing.T) *dinnersEnv {
t.Helper()
e.search.Results["Chicken curry"] = []recipes.Candidate{{URL: curryPage}, {URL: curry2Page}}
e.pages.Bodies[curryPage] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
e.pages.Bodies[curry2Page] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25)
e.pages.Bodies[stewPage] = recipestest.RecipePage("Beef Stew", "Stew Co", "https://c.example/stew.jpg", 150)
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
e.images.JPEGs["https://c.example/stew.jpg"] = recipestest.JPEG(t, 400, 320, 3)
return e
}
// waitJob polls GET /jobs/{id} until the job ends and returns its body.
func waitJob(t *testing.T, h http.Handler, id string) map[string]any {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
rec, body := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET job = %d: %s", rec.Code, rec.Body)
}
if s := body["status"]; s == "succeeded" || s == "failed" {
return body
}
if time.Now().After(deadline) {
t.Fatalf("job %s never finished; last %v", id, body)
}
time.Sleep(5 * time.Millisecond)
}
}
// listedDinner returns dinner id as GET /dinners?status=regular lists it.
func listedDinner(t *testing.T, h http.Handler, id string) map[string]any {
t.Helper()
rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"?status=regular", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET dinners = %d: %s", rec.Code, rec.Body)
}
for _, d := range body["dinners"].([]any) {
if d := d.(map[string]any); d["id"] == id {
return d
}
}
t.Fatalf("dinner %s not listed", id)
return nil
}
func TestPostDinnerReturnsBeforeGroundingCompletes(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
e.search.Gate = make(chan struct{})
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
e.start(t)
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
jobID, _ := created["grounding_job_id"].(string)
if created["grounding_status"] != "pending" || created["grounding_error"] != nil || jobID == "" {
t.Fatalf("created = %v, want pending with a grounding_job_id", created)
}
// The search is held, so grounding cannot have completed.
_, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+jobID, "")
if s := job["status"]; s != "queued" && s != "running" {
t.Fatalf("job right after POST = %v, want queued or running", job)
}
if d := listedDinner(t, e.h, created["id"].(string)); d["grounding_status"] != "pending" {
t.Fatalf("dinner right after POST = %v, want pending", d)
}
close(e.search.Gate)
job = waitJob(t, e.h, jobID)
if job["status"] != "succeeded" || job["kind"] != "ground_dinner" {
t.Fatalf("job = %v, want ground_dinner succeeded", job)
}
d := listedDinner(t, e.h, created["id"].(string))
photo, _ := d["photo_url"].(string)
if d["grounding_status"] != "grounded" || d["recipe_id"] == nil || !strings.HasSuffix(photo, "-400.jpg") ||
d["method"] != "stovetop" || d["minutes_total"] != 40.0 || d["difficulty"] != "easy" || d["grounding_error"] != nil {
t.Errorf("dinner = %v, want grounded with recipe, photo, method, minutes and difficulty", d)
}
}
func TestPostDinnerGroundingFailure(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
e.start(t)
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Nothing matches this"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
job := waitJob(t, e.h, created["grounding_job_id"].(string))
if job["status"] != "failed" || job["error"] != "no_recipe_found" {
t.Errorf("job = %v, want failed with no_recipe_found", job)
}
d := listedDinner(t, e.h, created["id"].(string))
if d["grounding_status"] != "failed" || d["grounding_error"] != "no_recipe_found" || d["recipe_id"] != nil {
t.Errorf("dinner = %v, want failed with no_recipe_found", d)
}
}
func TestGetRecipeCandidates(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
id := createDinner(t, e.h, `{"name":"Chicken curry"}`) // runner not started: stays pending
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
}
list, _ := body["candidates"].([]any)
if len(list) != 2 {
t.Fatalf("candidates = %v, want 2", body)
}
first := list[0].(map[string]any)
want := map[string]any{
"candidate_id": curryPage,
"title": "Chicken Curry",
"site_name": "Curry House",
"photo_url": nil,
"minutes": 40.0,
}
if len(first) != len(want) {
t.Errorf("candidate keys = %v, want exactly %v", first, want)
}
for k, v := range want {
if got, ok := first[k]; !ok || got != v {
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
}
}
rec, body = callDinners(t, e.h, http.MethodGet, dinnersPath+"/nope/recipe-candidates", "")
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("unknown dinner = %d %v, want 404", rec.Code, body)
}
}
func TestGetRecipeCandidatesSearchFails(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
// The provider's text must reach neither the response nor the logs.
const leak = "provider-said-token-abc123"
e.search.Err = fmt.Errorf("%w: %s", recipes.ErrSearchRefused, leak)
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
if rec.Code != http.StatusBadGateway || errorCode(body) != "search_failed" {
t.Errorf("GET = %d %v, want 502 search_failed", rec.Code, body)
}
if strings.Contains(rec.Body.String(), leak) {
t.Errorf("response quotes the provider: %s", rec.Body)
}
logs := e.logs.String()
if strings.Contains(logs, leak) || !strings.Contains(logs, "reason=search_failed") {
t.Errorf("logs = %q, want reason=search_failed and no provider text", logs)
}
}
func TestPostGroundViaCandidate(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"candidate_id":"`+curry2Page+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
photo, _ := d["photo_url"].(string)
if d["grounding_status"] != "grounded" || d["recipe_id"] == nil || photo == "" ||
d["method"] != "stovetop" || d["minutes_total"] != 25.0 || d["difficulty"] != "easy" || d["updated_by"] != testSession.Subject {
t.Errorf("dinner = %v", d)
}
// The candidate is now stored, so it is offered with its photo.
_, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
for _, c := range body["candidates"].([]any) {
if c := c.(map[string]any); c["candidate_id"] == curry2Page && c["photo_url"] != photo {
t.Errorf("grounded candidate photo_url = %v, want %s", c["photo_url"], photo)
}
}
}
func TestPostGroundViaRecipeURL(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+stewPage+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
if d["grounding_status"] != "grounded" || d["photo_url"] == nil || d["method"] != "oven" || d["minutes_total"] != 150.0 {
t.Errorf("dinner = %v", d)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"https://gone.example/soup"}`)
if rec.Code != http.StatusUnprocessableEntity || errorCode(body) != "ungroundable" || strings.Contains(rec.Body.String(), "gone.example") {
t.Errorf("unusable link = %d %v, want 422 ungroundable", rec.Code, body)
}
}
// TestPostGroundViaRecipeURLStorageFailure: a pasted link that fails for a
// reason other than its recipe is a 500 logged by reason and dinner id,
// never with the link, which can carry a personal token, or the dependency
// error.
func TestPostGroundViaRecipeURLStorageFailure(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
const token = "personal-token-abc123"
link := stewPage + "?access_token=" + token
e.pages.Bodies[link] = e.pages.Bodies[stewPage]
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
if _, err := e.st.Writer.ExecContext(t.Context(),
`CREATE TRIGGER refuse_recipes BEFORE INSERT ON recipes BEGIN SELECT RAISE(ABORT, 'disk full'); END`); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+link+`"}`)
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" || strings.Contains(rec.Body.String(), token) {
t.Errorf("POST = %d %s, want 500 internal without the link", rec.Code, rec.Body)
}
logs := e.logs.String()
if strings.Contains(logs, token) || strings.Contains(logs, "c.example") || strings.Contains(logs, "disk full") ||
!strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the link or dependency error", logs)
}
}
// TestPostGroundViaRecipeURLDinnerWriteFailure: a pasted link whose recipe
// is stored but whose dinner cannot be written is a 500 logged by reason
// and dinner id, never with the dependency error.
func TestPostGroundViaRecipeURLDinnerWriteFailure(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
if _, err := e.st.Writer.ExecContext(t.Context(),
`CREATE TRIGGER refuse_grounding BEFORE UPDATE OF recipe_id ON dinners WHEN NEW.recipe_id IS NOT NULL BEGIN SELECT RAISE(ABORT, 'disk full'); END`); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+stewPage+`"}`)
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
t.Errorf("POST = %d %s, want 500 internal", rec.Code, rec.Body)
}
var stored int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM recipes WHERE source_url = ?`, stewPage).Scan(&stored); err != nil || stored != 1 {
t.Fatalf("recipes stored for the link = %d (%v), want 1: the failure must be the dinner write", stored, err)
}
logs := e.logs.String()
if strings.Contains(logs, "disk full") || !strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the dependency error", logs)
}
}
// TestPostGroundUnexpectedFailures: grounding by hand that fails reading
// the dinner, or storing a manual recipe, is a 500 logged by reason and
// dinner id, never with the database error.
func TestPostGroundUnexpectedFailures(t *testing.T) {
t.Parallel()
const (
// Renaming a column every dinner read selects fails the read.
breakRead = `ALTER TABLE dinners RENAME COLUMN cuisine TO cuisine_gone`
// The trigger's message stands in for the database error.
breakStore = `CREATE TRIGGER refuse_recipes BEFORE INSERT ON recipes BEGIN SELECT RAISE(ABORT, 'disk full'); END`
manual = `{"manual":{"ingredients":["1 lb beef"],"steps":["Brown."],"minutes":240,"method":"crockpot"}}`
)
for _, tc := range []struct {
name, sabotage, fix, leak string
}{
{"link read", breakRead, `{"recipe_url":"` + stewPage + `"}`, "cuisine"},
{"manual read", breakRead, manual, "cuisine"},
{"manual store", breakStore, manual, "disk full"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
if _, err := e.st.Writer.ExecContext(t.Context(), tc.sabotage); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", tc.fix)
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
t.Errorf("POST = %d %s, want 500 internal", rec.Code, rec.Body)
}
logs := e.logs.String()
if strings.Contains(logs, tc.leak) || !strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the database error", logs)
}
})
}
}
// TestPostGroundArchived: grounding an archived dinner by hand is a 409.
func TestPostGroundArchived(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Family chili"}`)
if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
`{"manual":{"ingredients":["1 lb beef"],"minutes":240,"method":"crockpot"}}`)
if rec.Code != http.StatusConflict || errorCode(body) != "dinner_archived" {
t.Errorf("POST = %d %v, want 409 dinner_archived", rec.Code, body)
}
}
func TestPostGroundViaManual(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Family chili"}`)
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`)
if rec.Code != http.StatusOK {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
if d["grounding_status"] != "manual" || d["recipe_id"] == nil || d["photo_url"] != nil ||
d["method"] != "crockpot" || d["minutes_total"] != 240.0 || d["grounding_error"] != nil {
t.Errorf("dinner = %v", d)
}
var lines int
if err := e.st.Reader.QueryRow(`SELECT count(*) FROM recipe_ingredients WHERE recipe_id = ?`, d["recipe_id"]).Scan(&lines); err != nil {
t.Fatal(err)
}
if lines != 2 {
t.Errorf("stored %d ingredient lines, want 2", lines)
}
}
func TestPostGroundRejects(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Pad thai"}`)
path := dinnersPath + "/" + id + "/ground"
for _, tc := range []struct{ name, body, code string }{
{"empty", `{}`, "invalid_dinner"},
{"two kinds", `{"candidate_id":"https://a.example/x","recipe_url":"https://a.example/x"}`, "invalid_dinner"},
{"candidate not a link", `{"candidate_id":"c1"}`, "invalid_dinner"},
{"http link", `{"recipe_url":"http://a.example/x"}`, "invalid_dinner"},
{"null", `{"recipe_url":null}`, "invalid_request"},
{"unknown field", `{"url":"https://a.example/x"}`, "invalid_request"},
{"unknown manual field", `{"manual":{"ingredients":["x"],"minutes":5,"method":"oven","serves":4}}`, "invalid_request"},
{"manual without method", `{"manual":{"ingredients":["x"],"minutes":5}}`, "invalid_dinner"},
{"manual without ingredients", `{"manual":{"ingredients":[],"minutes":5,"method":"oven"}}`, "invalid_dinner"},
{"manual minutes too many", `{"manual":{"ingredients":["x"],"minutes":5000,"method":"oven"}}`, "invalid_dinner"},
{"not an object", `[]`, "invalid_request"},
} {
rec, body := callDinners(t, e.h, http.MethodPost, path, tc.body)
if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code {
t.Errorf("%s: %d %v, want 400 %s", tc.name, rec.Code, body, tc.code)
}
}
if d := listedDinner(t, e.h, id); d["grounding_status"] != "pending" {
t.Errorf("dinner after rejected fixes = %v, want unchanged", d)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/nope/ground",
`{"manual":{"ingredients":["x"],"minutes":5,"method":"oven"}}`)
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("unknown dinner = %d %v, want 404", rec.Code, body)
}
}
func TestPostDinnerEnqueueFailureAnswersFailedDinner(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
if _, err := e.st.Writer.ExecContext(t.Context(),
`CREATE TRIGGER refuse_jobs BEFORE INSERT ON jobs BEGIN SELECT RAISE(ABORT, 'no jobs'); END`); err != nil {
t.Fatal(err)
}
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
id, _ := created["id"].(string)
if id == "" || created["grounding_status"] != "failed" || created["grounding_error"] != "grounding_error" || created["grounding_job_id"] != nil {
t.Fatalf("created = %v, want its id, failed (grounding_error) and a null grounding_job_id", created)
}
if got := listedDinner(t, e.h, id); got["grounding_status"] != "failed" {
t.Errorf("listed = %v, want failed", got)
}
}
+1 -1
View File
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
t.Fatal(err)
}
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestRunner(t),
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t),
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+1 -1
View File
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
}
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+2 -2
View File
@@ -22,7 +22,7 @@ const (
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestRunner(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
@@ -46,7 +46,7 @@ func TestRecipeSearchSettingsAPI(t *testing.T) {
}
rec, body := call(http.MethodGet, recipeSearchPath, "")
if rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false {
if def, ok := body["default_base_url"]; rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false || !ok || def != nil {
t.Errorf("GET unconfigured = %d %v", rec.Code, body)
}
rec, body = call(http.MethodPost, recipeSearchTestPath, "")
+28 -2
View File
@@ -10,6 +10,8 @@ import (
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
@@ -31,18 +33,29 @@ type RouteMounter interface {
// only from a peer in trusted (see resolveOrigin). version is reported by
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
// /media; settings serves /api/v1/settings/household; search serves
// /api/v1/settings/recipe-search and its /test; runner serves
// /api/v1/settings/recipe-search and its /test; ai serves
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
// runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
// GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
if search == nil {
return nil, errors.New("server: nil recipe search settings service")
}
if ai == nil {
return nil, errors.New("server: nil AI settings service")
}
if dinnerSvc == nil {
return nil, errors.New("server: nil dinners service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
@@ -80,6 +93,19 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Get("/settings/recipe-search", getRecipeSearchSettings(logger, search))
apiV1.Patch("/settings/recipe-search", patchRecipeSearchSettings(logger, search))
apiV1.Post("/settings/recipe-search/test", postRecipeSearchTest(logger, search))
apiV1.Get("/settings/ai", getAISettings(logger, ai))
apiV1.Patch("/settings/ai", patchAISettings(logger, ai))
apiV1.Put("/settings/ai/api-key", putAIKey(logger, ai))
apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai))
apiV1.Get("/settings/ai/models", getAIModels(logger, ai))
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc))
apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc))
apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc))
apiV1.Get("/dinners/{id}/recipe-candidates", getDinnerCandidates(logger, dinnerSvc))
apiV1.Post("/dinners/{id}/ground", postDinnerGround(logger, dinnerSvc))
apiV1.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
media.Get("/photos/{name}", getPhoto(logger, photos))
+73 -12
View File
@@ -16,9 +16,13 @@ import (
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
@@ -42,7 +46,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
"index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
}
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), runner, newTestPhotos(t), assets)
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), runner, newTestPhotos(t), assets)
if err != nil {
t.Fatal(err)
}
@@ -131,24 +135,72 @@ func newTestPhotos(t *testing.T) *recipes.PhotoCache {
// configured, over a migrated temp database and a random secret box.
func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
t.Helper()
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatal(err)
}
search, err := recipes.NewSearchSettingsService(newTestStore(t), box)
search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t), "")
if err != nil {
t.Fatal(err)
}
return search
}
// newTestDinners returns a dinners service over a migrated temp database,
// with its own unstarted job runner and a grounder that finds nothing.
func newTestDinners(t *testing.T) *dinners.Service {
t.Helper()
st := newTestStore(t)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
svc, err := dinners.NewService(st, g, runner, logger)
if err != nil {
t.Fatal(err)
}
return svc
}
// newTestBox returns a secret box under a random key.
func newTestBox(t *testing.T) *secretbox.Box {
t.Helper()
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatal(err)
}
return box
}
// newTestAI returns an AI settings service over a migrated temp database
// with the seed AI settings and a random secret box.
func newTestAI(t *testing.T) *aisettings.Service {
t.Helper()
st := newTestStore(t)
if err := household.SeedAISettings(context.Background(), st); err != nil {
t.Fatal(err)
}
ai, err := aisettings.New(st, newTestBox(t), "https://mealquest.example")
if err != nil {
t.Fatal(err)
}
return ai
}
func TestNewRouterRequiresAISettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil AI settings service")
}
}
func TestNewRouterRequiresSearchSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil recipe search settings service")
}
}
@@ -157,7 +209,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted nil photo files")
}
}
@@ -166,16 +218,25 @@ func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
func TestNewRouterRequiresDinners(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil dinners service")
}
}
func TestNewRouterRequiresJobRunner(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner")
}
}
+19 -4
View File
@@ -9,14 +9,21 @@ import (
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
)
// maxSettingsBody bounds a PATCH /settings/household or
// /settings/recipe-search request body.
// /settings/recipe-search, or PUT /settings/ai/api-key, request body.
const maxSettingsBody = 16 << 10
// maxAISettingsBody bounds a PATCH /settings/ai request body. It fits the
// longest valid system prompt at 12 bytes of JSON per character (a
// \u-escaped surrogate pair) on top of maxSettingsBody for the other
// fields, so only aisettings.MaxSystemPromptChars limits the prompt.
const maxAISettingsBody = maxSettingsBody + 12*aisettings.MaxSystemPromptChars
// settingsPatch is the PATCH /settings/household body. Every field is
// optional; unknown fields and null values are refused.
type settingsPatch struct {
@@ -30,6 +37,9 @@ type settingsPatch struct {
SuggestionsTime *string `json:"suggestions_time"`
ChatAutosendDow *int `json:"chat_autosend_dow"`
ChatAutosendTime *string `json:"chat_autosend_time"`
// OnboardingCompleted true sets onboarding_completed_at, which is
// itself read-only.
OnboardingCompleted *bool `json:"onboarding_completed"`
}
func getHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc {
@@ -103,7 +113,7 @@ func decodePatch[T any](body io.Reader) (T, error) {
if errors.As(err, &typeErr) {
return patch, fmt.Errorf("%s: want a JSON %s", typeErr.Field, typeErr.Type)
}
return patch, fmt.Errorf("invalid settings: %v", err)
return patch, fmt.Errorf("invalid request body: %v", err)
}
return patch, nil
}
@@ -124,13 +134,18 @@ func writeJSON(w http.ResponseWriter, r *http.Request, logger *slog.Logger, stat
// writeError responds with status and a non-retryable API error.
func writeError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, code, message string) {
body, err := json.Marshal(errorBody{Error: apiError{Code: code, Message: message}})
writeAPIError(w, r, logger, status, apiError{Code: code, Message: message})
}
// writeAPIError responds with status and e.
func writeAPIError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, e apiError) {
body, err := json.Marshal(errorBody{Error: e})
if err != nil {
panic(err) // string and bool fields cannot fail to marshal
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if _, err := w.Write(body); err != nil {
logger.WarnContext(r.Context(), "write error response", "code", code, "error", err)
logger.WarnContext(r.Context(), "write error response", "code", e.Code, "error", err)
}
}
+62 -1
View File
@@ -3,6 +3,7 @@ package server
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
@@ -13,6 +14,7 @@ import (
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
@@ -115,6 +117,63 @@ func TestPatchHouseholdSettings(t *testing.T) {
}
}
// newOnboardingRouter returns a router whose settings store holds regulars
// Regulars.
func newOnboardingRouter(t *testing.T, regulars int) http.Handler {
t.Helper()
st := newTestStore(t)
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
t.Fatal(err)
}
for i := range regulars {
now := store.Now().String()
if _, err := db.New(st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
ID: store.NewID(), Name: fmt.Sprintf("Dinner %d", i), NormalizedName: fmt.Sprintf("dinner %d", i),
Status: "regular", Origin: "regular", GroundingStatus: "pending", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
}
return newTestRouterWith(t, "dev", household.NewSettingsService(st), newTestRunner(t))
}
func TestPatchHouseholdSettingsCompletesOnboarding(t *testing.T) {
t.Parallel()
rec, body := serve(t, newOnboardingRouter(t, household.MinRegulars), signedIn(http.MethodPatch, settingsPath,
strings.NewReader(`{"onboarding_completed":true}`)))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
}
if at, ok := body["onboarding_completed_at"].(string); !ok || at != body["updated_at"] {
t.Errorf("onboarding_completed_at = %#v, want the update time %#v", body["onboarding_completed_at"], body["updated_at"])
}
}
// TestPatchHouseholdSettingsOnboardingNeedsRegulars: completing onboarding
// with fewer than three Regulars is refused and leaves it incomplete.
func TestPatchHouseholdSettingsOnboardingNeedsRegulars(t *testing.T) {
t.Parallel()
for regulars := range household.MinRegulars {
t.Run(fmt.Sprintf("%d Regulars", regulars), func(t *testing.T) {
t.Parallel()
router := newOnboardingRouter(t, regulars)
rec, _ := serve(t, router, signedIn(http.MethodPatch, settingsPath,
strings.NewReader(`{"onboarding_completed":true}`)))
if rec.Code != http.StatusBadRequest ||
!strings.Contains(rec.Body.String(), `"code":"invalid_settings"`) ||
!strings.Contains(rec.Body.String(), "onboarding_completed: add at least 3 Regulars first") {
t.Fatalf("PATCH = %d %s, want 400 invalid_settings on onboarding_completed", rec.Code, rec.Body)
}
_, body := serve(t, router, signedIn(http.MethodGet, settingsPath, nil))
if body["onboarding_completed_at"] != nil {
t.Errorf("onboarding_completed_at = %#v after a refused completion, want null", body["onboarding_completed_at"])
}
})
}
}
func TestPatchHouseholdSettingsRejected(t *testing.T) {
t.Parallel()
@@ -136,6 +195,8 @@ func TestPatchHouseholdSettingsRejected(t *testing.T) {
{"null value", `{"timezone":null}`, "invalid_request", "timezone"},
{"unknown field", `{"dinner":"17:30"}`, "invalid_request", "dinner"},
{"read-only field", `{"updated_by":"someone"}`, "invalid_request", "updated_by"},
{"read-only onboarding time", `{"onboarding_completed_at":"2026-10-06T12:00:00.000Z"}`, "invalid_request", "onboarding_completed_at"},
{"onboarding undone", `{"onboarding_completed":false}`, "invalid_settings", "onboarding_completed"},
{"not an object", `["timezone"]`, "invalid_request", "JSON object"},
{"null body", `null`, "invalid_request", "JSON object"},
{"trailing data", `{"dinner_time":"18:00"} {}`, "invalid_request", "JSON object"},
@@ -216,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+4 -4
View File
@@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision", got)
}
}
@@ -196,8 +196,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision", got)
}
if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created")
+5
View File
@@ -0,0 +1,5 @@
-- 0008_dinner_recipe_url: a Regular added from a pasted recipe link keeps
-- that link until grounding resolves it to a recipe (recipe_id). Nothing
-- else records the link: recipes rows exist only for fetched pages.
ALTER TABLE dinners ADD COLUMN recipe_url TEXT;
@@ -0,0 +1,7 @@
-- 0009_dinner_grounding_error: a dinner whose grounding failed keeps why,
-- as one of the reason codes package dinners defines (no_recipe_found,
-- recipe_unusable, search_not_configured, search_failed, ai_failed,
-- interrupted, grounding_error). It is NULL unless grounding_status is
-- failed.
ALTER TABLE dinners ADD COLUMN grounding_error TEXT;
@@ -0,0 +1,7 @@
-- 0010_dinner_grounding_revision: each ground_dinner job carries the
-- dinner's grounding_revision when it was enqueued, and records its result
-- only while the dinner still has that revision. Renaming a pending dinner
-- raises the revision before enqueuing the job for the new name, so an
-- earlier job — queued or running — can no longer write the dinner.
ALTER TABLE dinners ADD COLUMN grounding_revision INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,11 @@
-- 0011_ground_dinner_job_revision: every dinner starts at grounding
-- revision 0 (0010), so a ground_dinner job enqueued before 0010, whose
-- payload has only dinner_id, is for revision 0. Recording it lets the boot
-- cleanup, which matches a pending dinner to its job by revision, see a job
-- still queued for its dinner. This is a migration of its own, not part of
-- 0010, so it also runs on a database that has already applied 0010.
UPDATE jobs
SET payload = json_set(payload, '$.revision', 0)
WHERE kind = 'ground_dinner'
AND json_type(payload, '$.revision') IS NULL;
+114 -3
View File
@@ -1,12 +1,12 @@
-- name: CreateDinner :one
INSERT INTO dinners (
id, name, normalized_name, status, origin, method, minutes_total,
difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status,
updated_by, created_at, updated_at
difficulty, cuisine, is_favorite, recipe_id, recipe_url, photo_id,
grounding_status, updated_by, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?,
?, ?, ?
?, ?, ?, ?
)
RETURNING *;
@@ -19,6 +19,20 @@ SELECT * FROM dinners
WHERE status = ?
ORDER BY normalized_name, id;
-- name: CountRegularDinners :one
-- How many Regulars the household has; archived dinners are not counted.
SELECT COUNT(*) FROM dinners
WHERE status = 'regular';
-- name: ListUnarchivedDinners :many
-- Every dinner a new or renamed dinner must not repeat, with the
-- source_url of the recipe it is grounded in (NULL while it has none).
SELECT sqlc.embed(dinners), recipes.source_url AS recipe_source_url
FROM dinners
LEFT JOIN recipes ON recipes.id = dinners.recipe_id
WHERE dinners.status <> 'archived'
ORDER BY dinners.id;
-- name: UpdateDinner :one
-- An unknown id returns sql.ErrNoRows.
UPDATE dinners
@@ -31,8 +45,10 @@ SET name = ?,
difficulty = ?,
cuisine = ?,
recipe_id = ?,
recipe_url = ?,
photo_id = ?,
grounding_status = ?,
grounding_revision = ?,
updated_by = ?,
updated_at = ?
WHERE id = ?
@@ -55,3 +71,98 @@ SET is_favorite = ?,
updated_at = ?
WHERE id = ?
RETURNING *;
-- name: ToggleDinnerFavorite :one
-- An unknown id returns sql.ErrNoRows.
UPDATE dinners
SET is_favorite = 1 - is_favorite,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING *;
-- name: GroundDinner :one
-- Records a dinner's grounding by hand (grounded or manual) and clears any
-- grounding_error and pasted recipe_url: the recipe chosen replaces the
-- link. It writes only an unarchived dinner still as it was observed
-- before grounding (same name, normalized_name, grounding_status,
-- grounding_revision and recipe_id), so a dinner renamed, archived or
-- grounded some other way meanwhile is left as it is. That dinner, or an
-- unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
-- no longer as observed is kept: a member edited it while it was grounding.
UPDATE dinners
SET recipe_id = sqlc.arg(recipe_id),
recipe_url = NULL,
photo_id = sqlc.arg(photo_id),
method = CASE WHEN method IS sqlc.narg(observed_method)
THEN sqlc.arg(method) ELSE method END,
minutes_total = CASE WHEN minutes_total IS sqlc.narg(observed_minutes_total)
THEN sqlc.arg(minutes_total) ELSE minutes_total END,
difficulty = CASE WHEN difficulty IS sqlc.narg(observed_difficulty)
THEN sqlc.arg(difficulty) ELSE difficulty END,
grounding_status = sqlc.arg(grounding_status),
grounding_error = NULL,
updated_by = sqlc.arg(updated_by),
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id)
AND status <> 'archived'
AND name = sqlc.arg(observed_name)
AND normalized_name = sqlc.arg(observed_normalized_name)
AND grounding_status = sqlc.arg(observed_grounding_status)
AND grounding_revision = sqlc.arg(observed_grounding_revision)
AND recipe_id IS sqlc.narg(observed_recipe_id)
RETURNING *;
-- name: GroundPendingDinner :execrows
-- Records the background grounding of an unarchived dinner still pending at
-- the grounding_revision it was grounded for; a dinner grounded some other
-- way, renamed or archived meanwhile is left as it is (0 rows). A method,
-- minutes_total or difficulty already set is kept: a pending dinner has
-- them only when a member edited them while it was grounding.
UPDATE dinners
SET recipe_id = sqlc.arg(recipe_id),
photo_id = sqlc.arg(photo_id),
method = COALESCE(method, sqlc.arg(method)),
minutes_total = COALESCE(minutes_total, sqlc.arg(minutes_total)),
difficulty = COALESCE(difficulty, sqlc.arg(difficulty)),
grounding_status = 'grounded',
grounding_error = NULL,
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id)
AND status <> 'archived'
AND grounding_status = 'pending'
AND grounding_revision = sqlc.arg(grounding_revision);
-- name: FailPendingDinnerGrounding :execrows
-- Records why the background grounding of an unarchived dinner still
-- pending at the grounding_revision it was grounded for failed; a dinner
-- grounded some other way, renamed or archived meanwhile is left as it is
-- (0 rows).
UPDATE dinners
SET grounding_status = 'failed',
grounding_error = ?,
updated_at = ?
WHERE id = ?
AND status <> 'archived'
AND grounding_status = 'pending'
AND grounding_revision = ?;
-- name: FailOrphanedPendingDinnerGroundings :execrows
-- Fails every unarchived dinner still pending whose grounding job for its
-- current grounding_revision is no longer queued or running: the process
-- stopped before the job, or before its handler recorded a result. An
-- archived dinner is left as it is; archiving stopped its grounding. Run at
-- boot, after the job runner fails the jobs a previous process left running.
UPDATE dinners
SET grounding_status = 'failed',
grounding_error = sqlc.arg(grounding_error),
updated_at = sqlc.arg(updated_at)
WHERE grounding_status = 'pending'
AND status <> 'archived'
AND NOT EXISTS (
SELECT 1 FROM jobs
WHERE jobs.kind = sqlc.arg(kind)
AND jobs.status IN ('queued', 'running')
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
AND json_extract(jobs.payload, '$.revision') = dinners.grounding_revision
);
+12
View File
@@ -69,3 +69,15 @@ ORDER BY position, id;
-- name: DeleteRecipeSteps :exec
DELETE FROM recipe_steps
WHERE recipe_id = ?;
-- name: CreateManualRecipe :one
-- A recipe the household typed in. Its source_url is a mealquest:manual:
-- URN rather than a page, so it never matches a fetched page.
INSERT INTO recipes (
id, source_url, title, total_minutes, raw_jsonld, method,
minutes_total, fetched_at, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?,
?, ?, ?, ?
)
RETURNING *;
+126 -13
View File
@@ -1,11 +1,13 @@
import { act, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApiClient, createVerifiedSession } from './api/index.ts'
import { createNetworkStatus } from './pwa/networkStatus.ts'
import { mockViewport } from './test/matchMedia.ts'
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts'
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
import App, { routes } from './App.tsx'
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
import { InDataRouter } from './test/dataRouter.tsx'
import { dinnerBody } from './test/dinners.ts'
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
@@ -19,12 +21,28 @@ function renderAt(path: string, location = fakeLocation(path)) {
function renderAppAt(path: string, location = fakeLocation(path)) {
return render(
<MemoryRouter initialEntries={[path]}>
<InDataRouter path={path}>
<App api={createApiClient(location, createNetworkStatus(window), createVerifiedSession(localStorage))} serviceWorker={undefined} />
</MemoryRouter>,
</InDataRouter>,
)
}
// The routes that render a built screen rather than a placeholder page.
const SCREENS = {
'/onboarding': true,
'/settings': true,
'/settings/ai': true,
'/settings/household': true,
'/settings/regulars': true,
}
// SIGNED_IN answers what every route reads first: the session, then the
// household settings, onboarding finished.
const SIGNED_IN = {
'/api/v1/me': () => jsonResponse(200, ME),
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
}
describe('App routes', () => {
it('registers every handoff route plus the extra settings pages', () => {
expect(routes.map((route) => route.path)).toEqual([
@@ -46,23 +64,113 @@ describe('App routes', () => {
])
})
it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
it.each(routes.filter(({ path }) => !Object.hasOwn(SCREENS, path)).map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
'%s renders its placeholder page inside the shell',
async (path, title) => {
mockViewport(390)
stubFetch(jsonResponse(200, ME))
stubFetchByPath(SIGNED_IN)
renderAt(path)
const heading = await screen.findByRole('heading', { level: 1, name: title })
expect(screen.getByRole('main')).toContainElement(heading)
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
},
)
it('/settings renders the settings home inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
'/api/v1/me': () => jsonResponse(200, ME),
...SETTINGS_API,
})
renderAt('/settings')
const heading = await screen.findByRole('heading', { level: 1, name: 'Settings' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
})
it('/settings/ai renders the AI assistant screen inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
...SIGNED_IN,
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }),
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
})
renderAt('/settings/ai')
const heading = await screen.findByRole('heading', { level: 1, name: 'AI assistant' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument()
})
it('/settings/household renders the household settings screen inside the shell', async () => {
mockViewport(390)
stubFetchByPath(SIGNED_IN)
renderAt('/settings/household')
const heading = await screen.findByRole('heading', { level: 1, name: 'Household' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByLabelText('Dinner time')).toHaveValue('17:30')
})
it('/settings/regulars renders the Regular dinners screen inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
...SIGNED_IN,
'/api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: [dinnerBody('d1', 'Beef tacos')] }),
})
renderAt('/settings/regulars')
const heading = await screen.findByRole('heading', { level: 1, name: 'Regular dinners' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
})
})
describe('App first-run redirect', () => {
const FIRST_RUN = {
'/api/v1/me': () => jsonResponse(200, ME),
'/api/v1/settings/household': () => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }),
}
it.each(routes.filter(({ path }) => path !== '/onboarding').map(({ path }) => path.replace(':id', 'abc123')))(
'%s redirects to /onboarding while onboarding is not finished',
async (path) => {
mockViewport(390)
stubFetchByPath(FIRST_RUN)
renderAt(path)
expect(await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })).toBeInTheDocument()
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
},
)
it('stays on an onboarding step', async () => {
mockViewport(390)
stubFetchByPath({ ...FIRST_RUN, '/api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: [] }) })
renderAt('/onboarding/regulars')
expect(await screen.findByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument()
})
it('opens the routes once onboarding is finished', async () => {
mockViewport(390)
stubFetchByPath(SIGNED_IN)
renderAt('/plan')
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
it('reports a household settings failure with a retry instead of rendering the routes', async () => {
mockViewport(390)
stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS))
renderAt('/plan')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
screen.getByRole('button', { name: 'Retry' }).click()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
})
describe('App bootstrap', () => {
it('shows the skeleton until /me loads, then the routes', async () => {
mockViewport(390)
const fetchMock = stubFetch(jsonResponse(200, ME))
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
renderAt('/plan')
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
@@ -96,7 +204,7 @@ describe('App bootstrap', () => {
it('reports a /me failure with a retry instead of rendering the routes', async () => {
mockViewport(390)
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME))
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
renderAt('/')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
@@ -116,7 +224,7 @@ describe('App offline bootstrap', () => {
// visitOnline is an earlier online visit on this device: /me answers with a
// member, then the app is closed.
async function visitOnline() {
stubFetch(jsonResponse(200, ME))
stubFetchByPath(SIGNED_IN)
const { unmount } = renderAt('/')
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
unmount()
@@ -177,19 +285,24 @@ describe('App offline bootstrap', () => {
goOffline()
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<MemoryRouter initialEntries={['/plan']}>
<InDataRouter path="/plan">
<App api={api} serviceWorker={undefined} />
</MemoryRouter>,
</InDataRouter>,
)
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
const fetchMock = stubFetch(jsonResponse(200, ME))
const fetchMock = stubFetchByPath(SIGNED_IN)
onLine = true
act(() => {
window.dispatchEvent(new Event('online'))
})
await vi.waitFor(() => expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' }))
await vi.waitFor(() =>
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
'/api/v1/me',
'/api/v1/settings/household',
]),
)
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
+29 -7
View File
@@ -1,11 +1,17 @@
import { useState } from 'react'
import { useState, type ReactNode } from 'react'
import { QueryClientProvider } from '@tanstack/react-query'
import { Route, Routes } from 'react-router'
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
import { AuthBootstrap } from './AuthBootstrap.tsx'
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
import { OfflinePill } from './pwa/OfflinePill.tsx'
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
import { AISettingsPage } from './settings/AISettingsPage.tsx'
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
import { SettingsPage } from './settings/SettingsPage.tsx'
import { AppShell } from './shell/AppShell.tsx'
// Every route the app serves, with the heading its placeholder page shows.
@@ -27,14 +33,29 @@ export const routes = [
{ path: '/settings/calendar', title: 'Calendar' },
] as const
// The screens built so far; every other route shows its placeholder page.
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
'/settings/household': <HouseholdSettingsPage />,
'/settings/regulars': <RegularsSettingsPage />,
}
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
// Onboarding is a full screen of its own, outside the shell; until it is
// finished, OnboardingGate sends every other route to it.
export function AppRoutes() {
return (
<Routes>
<Route element={<AppShell />}>
{routes.map(({ path, title }) => (
<Route key={path} path={path} element={<PlaceholderPage title={title} />} />
))}
<Route element={<OnboardingGate />}>
<Route path={`${ONBOARDING_PATH}/*`} element={<OnboardingPage />} />
<Route element={<AppShell />}>
{routes
.filter(({ path }) => path !== ONBOARDING_PATH)
.map(({ path, title }) => (
<Route key={path} path={path} element={screens[path] ?? <PlaceholderPage title={title} />} />
))}
</Route>
</Route>
</Routes>
)
@@ -50,8 +71,9 @@ function reloadPage() {
window.location.reload()
}
// App expects to be rendered inside a router (BrowserRouter in main.tsx,
// MemoryRouter in tests).
// App expects to be rendered by a data router's splat route
// (createBrowserRouter in main.tsx, createMemoryRouter in tests), so pages can
// block navigation.
export default function App({ api, serviceWorker }: AppProps) {
const [queryClient] = useState(createQueryClient)
return (
+5
View File
@@ -41,6 +41,11 @@ export function AuthBootstrap({ children }: { children: ReactNode }) {
</main>
)
}
return <LoadingScreen />
}
// LoadingScreen is the full-screen skeleton shown while MealQuest loads.
export function LoadingScreen() {
return (
<main className={styles.screen} aria-busy="true" aria-label="Loading MealQuest">
<div className={styles.loading}>
+334
View File
@@ -0,0 +1,334 @@
import { useIsMutating, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
export const DINNERS_PATH = '/api/v1/dinners'
export const REGULARS_PATH = `${DINNERS_PATH}?status=regular`
// GROUNDING_POLL_MS is how often the Regulars are read again while any of
// them is still finding its recipe.
export const GROUNDING_POLL_MS = 3000
// Methods and Difficulties are the values the dinners API allows.
export const METHODS = ['stovetop', 'oven', 'crockpot', 'grill', 'other'] as const
export const DIFFICULTIES = ['easy', 'medium', 'hard'] as const
const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const
export type Method = (typeof METHODS)[number]
export type Difficulty = (typeof DIFFICULTIES)[number]
export type GroundingStatus = (typeof GROUNDING_STATUSES)[number]
// Regular is the part of a dinner the Regulars screens read. photo_id is
// the stored photo's id, from photo_url. pending is true while an
// optimistic add waits for the server; its id is not the server's.
export type Regular = {
id: string
name: string
method: Method | null
minutes_total: number | null
difficulty: Difficulty | null
is_favorite: boolean
photo_id: string | undefined
grounding_status: GroundingStatus
pending?: true
}
// NewRegular is a POST /api/v1/dinners body: a name or a recipe link.
export type NewRegular = { name: string } | { recipe_url: string }
// RegularPatch is a PATCH /api/v1/dinners/{id} body; every field is optional.
export type RegularPatch = Partial<{ name: string; method: Method; minutes_total: number; difficulty: Difficulty }>
// ManualRecipe is a recipe the household types in; the server needs its
// minutes and method.
export type ManualRecipe = { ingredients: string[]; steps: string[]; minutes: number; method: Method }
// RecipeFix is a POST /api/v1/dinners/{id}/ground body: exactly one way to
// ground the dinner.
export type RecipeFix = { candidate_id: string } | { recipe_url: string } | { manual: ManualRecipe }
// RecipeCandidate is one recipe page a dinner could be grounded in.
export type RecipeCandidate = {
candidate_id: string
title: string
site_name: string
photo_id: string | undefined
minutes: number | null
}
// CachedRegular is a Regular as the cache holds it. removing is true while
// an optimistic removal waits for the server: the entry keeps its place, so
// a refused removal returns exactly there whatever else changed meanwhile.
type CachedRegular = Regular & { removing?: true }
function shown(list: CachedRegular[]): Regular[] {
return list.filter((regular) => !regular.removing)
}
function restored(regular: CachedRegular): CachedRegular {
const copy = { ...regular }
delete copy.removing
return copy
}
// REGULARS_KEY prefixes every Regulars query, so a change refreshes them all
// (the settings home's count included).
export const REGULARS_KEY = ['dinners', 'regular'] as const
const REGULARS_LIST_KEY = [...REGULARS_KEY, 'list'] as const
const REGULARS_MUTATION_KEY = ['dinners', 'regular', 'change'] as const
function oneOf<T extends string>(where: string, field: string, values: readonly T[], value: unknown): T {
if (typeof value !== 'string' || !(values as readonly string[]).includes(value)) {
throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not one of ${values.join(', ')}`)
}
return value as T
}
function nullable<T>(value: unknown, parse: (value: unknown) => T): T | null {
return value === null ? null : parse(value)
}
function wholeNumber(where: string, field: string, value: unknown): number {
if (typeof value !== 'number' || !Number.isInteger(value)) throw new Error(`${where}: ${field} is not a whole number`)
return value
}
const PHOTO_URL = /^\/media\/photos\/([^/]+)-400\.jpg$/
// photoId reads the photo id out of a photo_url, which the server always
// sends as /media/photos/{id}-400.jpg or null.
function photoId(where: string, value: unknown): string | undefined {
if (value === null) return undefined
const match = typeof value === 'string' ? PHOTO_URL.exec(value) : null
if (!match?.[1]) throw new Error(`${where}: photo_url ${JSON.stringify(value)} is not a stored photo`)
return decodeURIComponent(match[1])
}
function parseDinner(where: string, body: unknown): Regular {
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
throw new Error(`${where}: response is not a dinner`)
}
if (typeof body.is_favorite !== 'boolean') throw new Error(`${where}: is_favorite is not a boolean`)
return {
id: body.id,
name: body.name,
method: nullable(body.method, (v) => oneOf(where, 'method', METHODS, v)),
minutes_total: nullable(body.minutes_total, (v) => wholeNumber(where, 'minutes_total', v)),
difficulty: nullable(body.difficulty, (v) => oneOf(where, 'difficulty', DIFFICULTIES, v)),
is_favorite: body.is_favorite,
photo_id: photoId(where, body.photo_url),
grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status),
}
}
function parseCandidate(where: string, body: unknown): RecipeCandidate {
if (
!isRecord(body) ||
typeof body.candidate_id !== 'string' ||
typeof body.title !== 'string' ||
typeof body.site_name !== 'string'
) {
throw new Error(`${where}: response is not a recipe candidate`)
}
return {
candidate_id: body.candidate_id,
title: body.title,
site_name: body.site_name,
photo_id: photoId(where, body.photo_url),
minutes: nullable(body.minutes, (v) => wholeNumber(where, 'minutes', v)),
}
}
function dinnerPath(id: string): string {
return `${DINNERS_PATH}/${encodeURIComponent(id)}`
}
// useRegulars is the household's Regulars, in the server's order. While any
// of them is still finding its recipe it is read again every
// GROUNDING_POLL_MS, except while any change is in flight (whose settling
// reads it again anyway), so a poll never overwrites a change being saved.
// Watching the changes in flight re-renders the hook as each one starts and
// settles, which pauses and resumes the poll.
export function useRegulars() {
const { apiFetch } = useApi()
const changing = useIsMutating({ mutationKey: REGULARS_MUTATION_KEY }) > 0
return useQuery({
queryKey: REGULARS_LIST_KEY,
queryFn: async () => {
const where = `GET ${REGULARS_PATH}`
const body = await apiFetch<unknown>(REGULARS_PATH)
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
return body.dinners.map((dinner): CachedRegular => parseDinner(where, dinner))
},
select: shown,
refetchInterval: (query) => {
const list = query.state.data
if (list === undefined || changing) return false
return list.some((regular) => regular.grounding_status === 'pending') ? GROUNDING_POLL_MS : false
},
})
}
// changeOptimistically stops Regulars reads in flight, so they cannot
// overwrite the optimistic list, then applies change to it.
async function changeOptimistically(queryClient: QueryClient, change: (list: CachedRegular[]) => CachedRegular[]) {
await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY })
setRegulars(queryClient, change)
}
function setRegulars(queryClient: QueryClient, change: (list: CachedRegular[]) => CachedRegular[]) {
queryClient.setQueryData<CachedRegular[]>(REGULARS_LIST_KEY, (list) => change(list ?? []))
}
// replace puts the server's answer for a dinner in its place in the list.
function replace(queryClient: QueryClient, dinner: Regular) {
setRegulars(queryClient, (list) => list.map((regular) => (regular.id === dinner.id ? dinner : regular)))
}
// settle refetches the Regulars once the last change in flight is done, so
// an earlier change's refetch never drops a later change's optimistic entry.
function settle(queryClient: QueryClient) {
if (queryClient.isMutating({ mutationKey: REGULARS_MUTATION_KEY }) === 1) {
void queryClient.invalidateQueries({ queryKey: REGULARS_KEY })
}
}
let pendingIds = 0
// useAddRegular adds a Regular by name or recipe link (POST
// /api/v1/dinners). It shows in the list at once — a link under the link
// itself until the server names it — and is taken out again if the server
// refuses it.
export function useAddRegular() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationKey: REGULARS_MUTATION_KEY,
mutationFn: async (add: NewRegular) =>
parseDinner(`POST ${DINNERS_PATH}`, await apiFetch<unknown>(DINNERS_PATH, { method: 'POST', body: add })),
onMutate: async (add) => {
const id = `pending-${++pendingIds}`
const optimistic: CachedRegular = {
id,
name: 'name' in add ? add.name : add.recipe_url,
method: null,
minutes_total: null,
difficulty: null,
is_favorite: false,
photo_id: undefined,
grounding_status: 'pending',
pending: true,
}
await changeOptimistically(queryClient, (list) => [...list, optimistic])
return { id }
},
onSuccess: (dinner, _add, context) => {
setRegulars(queryClient, (list) => list.map((regular) => (regular.id === context.id ? dinner : regular)))
},
onError: (_error, _add, context) => {
if (context === undefined) return
setRegulars(queryClient, (list) => list.filter((regular) => regular.id !== context.id))
},
onSettled: () => settle(queryClient),
})
}
// useRemoveRegular archives a Regular (DELETE /api/v1/dinners/{id}). It
// leaves the list at once and is put back in its place if the server
// refuses.
export function useRemoveRegular() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationKey: REGULARS_MUTATION_KEY,
mutationFn: async (regular: Regular) => {
await apiFetch<unknown>(dinnerPath(regular.id), { method: 'DELETE' })
},
onMutate: async (regular) => {
await changeOptimistically(queryClient, (list) =>
list.map((r): CachedRegular => (r.id === regular.id ? { ...r, removing: true } : r)),
)
},
onSuccess: (_data, regular) => {
setRegulars(queryClient, (list) => list.filter((r) => r.id !== regular.id))
},
onError: (_error, regular) => {
setRegulars(queryClient, (list) => list.map((r) => (r.id === regular.id ? restored(r) : r)))
},
onSettled: () => settle(queryClient),
})
}
// useEditRegular saves changes to a Regular (PATCH /api/v1/dinners/{id})
// and shows the server's answer once it is saved.
export function useEditRegular() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationKey: REGULARS_MUTATION_KEY,
mutationFn: async ({ id, patch }: { id: string; patch: RegularPatch }) =>
parseDinner(`PATCH ${DINNERS_PATH}/{id}`, await apiFetch<unknown>(dinnerPath(id), { method: 'PATCH', body: patch })),
onSuccess: (dinner) => replace(queryClient, dinner),
onSettled: () => settle(queryClient),
})
}
// useToggleFavorite toggles a Regular's favorite heart (POST
// /api/v1/dinners/{id}/favorite). The heart changes at once and changes
// back if the server refuses.
export function useToggleFavorite() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
const set = (id: string, is_favorite: boolean) =>
setRegulars(queryClient, (list) => list.map((r) => (r.id === id ? { ...r, is_favorite } : r)))
return useMutation({
mutationKey: REGULARS_MUTATION_KEY,
mutationFn: async (regular: Regular) =>
parseDinner(
`POST ${DINNERS_PATH}/{id}/favorite`,
await apiFetch<unknown>(`${dinnerPath(regular.id)}/favorite`, { method: 'POST' }),
),
onMutate: async (regular) => {
await changeOptimistically(queryClient, (list) =>
list.map((r) => (r.id === regular.id ? { ...r, is_favorite: !regular.is_favorite } : r)),
)
},
onSuccess: (dinner) => replace(queryClient, dinner),
onError: (_error, regular) => set(regular.id, regular.is_favorite),
onSettled: () => settle(queryClient),
})
}
// useGroundRegular fixes a Regular's recipe (POST /api/v1/dinners/{id}/ground)
// with a candidate, a pasted link or a recipe typed in. A candidate or link
// is read in the request, which can take up to a minute and a half.
export function useGroundRegular() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationKey: REGULARS_MUTATION_KEY,
mutationFn: async ({ id, fix }: { id: string; fix: RecipeFix }) =>
parseDinner(
`POST ${DINNERS_PATH}/{id}/ground`,
await apiFetch<unknown>(`${dinnerPath(id)}/ground`, { method: 'POST', body: fix }),
),
onSuccess: (dinner) => replace(queryClient, dinner),
onSettled: () => settle(queryClient),
})
}
// useRecipeCandidates is up to three recipe pages the dinner could be
// grounded in, best first (GET /api/v1/dinners/{id}/recipe-candidates).
export function useRecipeCandidates(id: string) {
const { apiFetch } = useApi()
return useQuery({
queryKey: ['dinners', 'candidates', id],
queryFn: async () => {
const path = `${dinnerPath(id)}/recipe-candidates`
const where = `GET ${DINNERS_PATH}/{id}/recipe-candidates`
const body = await apiFetch<unknown>(path)
if (!isRecord(body) || !Array.isArray(body.candidates)) throw new Error(`${where}: response has no candidates list`)
return body.candidates.map((candidate) => parseCandidate(where, candidate))
},
})
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import cases from '../../../internal/ai/harness/testdata/dedupe_cases.json'
import { normalizeName, similarNames } from './names.ts'
// The cases internal/ai/harness's tests run against Normalize and Similar.
describe('dinner names, as the server compares them', () => {
it('has cases to run', () => {
expect(cases.normalize.length).toBeGreaterThan(0)
expect(cases.similar.length).toBeGreaterThan(0)
})
it.each(cases.normalize)('normalizes "$name" to "$want"', ({ name, want }) => {
expect(normalizeName(name)).toBe(want)
})
it.each(cases.similar)('"$a" and "$b" similar: $want', ({ a, b, want }) => {
expect(similarNames(a, b)).toBe(want)
expect(similarNames(b, a)).toBe(want)
})
})
+58
View File
@@ -0,0 +1,58 @@
// The PWA's copy of the server's dinner-name rules (internal/ai/harness
// Normalize and Similar), so a screen can tell beforehand that the dinners
// API would refuse a name as a duplicate. The server still decides.
// internal/ai/harness/testdata/dedupe_cases.json is run against both.
import folds from './letterFolds.json'
// SIMILAR_THRESHOLD is harness.SimilarThreshold.
const SIMILAR_THRESHOLD = 0.85
// fillerWords is harness's fillerWords.
const fillerWords = new Set(['easy', 'best', 'homemade', 'quick', 'simple', 'the', 'a', 'recipe', 'classic'])
// letterFolds is harness's letterFolds: gen_folds.py writes both tables,
// and harness's TestLetterFoldsMatchPWA fails when they differ.
const letterFolds: Record<string, string> = folds
const combiningMark = /\p{Mn}/u
const wordChar = /[\p{L}\p{Nd}]/u
// normalizeName is harness.Normalize: lowercased, accents dropped, letters
// folded, apostrophes removed, other punctuation turned into spaces and
// filler words dropped. "Easy Chicken Tikka-Masala!" is
// "chicken tikka masala".
export function normalizeName(name: string): string {
let out = ''
// Lowercased letter by letter, as Go's strings.ToLower does, so no
// letter is lowered by its context (a final Σ is σ, not ς).
for (const letter of name) {
for (const ch of letter.toLowerCase()) {
if (combiningMark.test(ch) || ch === "'" || ch === '’') continue
const fold = letterFolds[ch]
if (fold !== undefined) out += fold
else if (wordChar.test(ch)) out += ch
else out += ' '
}
}
return out
.split(' ')
.filter((word) => word !== '' && !fillerWords.has(word))
.join(' ')
}
function words(name: string): Set<string> {
const normalized = normalizeName(name)
return new Set(normalized === '' ? [] : normalized.split(' '))
}
// similarNames is harness.Similar: whether a and b name the same dinner, by
// the share of the words of their normalized forms they have in common.
export function similarNames(a: string, b: string): boolean {
const left = words(a)
const right = words(b)
if (left.size === 0 || right.size === 0) return false
let shared = 0
for (const word of left) if (right.has(word)) shared++
return shared / (left.size + right.size - shared) >= SIMILAR_THRESHOLD
}
+26
View File
@@ -0,0 +1,26 @@
import { useRef, useState } from 'react'
// Refusal is one change the server turned down. key is unique to that
// attempt, so two attempts of the same change in flight each keep their own
// refusal. change names what was tried, so trying it again once it has been
// refused clears the earlier refusal and no other.
export type Refusal = { key: number; change: string; text: string }
// useRefusals keeps each change's own refusal: the mutation hooks only
// report their latest change, which would hide a refusal while another
// change is in flight. track(change, what, answer) clears change's earlier
// refusal and records "Could not {what}: …" if answer rejects.
export function useRefusals() {
const [refusals, setRefusals] = useState<Refusal[]>([])
const attempts = useRef(0)
function track(change: string, what: string, answer: Promise<unknown>) {
const key = ++attempts.current
setRefusals((all) => all.filter((refusal) => refusal.change !== change))
answer.catch((error: Error) => {
setRefusals((all) => [...all, { key, change, text: `Could not ${what}: ${error.message}` }])
})
}
return { refusals, track }
}
+11 -4
View File
@@ -1,6 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import { createBrowserRouter, RouterProvider } from 'react-router'
import './styles/tokens.css'
import './styles/base.css'
import App from './App.tsx'
@@ -14,10 +14,17 @@ if (!root) {
const api = createApiClient(window.location, createNetworkStatus(window), createVerifiedSession(window.localStorage))
// A data router, so pages can block navigation (useBlocker); App's own
// <Routes> match every path under its splat route.
const router = createBrowserRouter([
{
path: '*',
element: <App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />,
},
])
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />
</BrowserRouter>
<RouterProvider router={router} />
</StrictMode>,
)
@@ -0,0 +1,301 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { Route, Routes } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { createApiClient, createVerifiedSession } from '../api/index.ts'
import App from '../App.tsx'
import { AuthBootstrap } from '../AuthBootstrap.tsx'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { NotificationsStep, notificationsUnavailable, type EnableNotifications } from './NotificationsStep.tsx'
import styles from './Onboarding.module.css'
const IPHONE_SAFARI =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1'
const COMPLETED_AT = '2026-10-06T21:30:00.000Z'
// requestPermission stands in for the browser's Notification API, which
// jsdom lacks, so a test can prove nothing asks for permission.
let requestPermission: Mock<() => Promise<NotificationPermission>>
beforeEach(() => {
requestPermission = vi.fn<() => Promise<NotificationPermission>>(() => Promise.resolve('granted'))
vi.stubGlobal('Notification', { permission: 'default', requestPermission })
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
Reflect.deleteProperty(navigator, 'maxTouchPoints')
Reflect.deleteProperty(navigator, 'standalone')
localStorage.clear()
})
// A PATCH the fake server holds until the test answers it.
type Held = { body: unknown; answer: (response: Response) => void }
// stubFirstRunServer stands in for the server on first run: signed in,
// onboarding not finished. Each PATCH of the household settings is held in
// `held` until the test answers it.
function stubFirstRunServer() {
const held: Held[] = []
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const path = String(input)
const method = init?.method ?? 'GET'
if (method === 'GET' && path === '/api/v1/me') return Promise.resolve(jsonResponse(200, ME))
if (method === 'GET' && path === '/api/v1/settings/household') {
return Promise.resolve(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }))
}
if (method === 'PATCH' && path === '/api/v1/settings/household') {
return new Promise<Response>((resolve) => {
held.push({ body: JSON.parse(String(init?.body)), answer: resolve })
})
}
throw new Error(`unexpected fetch: ${method} ${path}`)
})
vi.stubGlobal('fetch', fetchMock)
return { fetchMock, held }
}
function completed(request: Held) {
request.answer(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: COMPLETED_AT }))
}
// renderApp renders the whole app at step 3, so the first-run redirect is
// what decides where finishing lands.
async function renderApp() {
mockViewport(390)
const server = stubFirstRunServer()
const api = createApiClient(
fakeLocation('/onboarding/notifications'),
createNetworkStatus(window),
createVerifiedSession(localStorage),
)
render(
<InDataRouter path="/onboarding/notifications">
<App api={api} serviceWorker={undefined} />
</InDataRouter>,
)
await screen.findByRole('heading', { level: 1, name: 'Stay in the loop' })
return server
}
// renderStep renders step 3 alone with the given enableNotifications, and a
// Home that says it was reached. AuthBootstrap reads /api/v1/me for the CSRF
// token every write needs.
async function renderStep(enableNotifications?: EnableNotifications) {
const server = stubFirstRunServer()
render(
<InDataRouter path="/onboarding/notifications">
<ApiProviders>
<AuthBootstrap>
<Routes>
<Route
path="/onboarding/notifications"
element={<NotificationsStep enableNotifications={enableNotifications} />}
/>
<Route path="/" element={<h1>Home</h1>} />
<Route path="/onboarding/regulars" element={<h1>Regulars step</h1>} />
</Routes>
</AuthBootstrap>
</ApiProviders>
</InDataRouter>,
)
await screen.findByRole('heading', { level: 1, name: 'Stay in the loop' })
return server
}
function turnOn() {
return screen.getByRole('button', { name: 'Turn on notifications' })
}
function later() {
return screen.getByRole('button', { name: 'Maybe later' })
}
describe('onboarding step 3', () => {
it('explains what MealQuest notifies about, with all three progress pills filled', async () => {
await renderApp()
expect(screen.getByText('Step 3 of 3')).toBeInTheDocument()
const pills = Array.from(document.getElementsByClassName(styles.pill!))
expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, true, true])
const topics = within(screen.getByRole('list')).getAllByRole('listitem')
expect(topics.map((topic) => topic.textContent)).toEqual([
expect.stringMatching(/^Plan ready/),
expect.stringMatching(/^Supply run ready/),
expect.stringMatching(/^Tonight's dinner/),
expect.stringMatching(/^New Side quests/),
])
expect(turnOn()).toBeEnabled()
expect(later()).toBeEnabled()
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
})
it('never asks for notification permission on render', async () => {
await renderApp()
expect(requestPermission).not.toHaveBeenCalled()
})
it('sizes both actions as tap targets and fixes them to the bottom', async () => {
await renderApp()
expectMinHitArea(turnOn())
expectMinHitArea(later())
expect(declaredStyle(turnOn()).height).toBe('52px')
expect(declaredStyle(later()).height).toBe('52px')
expect(declaredStyle(turnOn().parentElement!.parentElement!).position).toBe('fixed')
})
it('shows no install hint outside iOS Safari', async () => {
await renderApp()
expect(screen.queryByText('Tap Share, then Add to Home Screen to get notifications')).not.toBeInTheDocument()
})
it('shows the install hint in an iOS Safari tab', async () => {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(IPHONE_SAFARI)
Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 })
Object.defineProperty(navigator, 'standalone', { configurable: true, value: false })
vi.stubGlobal('matchMedia', (query: string) => ({ matches: false, media: query }) as MediaQueryList)
await renderStep()
expect(screen.getByRole('note')).toHaveTextContent('Tap Share, then Add to Home Screen to get notifications')
})
it('"Maybe later" finishes onboarding and goes Home, ending the first-run redirect', async () => {
const server = await renderApp()
fireEvent.click(later())
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
expect(turnOn()).toBeDisabled()
expect(later()).toBeDisabled()
completed(server.held[0]!)
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
expect(requestPermission).not.toHaveBeenCalled()
})
it('"Turn on notifications" finishes onboarding and goes Home while notifications are unavailable', async () => {
const server = await renderApp()
fireEvent.click(turnOn())
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
completed(server.held[0]!)
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
expect(requestPermission).not.toHaveBeenCalled()
})
it('"Turn on notifications" asks the injected enableNotifications first, then finishes', async () => {
let answer: (result: 'enabled') => void = () => {}
const enable = vi.fn<EnableNotifications>(
() =>
new Promise((resolve) => {
answer = resolve
}),
)
const server = await renderStep(enable)
expect(enable).not.toHaveBeenCalled()
fireEvent.click(turnOn())
expect(enable).toHaveBeenCalledOnce()
expect(turnOn()).toBeDisabled()
expect(later()).toBeDisabled()
// Onboarding is not finished until enableNotifications has answered.
await Promise.resolve()
expect(server.held).toHaveLength(0)
answer('enabled')
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
completed(server.held[0]!)
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
})
it('"Maybe later" does not ask the injected enableNotifications', async () => {
const enable = vi.fn<EnableNotifications>(() => Promise.resolve('enabled'))
const server = await renderStep(enable)
fireEvent.click(later())
await vi.waitFor(() => expect(server.held).toHaveLength(1))
completed(server.held[0]!)
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
expect(enable).not.toHaveBeenCalled()
})
it('says why and stays when enableNotifications fails', async () => {
const server = await renderStep(() => Promise.reject(new Error('push service down')))
fireEvent.click(turnOn())
expect(await screen.findByRole('alert')).toHaveTextContent('Could not turn on notifications: push service down')
expect(server.held).toHaveLength(0)
expect(turnOn()).toBeEnabled()
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
})
it('says why and stays when the server refuses to finish onboarding, then finishes on retry', async () => {
const server = await renderApp()
fireEvent.click(later())
await vi.waitFor(() => expect(server.held).toHaveLength(1))
server.held[0]!.answer(errorResponse(500, 'internal'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not finish setting up: internal message')
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
expect(later()).toBeEnabled()
fireEvent.click(later())
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
await vi.waitFor(() => expect(server.held).toHaveLength(2))
completed(server.held[1]!)
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
})
})
describe('onboarding step 3 with too few Regulars', () => {
it('says why and offers a way back to add Regulars', async () => {
const server = await renderStep()
fireEvent.click(later())
await vi.waitFor(() => expect(server.held).toHaveLength(1))
server.held[0]!.answer(
jsonResponse(400, {
error: {
code: 'invalid_settings',
message: 'invalid household settings: onboarding_completed: add at least 3 Regulars first (have 2)',
retryable: false,
},
}),
)
expect(await screen.findByRole('alert')).toHaveTextContent(
'Could not finish setting up: invalid household settings: onboarding_completed: add at least 3 Regulars first (have 2)',
)
const addRegulars = screen.getByRole('button', { name: 'Add Regulars' })
expectMinHitArea(addRegulars)
fireEvent.click(addRegulars)
expect(await screen.findByRole('heading', { level: 1, name: 'Regulars step' })).toBeInTheDocument()
})
it('offers no way back for any other refusal', async () => {
const server = await renderStep()
fireEvent.click(later())
await vi.waitFor(() => expect(server.held).toHaveLength(1))
server.held[0]!.answer(errorResponse(500, 'internal'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not finish setting up: internal message')
expect(screen.queryByRole('button', { name: 'Add Regulars' })).not.toBeInTheDocument()
})
})
describe('notificationsUnavailable', () => {
it('answers "unavailable" without asking for permission', async () => {
await expect(notificationsUnavailable()).resolves.toBe('unavailable')
expect(requestPermission).not.toHaveBeenCalled()
})
})
+143
View File
@@ -0,0 +1,143 @@
import { useState } from 'react'
import { CalendarDays, ShoppingBasket, Sparkles, UtensilsCrossed, type LucideIcon } from 'lucide-react'
import { useNavigate } from 'react-router'
import { InstallHint } from '../pwa/InstallHint.tsx'
import { needsMoreRegulars, useCompleteOnboarding } from '../settings/api.ts'
import { Button } from '../ui/Button.tsx'
import { Icon } from '../ui/Icon.tsx'
import { OnboardingStep } from './OnboardingStep.tsx'
import styles from './Onboarding.module.css'
// NotificationsResult is how asking to turn on notifications ended.
export type NotificationsResult = 'enabled' | 'denied' | 'unavailable'
export type EnableNotifications = () => Promise<NotificationsResult>
// TODO(Feature 19): Web Push. Until it is wired, notifications cannot be
// turned on, and nothing asks the browser for permission.
export function notificationsUnavailable(): Promise<NotificationsResult> {
return Promise.resolve('unavailable')
}
const topics: { icon: LucideIcon; name: string; meaning: string }[] = [
{
icon: CalendarDays,
name: 'Plan ready',
meaning: "The week's Draft board is ready for the household to adjust.",
},
{
icon: ShoppingBasket,
name: 'Supply run ready',
meaning: "The week's shopping list, grouped by dinner, is ready to shop.",
},
{
icon: UtensilsCrossed,
name: "Tonight's dinner",
meaning: "A reminder before dinner of what's on the plan tonight.",
},
{
icon: Sparkles,
name: 'New Side quests',
meaning: 'Fresh dinners to Try it or Pass, picked from your Regulars.',
},
]
export type NotificationsStepProps = {
// Asks to turn on notifications when the household taps "Turn on
// notifications" — never on render.
enableNotifications?: EnableNotifications
}
// NotificationsStep is onboarding step 3: what MealQuest notifies about, the
// iOS install hint, and finishing onboarding. Either button records that
// onboarding is finished and goes Home. When the server refuses because the
// household has fewer than three Regulars, the reason shows with a way back
// to step 2 to add them.
export function NotificationsStep({ enableNotifications = notificationsUnavailable }: NotificationsStepProps) {
const navigate = useNavigate()
const complete = useCompleteOnboarding()
const [enabling, setEnabling] = useState(false)
const [problem, setProblem] = useState<string | null>(null)
const [fewRegulars, setFewRegulars] = useState(false)
const busy = enabling || complete.isPending
async function finish() {
try {
await complete.mutateAsync()
} catch (error) {
setProblem(`Could not finish setting up: ${(error as Error).message}`)
setFewRegulars(needsMoreRegulars(error as Error))
return
}
void navigate('/', { replace: true })
}
function clearProblem() {
setProblem(null)
setFewRegulars(false)
}
async function turnOn() {
clearProblem()
setEnabling(true)
try {
await enableNotifications()
} catch (error) {
setProblem(`Could not turn on notifications: ${(error as Error).message}`)
return
} finally {
setEnabling(false)
}
await finish()
}
function later() {
clearProblem()
void finish()
}
return (
<OnboardingStep
step={3}
title="Stay in the loop"
action={
<div className={styles.actions}>
<Button className={styles.action} disabled={busy} onClick={() => void turnOn()}>
Turn on notifications
</Button>
<Button variant="outline" className={styles.action} disabled={busy} onClick={later}>
Maybe later
</Button>
</div>
}
>
<p className={styles.helper}>MealQuest can let the household know when there&apos;s something to look at:</p>
<ul className={styles.terms}>
{topics.map(({ icon, name, meaning }) => (
<li key={name} className={styles.term}>
<span className={styles.termIcon}>
<Icon icon={icon} size="nav" />
</span>
<p className={styles.termText}>
<span className={styles.termName}>{name}</span>
{meaning}
</p>
</li>
))}
</ul>
<div className={styles.hint}>
<InstallHint />
</div>
{problem && (
<p role="alert" className={styles.problem}>
{problem}
</p>
)}
{fewRegulars && (
<Button variant="outline" className={styles.recover} onClick={() => void navigate('/onboarding/regulars')}>
Add Regulars
</Button>
)}
</OnboardingStep>
)
}
+254
View File
@@ -0,0 +1,254 @@
/* Onboarding, handoff mock 1h: a full screen outside the app shell with
the step's action fixed to the bottom. */
.screen {
min-height: 100dvh;
box-sizing: border-box;
/* Room for the fixed 52 px action and its padding. */
padding: var(--space-6) var(--space-4) calc(52px + 2 * var(--space-6));
background: var(--color-bg);
}
.column {
max-width: 560px;
margin: 0 auto;
display: flex;
flex-direction: column;
}
.progress {
display: flex;
gap: 6px;
}
.pill {
flex: 1;
height: 6px;
border-radius: var(--radius-pill);
background: var(--color-neutral-300);
}
.filled {
background: var(--color-accent);
}
.kicker {
margin: 30px 0 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-accent-700);
}
.title {
margin: 8px 0 0;
font-family: var(--font-heading);
font-size: 30px;
font-weight: 400;
line-height: 1.1;
}
.helper {
margin: 12px 0 0;
font-family: var(--font-body);
font-size: 15px;
line-height: 1.45;
color: var(--color-neutral-800);
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: var(--space-4) var(--space-4) var(--space-6);
background: var(--color-bg);
}
.action {
display: flex;
width: 100%;
max-width: 560px;
height: 52px;
margin: 0 auto;
font-size: 17px;
}
/* Step 3: two stacked actions. */
.actions {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
/* Room for the second fixed action and the gap above it. */
.screen:has(.actions) {
padding-bottom: calc(2 * 52px + var(--space-3) + 2 * var(--space-6));
}
/* Step 1: what MealQuest's words mean; step 3: what it notifies about. */
.terms {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin: var(--space-6) 0 0;
padding: 0;
list-style: none;
}
.term {
display: flex;
gap: var(--space-3);
align-items: flex-start;
padding: var(--space-4);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.termIcon {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--radius-pill);
background: var(--color-accent-200);
color: var(--color-accent);
}
.termText {
margin: 0;
font-family: var(--font-body);
font-size: 15px;
line-height: 1.4;
}
.termName {
display: block;
font-weight: 700;
}
/* Step 2: the add pill, with the "+" inside it. */
.add {
position: relative;
margin-top: var(--space-6);
}
/* Beats the TextInput's own padding whatever order the sheets load in. */
.add .addInput {
width: 100%;
padding-right: 52px;
}
/* 44 px hit box around the 36 px grape face, inset 2 px in the 48 px pill. */
.addButton {
position: absolute;
top: 2px;
right: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
cursor: pointer;
}
.addButton:disabled {
cursor: not-allowed;
}
.addFace {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-pill);
background: var(--color-accent);
color: var(--on-accent);
transition: transform 100ms var(--ease-out-soft);
}
.addButton:disabled .addFace {
opacity: 0.45;
}
.addButton:active:not(:disabled) .addFace {
transform: scale(0.97);
}
.groupLabel {
margin: var(--space-6) 0 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 10px 0 0;
padding: 0;
list-style: none;
}
.pending {
opacity: 0.6;
}
.pick {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 44px;
min-height: 44px;
padding: 0 16px;
border: 1.5px dashed var(--color-neutral-400);
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-text);
font-family: var(--font-body);
font-size: 15px;
cursor: pointer;
}
.pick:active {
background: var(--color-neutral-200);
}
.problem {
margin: var(--space-3) 0 0;
font-family: var(--font-body);
font-size: 14px;
color: var(--color-accent-700);
}
/* Step 3: back to step 2 when the server wants more Regulars. */
.recover {
margin-top: var(--space-3);
}
.status {
margin-top: 10px;
}
/* Step 3: the iOS install hint, when it shows. */
.hint {
margin-top: var(--space-4);
}
.hint:empty {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.addFace {
transition: none;
}
}
+52
View File
@@ -0,0 +1,52 @@
import { useEffect } from 'react'
import { Navigate, Outlet, useLocation } from 'react-router'
import { useApi } from '../api/index.ts'
import { LoadingScreen } from '../AuthBootstrap.tsx'
import { useHouseholdSettings } from '../settings/api.ts'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import styles from '../pages/FullScreen.module.css'
export const ONBOARDING_PATH = '/onboarding'
function inOnboarding(pathname: string): boolean {
return pathname === ONBOARDING_PATH || pathname.startsWith(`${ONBOARDING_PATH}/`)
}
// OnboardingGate reads the household settings before any page: while
// onboarding_completed_at is null, every route outside /onboarding
// redirects there.
//
// The service worker never caches the settings, so offline on the saved
// pages (see AuthBootstrap) they cannot be read: the saved pages open as they
// are, since onboarding needs the server anyway, and the settings are read
// again when the browser is back online.
export function OnboardingGate() {
const api = useApi()
const { pathname } = useLocation()
const settings = useHouseholdSettings()
const offline = settings.isError && settings.error instanceof TypeError && api.session.verified()
const { refetch } = settings
useEffect(() => {
if (!offline) return
return api.network.subscribe(() => {
if (!api.network.offline()) void refetch()
})
}, [offline, api, refetch])
if (settings.data !== undefined) {
if (settings.data.onboarding_completed_at === null && !inOnboarding(pathname)) {
return <Navigate to={ONBOARDING_PATH} replace />
}
return <Outlet />
}
if (offline) return <Outlet />
if (settings.isError) {
return (
<main className={styles.screen}>
<ErrorBanner onRetry={() => void refetch()}>MealQuest could not load: {settings.error.message}</ErrorBanner>
</main>
)
}
return <LoadingScreen />
}
+419
View File
@@ -0,0 +1,419 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { createApiClient, createVerifiedSession } from '../api/index.ts'
import App from '../App.tsx'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea, hitArea } from '../test/hitArea.ts'
import styles from './Onboarding.module.css'
afterEach(() => {
vi.unstubAllGlobals()
})
type Dinner = DinnerBody
// A request the fake server holds until the test answers it.
type Held = { method: string; path: string; body: unknown; answer: (response: Response) => void }
// stubDinnersServer stands in for the server a household meets on first
// run: signed in, onboarding not finished and the given Regulars. GET answers at once;
// each POST and DELETE is held in `held` until the test answers it, so the
// test sees the optimistic list first. Once failReads is set, every GET of
// the Regulars fails, so the test sees the list the client keeps by itself.
function stubDinnersServer(regulars: Dinner[] = []) {
const held: Held[] = []
const reads = { fail: false }
const fetchMock: Mock<typeof fetch> = vi.fn<typeof fetch>((input, init) => {
const path = String(input)
const method = init?.method ?? 'GET'
if (method === 'GET' && path === '/api/v1/me') return Promise.resolve(jsonResponse(200, ME))
if (method === 'GET' && path === '/api/v1/settings/household') {
return Promise.resolve(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }))
}
if (method === 'GET' && path === '/api/v1/dinners?status=regular') {
if (reads.fail) return Promise.resolve(errorResponse(500, 'internal'))
return Promise.resolve(jsonResponse(200, { dinners: regulars }))
}
if ((method === 'POST' && path === '/api/v1/dinners') || (method === 'DELETE' && path.startsWith('/api/v1/dinners/'))) {
return new Promise<Response>((resolve) => {
held.push({ method, path, body: init?.body === undefined ? undefined : JSON.parse(String(init.body)), answer: resolve })
})
}
throw new Error(`unexpected fetch: ${method} ${path}`)
})
vi.stubGlobal('fetch', fetchMock)
return { fetchMock, held, regulars, reads }
}
// created answers a held POST /api/v1/dinners as the server does, adding the
// Regular to the list later GETs read.
function created(server: ReturnType<typeof stubDinnersServer>, request: Held, id: string) {
const { name } = request.body as { name: string }
server.regulars.push(dinnerBody(id, name))
request.answer(jsonResponse(201, { ...dinnerBody(id, name, { grounding_status: 'pending' }), grounding_job_id: 'job-1' }))
}
// renderAt renders the whole app, so /me supplies the CSRF token every
// write needs and OnboardingGate reads the household settings.
function renderAt(path: string) {
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<InDataRouter path={path}>
<App api={api} serviceWorker={undefined} />
</InDataRouter>,
)
}
async function regularsStep(regulars: Dinner[] = []) {
const server = stubDinnersServer(regulars)
renderAt('/onboarding/regulars')
// Common picks show once the Regulars have loaded.
await screen.findByRole('heading', { level: 2, name: 'Common picks' })
expect(screen.getByRole('heading', { level: 2, name: `Your regulars · ${regulars.length}` })).toBeInTheDocument()
return server
}
function input() {
return screen.getByRole('textbox', { name: 'Dinner name' })
}
function regularsList() {
return screen.getByRole('heading', { level: 2, name: /^Your regulars/ }).nextElementSibling as HTMLElement
}
function regularNames() {
return within(regularsList())
.queryAllByRole('listitem')
.map((item) => item.textContent)
}
function next() {
return screen.getByRole('button', { name: 'Next: notifications' })
}
describe('onboarding step 1', () => {
it('welcomes the household, explains the words and starts with step 2', async () => {
stubDinnersServer()
renderAt('/onboarding')
expect(await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })).toBeInTheDocument()
expect(screen.getByText('Step 1 of 3')).toBeInTheDocument()
const terms = within(screen.getByRole('list')).getAllByRole('listitem')
expect(terms.map((term) => term.textContent)).toEqual([
expect.stringMatching(/^Regulars/),
expect.stringMatching(/^Side quests/),
expect.stringMatching(/^Quest log/),
])
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: "Let's start" }))
expect(await screen.findByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument()
})
it('fills one progress pill of three', async () => {
stubDinnersServer()
renderAt('/onboarding')
await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })
const pills = Array.from(document.getElementsByClassName(styles.pill!))
expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, false, false])
})
})
describe('onboarding step 2 (mock 1h)', () => {
it('shows the step as the mock does', async () => {
await regularsStep([dinnerBody('d1', 'Beef tacos')])
expect(screen.getByText('Step 2 of 3')).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument()
expect(
screen.getByText('Add the dinners your household makes on repeat. MealQuest builds suggestions from these.'),
).toBeInTheDocument()
expect(input()).toHaveAttribute('placeholder', 'e.g. Chicken enchiladas')
expect(regularNames()).toEqual(['Beef tacos'])
expect(screen.getByRole('button', { name: 'Remove Beef tacos' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 2, name: 'Common picks' })).toBeInTheDocument()
// A common pick the household already has is not offered again.
expect(screen.queryByRole('button', { name: 'Add Beef tacos' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Chili' })).toHaveTextContent('+Chili')
const pills = Array.from(document.getElementsByClassName(styles.pill!))
expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, true, false])
expect(declaredStyle(pills[0]!).height).toBe('6px')
})
it('does not offer a common pick the server would refuse as a duplicate of a Regular', async () => {
await regularsStep([dinnerBody('d1', 'Homemade chili'), dinnerBody('d2', 'Easy Chicken Stir Fry')])
expect(screen.queryByRole('button', { name: 'Add Chili' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Add Chicken stir-fry' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Beef tacos' })).toBeInTheDocument()
})
it('sizes the input pill, "+" button, picks and Next as the mock does', async () => {
await regularsStep()
expect(hitArea(input()).height).toBe(48)
const add = screen.getByRole('button', { name: 'Add Regular' })
expectMinHitArea(add)
expect(declaredStyle(add.firstElementChild!).width).toBe('36px')
expect(declaredStyle(add.firstElementChild!).height).toBe('36px')
expectMinHitArea(screen.getByRole('button', { name: 'Add Chili' }))
expect(declaredStyle(next()).height).toBe('52px')
expect(declaredStyle(next().parentElement!).position).toBe('fixed')
})
it('adds a Regular on Enter, showing it before the server answers', async () => {
const server = await regularsStep()
fireEvent.change(input(), { target: { value: ' Chicken enchiladas ' } })
// Enter in the input submits its form.
fireEvent.submit(input())
expect(input()).toHaveValue('')
await vi.waitFor(() => expect(regularNames()).toEqual(['Chicken enchiladas']))
expect(screen.getByRole('heading', { level: 2, name: 'Your regulars · 1' })).toBeInTheDocument()
// Not removable until the server has given it an id.
expect(screen.queryByRole('button', { name: 'Remove Chicken enchiladas' })).not.toBeInTheDocument()
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]).toMatchObject({ method: 'POST', path: '/api/v1/dinners', body: { name: 'Chicken enchiladas' } })
created(server, server.held[0]!, 'd1')
expect(await screen.findByRole('button', { name: 'Remove Chicken enchiladas' })).toBeInTheDocument()
})
it('adds a Regular with the "+" button', async () => {
const server = await regularsStep()
const add = screen.getByRole('button', { name: 'Add Regular' })
expect(add).toBeDisabled()
fireEvent.change(input(), { target: { value: 'Shrimp scampi' } })
fireEvent.click(add)
await vi.waitFor(() => expect(regularNames()).toEqual(['Shrimp scampi']))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]).toMatchObject({ method: 'POST', body: { name: 'Shrimp scampi' } })
})
it('adds a common pick on tap and stops offering it', async () => {
const server = await regularsStep()
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
await vi.waitFor(() => expect(regularNames()).toEqual(['Chili']))
expect(screen.queryByRole('button', { name: 'Add Chili' })).not.toBeInTheDocument()
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]).toMatchObject({ method: 'POST', path: '/api/v1/dinners', body: { name: 'Chili' } })
})
it('takes an add back out and says why when the server refuses it', async () => {
const server = await regularsStep([dinnerBody('d1', 'Chili')])
fireEvent.change(input(), { target: { value: 'chili' } })
fireEvent.submit(input())
await vi.waitFor(() => expect(regularNames()).toEqual(['Chili', 'chili']))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add chili: duplicate_dinner message')
expect(regularNames()).toEqual(['Chili'])
})
it('says why an add was refused while another add is still unanswered', async () => {
const server = await regularsStep()
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
await vi.waitFor(() => expect(server.held).toHaveLength(2))
expect(regularNames()).toEqual(['Burgers', 'Chili'])
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
expect(regularNames()).toEqual(['Chili'])
// The other add's answer does not hide the refusal.
created(server, server.held[1]!, 'd1')
await screen.findByRole('button', { name: 'Remove Chili' })
expect(screen.getByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
expect(regularNames()).toEqual(['Chili'])
})
it('says why each of two overlapping adds was refused', async () => {
const server = await regularsStep()
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
await vi.waitFor(() => expect(server.held).toHaveLength(2))
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
server.held[1]!.answer(errorResponse(500, 'internal'))
await vi.waitFor(() =>
expect(screen.getAllByRole('alert').map((alert) => alert.textContent)).toEqual([
'Could not add Burgers: duplicate_dinner message',
'Could not add Chili: internal message',
]),
)
expect(regularNames()).toEqual([])
})
it('says why each of two overlapping adds of the same name was refused', async () => {
const server = await regularsStep()
fireEvent.change(input(), { target: { value: 'Lasagne' } })
fireEvent.submit(input())
await vi.waitFor(() => expect(server.held).toHaveLength(1))
fireEvent.change(input(), { target: { value: 'Lasagne' } })
fireEvent.submit(input())
await vi.waitFor(() => expect(server.held).toHaveLength(2))
expect(regularNames()).toEqual(['Lasagne', 'Lasagne'])
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Lasagne: duplicate_dinner message')
expect(regularNames()).toEqual(['Lasagne'])
// The second add's refusal shows beside the first instead of replacing it.
server.held[1]!.answer(errorResponse(500, 'internal'))
await vi.waitFor(() =>
expect(screen.getAllByRole('alert').map((alert) => alert.textContent)).toEqual([
'Could not add Lasagne: duplicate_dinner message',
'Could not add Lasagne: internal message',
]),
)
expect(regularNames()).toEqual([])
})
it('removes a Regular at once and archives it on the server', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
])
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
await vi.waitFor(() => expect(regularNames()).toEqual(['Pizza night']))
expect(screen.getByRole('heading', { level: 2, name: 'Your regulars · 1' })).toBeInTheDocument()
// Removed, it is a common pick again.
expect(screen.getByRole('button', { name: 'Add Beef tacos' })).toBeInTheDocument()
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]).toMatchObject({ method: 'DELETE', path: '/api/v1/dinners/d1' })
})
it('puts a removed Regular back in its place when the server refuses', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
])
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
server.held[0]!.answer(errorResponse(500, 'internal'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
expect(regularNames()).toEqual(['Beef tacos', 'Pizza night'])
})
it('puts a refused removal back and says why while another removal is still unanswered', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
dinnerBody('d3', 'Chili'),
])
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' }))
await vi.waitFor(() => expect(server.held).toHaveLength(2))
expect(regularNames()).toEqual(['Chili'])
server.held[0]!.answer(errorResponse(500, 'internal'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
expect(regularNames()).toEqual(['Beef tacos', 'Chili'])
// The other removal's answer does not hide the refusal.
server.regulars.splice(1, 1)
server.held[1]!.answer(new Response(null, { status: 204 }))
await vi.waitFor(() => expect(server.fetchMock.mock.calls.filter(([path]) => String(path).includes('status=regular'))).toHaveLength(2))
await vi.waitFor(() => expect(regularNames()).toEqual(['Beef tacos', 'Chili']))
expect(screen.getByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
})
it('puts two refused removals back in their places without rereading the list', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
dinnerBody('d3', 'Chili'),
])
server.reads.fail = true
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' }))
await vi.waitFor(() => expect(server.held).toHaveLength(2))
expect(regularNames()).toEqual(['Chili'])
server.held[0]!.answer(errorResponse(500, 'internal'))
await screen.findByRole('alert')
server.held[1]!.answer(errorResponse(500, 'internal'))
await screen.findByText('Could not remove Pizza night: internal message')
// The reread after the last answer fails, so the order is the client's own.
expect(await screen.findByText(/Your Regulars could not load/)).toBeInTheDocument()
expect(screen.getAllByRole('button', { name: /^Remove / }).map((button) => button.getAttribute('aria-label'))).toEqual([
'Remove Beef tacos',
'Remove Pizza night',
'Remove Chili',
])
})
it('enables "Next: notifications" once the server has saved 3 Regulars', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
])
expect(next()).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
await screen.findByRole('heading', { level: 2, name: 'Your regulars · 3' })
// The third chip shows at once, but Next waits for the server.
expect(next()).toBeDisabled()
await vi.waitFor(() => expect(server.held).toHaveLength(1))
created(server, server.held[0]!, 'd3')
await screen.findByRole('button', { name: 'Remove Burgers' })
expect(next()).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' }))
await vi.waitFor(() => expect(next()).toBeDisabled())
await vi.waitFor(() => expect(server.held).toHaveLength(2))
server.held[1]!.answer(errorResponse(500, 'internal'))
await vi.waitFor(() => expect(next()).toBeEnabled())
fireEvent.click(next())
expect(await screen.findByText('Step 3 of 3')).toBeInTheDocument()
})
it('keeps "Next: notifications" disabled when the third add is refused', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
])
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
await screen.findByRole('heading', { level: 2, name: 'Your regulars · 3' })
expect(next()).toBeDisabled()
await vi.waitFor(() => expect(server.held).toHaveLength(1))
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
expect(regularNames()).toEqual(['Beef tacos', 'Pizza night'])
expect(next()).toBeDisabled()
})
it('keeps "Next: notifications" disabled while a fourth add is unanswered', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
dinnerBody('d3', 'Chili'),
])
expect(next()).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
await screen.findByRole('heading', { level: 2, name: 'Your regulars · 4' })
expect(next()).toBeDisabled()
await vi.waitFor(() => expect(server.held).toHaveLength(1))
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
await screen.findByRole('alert')
expect(next()).toBeEnabled()
})
})
+68
View File
@@ -0,0 +1,68 @@
import { Repeat, ScrollText, Sparkles, type LucideIcon } from 'lucide-react'
import { Route, Routes, useNavigate } from 'react-router'
import { Button } from '../ui/Button.tsx'
import { Icon } from '../ui/Icon.tsx'
import { NotificationsStep } from './NotificationsStep.tsx'
import { OnboardingStep } from './OnboardingStep.tsx'
import { RegularsStep } from './RegularsStep.tsx'
import styles from './Onboarding.module.css'
// OnboardingPage is /onboarding and its steps: the welcome, the Regulars
// (/onboarding/regulars) and notifications (/onboarding/notifications).
export function OnboardingPage() {
return (
<Routes>
<Route index element={<WelcomeStep />} />
<Route path="regulars" element={<RegularsStep />} />
<Route path="notifications" element={<NotificationsStep />} />
</Routes>
)
}
const terms: { icon: LucideIcon; name: string; meaning: string }[] = [
{
icon: Repeat,
name: 'Regulars',
meaning: 'The dinners our kitchen makes on repeat. You add a few next.',
},
{
icon: Sparkles,
name: 'Side quests',
meaning: 'New dinners MealQuest suggests from your Regulars. Try it or Pass.',
},
{
icon: ScrollText,
name: 'Quest log',
meaning: 'The Side quests you said Try it to, waiting for a night on the plan.',
},
]
function WelcomeStep() {
const navigate = useNavigate()
return (
<OnboardingStep
step={1}
title="Welcome to MealQuest"
action={
<Button className={styles.action} onClick={() => void navigate('/onboarding/regulars')}>
Let&apos;s start
</Button>
}
>
<p className={styles.helper}>MealQuest plans the household&apos;s dinners around what you already love to cook.</p>
<ul className={styles.terms}>
{terms.map(({ icon, name, meaning }) => (
<li key={name} className={styles.term}>
<span className={styles.termIcon}>
<Icon icon={icon} size="nav" />
</span>
<p className={styles.termText}>
<span className={styles.termName}>{name}</span>
{meaning}
</p>
</li>
))}
</ul>
</OnboardingStep>
)
}
+35
View File
@@ -0,0 +1,35 @@
import type { ReactNode } from 'react'
import styles from './Onboarding.module.css'
export const ONBOARDING_STEPS = 3
export type OnboardingStepProps = {
// 1-based; the progress pills fill up to it.
step: number
title: string
children: ReactNode
// The step's primary action, fixed to the bottom of the screen.
action?: ReactNode
}
// OnboardingStep is the frame every onboarding step shares: progress pills,
// the "Step N of 3" kicker and the Bagel title.
export function OnboardingStep({ step, title, children, action }: OnboardingStepProps) {
return (
<main className={styles.screen}>
<div className={styles.column}>
<div className={styles.progress} aria-hidden="true">
{Array.from({ length: ONBOARDING_STEPS }, (_, i) => (
<span key={i} className={[styles.pill, i < step && styles.filled].filter(Boolean).join(' ')} />
))}
</div>
<p className={styles.kicker}>
Step {step} of {ONBOARDING_STEPS}
</p>
<h1 className={styles.title}>{title}</h1>
{children}
</div>
{action && <div className={styles.footer}>{action}</div>}
</main>
)
}
+147
View File
@@ -0,0 +1,147 @@
import { useState, type FormEvent } from 'react'
import { Plus } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useAddRegular, useRegulars, useRemoveRegular, type Regular } from '../dinners/api.ts'
import { similarNames } from '../dinners/names.ts'
import { useRefusals } from '../dinners/useRefusals.ts'
import { Button } from '../ui/Button.tsx'
import { Chip } from '../ui/Chip.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { TextInput } from '../ui/TextInput.tsx'
import { OnboardingStep } from './OnboardingStep.tsx'
import styles from './Onboarding.module.css'
// MIN_REGULARS is how many Regulars the server has saved before the
// household moves on.
export const MIN_REGULARS = 3
// commonPicks are offered as one-tap Regulars until the household has them,
// or a Regular the server would count as the same dinner.
export const commonPicks = [
'Beef tacos',
'Spaghetti bolognese',
'Chicken stir-fry',
'Pizza night',
'Chili',
'Pot roast',
'Burgers',
'Fried rice',
]
// RegularsStep is onboarding step 2, handoff mock 1h: the household adds
// the dinners it makes on repeat. Each add and remove shows at once and is
// undone if the server refuses it.
export function RegularsStep() {
const navigate = useNavigate()
const regulars = useRegulars()
const add = useAddRegular()
const remove = useRemoveRegular()
const [draft, setDraft] = useState('')
const { refusals, track } = useRefusals()
const list = regulars.data ?? []
const picks = commonPicks.filter((pick) => !list.some((regular) => similarNames(regular.name, pick)))
// Next waits for every add to be answered, so a refused add's error shows
// here and an add the server takes back never counts towards the three.
const adding = list.some((regular) => regular.pending)
const saved = list.filter((regular) => !regular.pending).length
function addRegular(name: string) {
track(`add:${name.toLowerCase()}`, `add ${name}`, add.mutateAsync({ name }))
}
function removeRegular(regular: Regular) {
track(`remove:${regular.id}`, `remove ${regular.name}`, remove.mutateAsync(regular))
}
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const name = draft.trim()
if (name === '') return
addRegular(name)
setDraft('')
}
return (
<OnboardingStep
step={2}
title="What's on your usual rotation?"
action={
<Button
className={styles.action}
disabled={adding || saved < MIN_REGULARS}
onClick={() => void navigate('/onboarding/notifications')}
>
Next: notifications
</Button>
}
>
<p className={styles.helper}>
Add the dinners your household makes on repeat. MealQuest builds suggestions from these.
</p>
<form className={styles.add} onSubmit={onSubmit}>
<TextInput
label="Dinner name"
labelHidden
className={styles.addInput}
placeholder="e.g. Chicken enchiladas"
autoComplete="off"
enterKeyHint="done"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<button type="submit" className={styles.addButton} aria-label="Add Regular" disabled={draft.trim() === ''}>
<span className={styles.addFace}>
<Icon icon={Plus} size="nav" />
</span>
</button>
</form>
{refusals.map((refusal) => (
<p key={refusal.key} role="alert" className={styles.problem}>
{refusal.text}
</p>
))}
<h2 className={styles.groupLabel}>Your regulars · {list.length}</h2>
{regulars.isPending && <Skeleton className={styles.status} />}
{regulars.isError && (
<div className={styles.status}>
<ErrorBanner onRetry={() => void regulars.refetch()}>
Your Regulars could not load: {regulars.error.message}
</ErrorBanner>
</div>
)}
{list.length > 0 && (
<ul className={styles.chips}>
{list.map((regular: Regular) => (
<li key={regular.id}>
<Chip
label={regular.name}
className={regular.pending && styles.pending}
aria-busy={regular.pending}
onRemove={regular.pending ? undefined : () => removeRegular(regular)}
/>
</li>
))}
</ul>
)}
{regulars.isSuccess && picks.length > 0 && (
<>
<h2 className={styles.groupLabel}>Common picks</h2>
<ul className={styles.chips}>
{picks.map((pick) => (
<li key={pick}>
<button type="button" className={styles.pick} aria-label={`Add ${pick}`} onClick={() => addRegular(pick)}>
<span aria-hidden="true">+</span>
{pick}
</button>
</li>
))}
</ul>
</>
)}
</OnboardingStep>
)
}
+313
View File
@@ -0,0 +1,313 @@
/* Admin · AI assistant, handoff mock 1o. */
.page {
box-sizing: border-box;
min-height: 100%;
max-width: 560px;
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 6px;
}
.header {
display: flex;
align-items: center;
gap: 16px;
}
.kicker {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-accent-700);
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 28px;
font-weight: 400;
}
.loading {
height: 320px;
border-radius: 26px;
}
.columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
}
.model {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.label {
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
.mono {
font-family: var(--font-mono);
}
.hint,
.note,
.verified,
.error,
.status {
margin: 0;
font-family: var(--font-body);
font-size: 13px;
line-height: 1.4;
}
.hint,
.note {
color: var(--color-neutral-700);
}
.verified {
font-weight: 700;
color: var(--color-accent-2-800);
}
.error {
font-weight: 700;
color: var(--color-accent-700);
}
.status {
color: var(--color-accent-2-800);
}
.status:empty {
display: none;
}
.key {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.keyRow {
display: flex;
align-items: center;
gap: 10px;
min-height: 56px;
padding: 0 6px 0 20px;
border: 1px solid var(--color-neutral-300);
border-radius: var(--radius-pill);
background: var(--color-surface);
}
.keyValue {
flex: 1;
min-width: 0;
overflow: hidden;
font-family: var(--font-mono);
font-size: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.replace {
flex: none;
min-width: 44px;
min-height: 44px;
padding: 0 18px;
border: 0;
border-radius: var(--radius-pill);
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.replace:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.keyStatus {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0 8px;
}
.link {
min-width: 44px;
min-height: 44px;
padding: 0 4px;
border: 0;
background: transparent;
color: var(--color-accent-700);
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
text-decoration: underline;
cursor: pointer;
}
.temperature {
display: flex;
flex-direction: column;
}
.temperatureHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.value {
font-family: var(--font-body);
font-size: 15px;
font-weight: 700;
}
.ends {
display: flex;
justify-content: space-between;
font-family: var(--font-body);
font-size: 12px;
color: var(--color-neutral-700);
}
.prompt {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.textarea {
box-sizing: border-box;
width: 100%;
min-height: 150px;
padding: 14px 18px;
border: 1px solid var(--color-neutral-300);
border-radius: 22px;
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
resize: vertical;
}
.actions {
/* Mock 1o anchors Reset and Save changes at the bottom of the screen. */
margin-top: auto;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: 10px;
padding-top: 6px;
}
.reset,
.save {
min-height: 52px;
}
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 560px;
margin: 0 auto;
}
.sheetTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.sheetText {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-neutral-700);
}
.sheetActions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* Recipe search card, below the AI assistant's actions. */
.card {
display: flex;
flex-direction: column;
gap: 14px;
border: 1px solid var(--color-neutral-300);
}
.cardTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.cardLoading {
height: 160px;
border-radius: var(--radius-lg);
}
.cardActions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: 10px;
}
.cardActions > button {
min-height: 52px;
}
.cardHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
/* AI assistant card on the desktop settings (mock 2e): the card grows to
fill its column and the system prompt grows to fill the card. */
.fill {
flex: 1;
gap: 18px;
}
.fill .prompt,
.fill .textarea {
flex: 1;
}
.fill .textarea {
resize: none;
}
/* A form locked while its save or prompt reset is pending, so no edit is
made that the response would then replace. It lays out as if it were not
there. */
.lock {
display: contents;
}
+782
View File
@@ -0,0 +1,782 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { AI_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { AISettingsPage, formatVerifiedDate } from './AISettingsPage.tsx'
import { maskedKey } from './api.ts'
const MODELS = {
models: [
{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' },
{ id: 'claude-haiku-4-5', display_name: 'Claude Haiku' },
],
}
const DEFAULT_PROMPT = 'You are MealQuest, the default persona.'
type Responder = (body: unknown) => Response | Promise<Response>
// stubAPI answers "METHOD path" from responders, each called with the
// request's decoded JSON body; GET /settings/ai and /settings/ai/models
// answer AI_SETTINGS and MODELS unless overridden. Any other request fails
// the test.
function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch> {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'GET /api/v1/settings/ai/models': () => jsonResponse(200, MODELS),
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined
return Promise.resolve(respond(body))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// sent lists the requests other than GETs, as "METHOD path" and body.
function sent(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
.map(([input, init]) => ({
request: `${init?.method} ${String(input)}`,
body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined,
}))
}
afterEach(() => {
vi.unstubAllGlobals()
})
function CurrentPath() {
return <p data-testid="path">{useLocation().pathname}</p>
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the page at /settings/ai in a data router, so its leave guard works.
async function renderPage() {
const api = createApiClient(fakeLocation('/settings/ai'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings/ai">
<Routes>
<Route path="/settings/ai" element={<AISettingsPage />} />
<Route path="*" element={<CurrentPath />} />
</Routes>
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
await screen.findByRole('option', { name: 'Claude Sonnet' })
}
const provider = () => screen.getByRole('combobox', { name: 'Provider' })
const model = () => screen.getByRole('combobox', { name: 'Model' })
const prompt = () => screen.getByRole('textbox', { name: 'System prompt' })
const slider = () => screen.getByRole('slider', { name: 'Creativity (temperature)' })
const save = () => screen.getByRole('button', { name: 'Save changes' })
describe('AISettingsPage', () => {
it('loads the settings as mock 1o lays them out', async () => {
stubAPI()
await renderPage()
expect(screen.getByRole('button', { name: 'Back to Settings' })).toBeInTheDocument()
expect(screen.getByText('Settings · Admin')).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: 'AI assistant' })).toBeInTheDocument()
expect(provider()).toHaveValue('anthropic')
expect(within(provider()).getByRole('option', { selected: true })).toHaveTextContent('Anthropic')
expect(model()).toHaveValue('claude-sonnet-4-5')
expect(within(model()).getAllByRole('option').map((o) => o.textContent)).toEqual([
'Claude Sonnet',
'Claude Haiku',
'Type a model id',
])
expect(screen.getByText('••••••••3f9a')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Replace' })).toBeInTheDocument()
expect(screen.getByText('Verified · Oct 1')).toBeInTheDocument()
expect(slider()).toHaveValue('0.7')
expect(slider()).toHaveAttribute('step', '0.1')
expect(screen.getByText('0.7')).toBeInTheDocument()
expect(screen.getByText('Familiar')).toBeInTheDocument()
expect(screen.getByText('Adventurous')).toBeInTheDocument()
expect(prompt()).toHaveValue(AI_SETTINGS.system_prompt)
expect(screen.getByRole('button', { name: 'Reset' })).toBeEnabled()
expect(save()).toBeDisabled()
})
it('reports a load failure with a retry', async () => {
stubAPI({ 'GET /api/v1/settings/ai': () => errorResponse(500, 'internal') })
const api = createApiClient(fakeLocation('/settings/ai'), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings/ai">
<AISettingsPage />
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
expect(await screen.findByRole('alert')).toHaveTextContent('AI settings could not load: internal message')
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
})
it('saves only the edited fields', async () => {
const fetchMock = stubAPI({
'PATCH /api/v1/settings/ai': (body) => jsonResponse(200, { ...AI_SETTINGS, ...(body as object) }),
})
await renderPage()
fireEvent.change(model(), { target: { value: 'claude-haiku-4-5' } })
fireEvent.change(slider(), { target: { value: '0.4' } })
expect(screen.getByText('0.4')).toBeInTheDocument()
fireEvent.change(prompt(), { target: { value: 'Keep it cozy.' } })
expect(save()).toBeEnabled()
fireEvent.click(save())
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{
request: 'PATCH /api/v1/settings/ai',
body: { model: 'claude-haiku-4-5', temperature: 0.4, system_prompt: 'Keep it cozy.' },
},
])
expect(save()).toBeDisabled()
expect(model()).toHaveValue('claude-haiku-4-5')
})
it('saves a typed model id', async () => {
const fetchMock = stubAPI({
'PATCH /api/v1/settings/ai': (body) => jsonResponse(200, { ...AI_SETTINGS, ...(body as object) }),
})
await renderPage()
expect(screen.queryByRole('textbox', { name: 'Model id' })).not.toBeInTheDocument()
fireEvent.change(model(), { target: { value: within(model()).getByRole('option', { name: 'Type a model id' }).getAttribute('value') } })
fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'claude-opus-5-5' } })
fireEvent.click(save())
await screen.findByText('Changes saved.')
expect(sent(fetchMock)).toEqual([{ request: 'PATCH /api/v1/settings/ai', body: { model: 'claude-opus-5-5' } }])
})
it('explains that a stored key must be removed before switching provider', async () => {
stubAPI({
'PATCH /api/v1/settings/ai': () =>
jsonResponse(400, {
error: {
code: 'invalid_settings',
message: 'invalid AI settings: provider: cannot change while an API key is stored: remove the key first',
retryable: false,
},
}),
})
await renderPage()
fireEvent.change(provider(), { target: { value: 'openai' } })
expect(screen.getByText(/An API key is stored\. Remove it \(Replace → Remove key\)/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Replace' })).toBeEnabled()
fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'gpt-5' } })
fireEvent.click(save())
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('Not saved: invalid AI settings: provider: cannot change while an API key is stored')
expect(alert).toHaveTextContent('Remove it (Replace → Remove key) before changing the provider or base URL.')
})
it('gives the same guidance when a stored key locks the base URL', async () => {
stubAPI({
'GET /api/v1/settings/ai': () =>
jsonResponse(200, { ...AI_SETTINGS, provider: 'openai_compatible', base_url: 'https://llm.example.com/v1' }),
'PATCH /api/v1/settings/ai': () =>
jsonResponse(400, {
error: {
code: 'invalid_settings',
message: 'invalid AI settings: base_url: cannot change while an API key is stored: remove the key first',
retryable: false,
},
}),
})
await renderPage()
fireEvent.change(screen.getByRole('textbox', { name: 'Base URL' }), { target: { value: 'https://other.example.com/v1' } })
expect(screen.getByText(/An API key is stored\. Remove it/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Replace' })).toBeEnabled()
fireEvent.click(save())
expect(await screen.findByRole('alert')).toHaveTextContent(
'Remove it (Replace → Remove key) before changing the provider or base URL.',
)
})
it('recovers a provider switch by removing the stored key, without undoing the switch', async () => {
const fetchMock = stubAPI({
'PUT /api/v1/settings/ai/api-key': () =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null }),
'PATCH /api/v1/settings/ai': (body) =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null, ...(body as object) }),
})
await renderPage()
fireEvent.change(provider(), { target: { value: 'ollama' } })
fireEvent.change(screen.getByRole('textbox', { name: 'Base URL' }), { target: { value: 'http://localhost:11434' } })
fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'llama3.2' } })
// The stored key stays in view, and removable, while Ollama is chosen.
expect(screen.getByText('••••••••3f9a')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove key' }))
expect(await screen.findByText('Ollama takes no API key.')).toBeInTheDocument()
expect(screen.queryByText(/An API key is stored/)).not.toBeInTheDocument()
expect(provider()).toHaveValue('ollama')
fireEvent.click(save())
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{ request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: '' } },
{
request: 'PATCH /api/v1/settings/ai',
body: { provider: 'ollama', base_url: 'http://localhost:11434', model: 'llama3.2' },
},
])
})
it('replaces the key in a sheet, then verifies it', async () => {
const verifiedAt = '2026-10-06T14:00:00.000Z'
const fetchMock = stubAPI({
'PUT /api/v1/settings/ai/api-key': () =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null }),
'POST /api/v1/settings/ai/verify': () =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: verifiedAt }),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
const sheet = screen.getByRole('dialog', { name: 'Replace API key' })
const input = within(sheet).getByLabelText('API key')
expect(input).toHaveAttribute('type', 'password')
fireEvent.change(input, { target: { value: 'sk-ant-secret-b7c1' } })
fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' }))
expect(await screen.findByText(`Verified · ${formatVerifiedDate(verifiedAt)}`)).toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getByText('••••••••b7c1')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{ request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: 'sk-ant-secret-b7c1' } },
{ request: 'POST /api/v1/settings/ai/verify', body: undefined },
])
expect(document.body).not.toHaveTextContent('sk-ant-secret-b7c1')
})
it('still verifies the key when the sheet is dismissed while it is being stored', async () => {
let answerPut: (response: Response) => void = () => {
throw new Error('PUT not sent')
}
const fetchMock = stubAPI({
'PUT /api/v1/settings/ai/api-key': () => new Promise<Response>((resolve) => (answerPut = resolve)),
'POST /api/v1/settings/ai/verify': () =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: '2026-10-06T14:00:00.000Z' }),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
const sheet = screen.getByRole('dialog', { name: 'Replace API key' })
fireEvent.change(within(sheet).getByLabelText('API key'), { target: { value: 'sk-ant-secret-b7c1' } })
fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' }))
await waitFor(() => expect(within(sheet).getByRole('button', { name: 'Saving…' })).toBeDisabled())
fireEvent.keyDown(sheet, { key: 'Escape' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Saving…' })).toBeDisabled()
act(() => answerPut(jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null })))
expect(await screen.findByText('Verified · Oct 6')).toBeInTheDocument()
expect(screen.getByText('••••••••b7c1')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Replace' })).toBeEnabled()
expect(sent(fetchMock)).toEqual([
{ request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: 'sk-ant-secret-b7c1' } },
{ request: 'POST /api/v1/settings/ai/verify', body: undefined },
])
})
it('shows the verify error and verifies again on request', async () => {
let verifyCalls = 0
stubAPI({
'PUT /api/v1/settings/ai/api-key': () =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null }),
'POST /api/v1/settings/ai/verify': () => {
verifyCalls++
return verifyCalls === 1
? errorResponse(502, 'auth_failed')
: jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1' })
},
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
const sheet = screen.getByRole('dialog', { name: 'Replace API key' })
fireEvent.change(within(sheet).getByLabelText('API key'), { target: { value: 'sk-wrong-b7c1' } })
fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Verification failed: the provider rejected this key.')
expect(screen.queryByText(/^Verified ·/)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
expect(await screen.findByText('Verified · Oct 1')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('shows a refused key in the sheet', async () => {
stubAPI({
'PUT /api/v1/settings/ai/api-key': () =>
jsonResponse(400, {
error: { code: 'invalid_settings', message: 'api_key: must be at least 8 characters', retryable: false },
}),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
const sheet = screen.getByRole('dialog', { name: 'Replace API key' })
fireEvent.change(within(sheet).getByLabelText('API key'), { target: { value: 'short' } })
fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' }))
expect(await within(sheet).findByRole('alert')).toHaveTextContent(
'The key was not stored: api_key: must be at least 8 characters',
)
})
it('removes the stored key from the sheet', async () => {
const fetchMock = stubAPI({
'PUT /api/v1/settings/ai/api-key': () =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null }),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove key' }))
expect(await screen.findByText('No key stored')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add key' })).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([{ request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: '' } }])
})
it('resets the system prompt after confirming', async () => {
const fetchMock = stubAPI({
'POST /api/v1/settings/ai/reset-prompt': () => jsonResponse(200, { ...AI_SETTINGS, system_prompt: DEFAULT_PROMPT }),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Reset' }))
const confirm = screen.getByRole('dialog', { name: 'Reset the system prompt?' })
fireEvent.click(within(confirm).getByRole('button', { name: 'Cancel' }))
expect(sent(fetchMock)).toEqual([])
fireEvent.click(screen.getByRole('button', { name: 'Reset' }))
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Reset prompt' }))
await waitFor(() => expect(prompt()).toHaveValue(DEFAULT_PROMPT))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(sent(fetchMock)).toEqual([{ request: 'POST /api/v1/settings/ai/reset-prompt', body: undefined }])
expect(save()).toBeDisabled()
})
it('asks before leaving with unsaved changes', async () => {
stubAPI()
await renderPage()
fireEvent.change(prompt(), { target: { value: 'Unsaved.' } })
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
const confirm = await screen.findByRole('dialog', { name: 'Leave without saving?' })
fireEvent.click(within(confirm).getByRole('button', { name: 'Keep editing' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(prompt()).toHaveValue('Unsaved.')
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Leave' }))
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('leaves without asking when nothing changed', async () => {
stubAPI()
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('guards a page unload only while there are unsaved changes', async () => {
stubAPI()
await renderPage()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
expect(unload()).toBe(false)
fireEvent.change(slider(), { target: { value: '0.2' } })
expect(unload()).toBe(true)
})
it('locks the form while a save is pending, and guards leaving until the save lands', async () => {
let answer: (response: Response) => void = () => {}
stubAPI({ 'PATCH /api/v1/settings/ai': () => new Promise<Response>((resolve) => (answer = resolve)) })
await renderPage()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
fireEvent.change(prompt(), { target: { value: 'Keep it cozy.' } })
fireEvent.click(save())
expect(await screen.findByRole('button', { name: 'Saving…' })).toBeDisabled()
for (const field of [provider(), model(), slider(), prompt()]) expect(field).toBeDisabled()
expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled()
expect(unload()).toBe(true)
answer(jsonResponse(200, { ...AI_SETTINGS, system_prompt: 'Keep it cozy.' }))
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
expect(prompt()).toBeEnabled()
expect(prompt()).toHaveValue('Keep it cozy.')
await waitFor(() => expect(unload()).toBe(false))
})
it('locks the form while a prompt reset is pending, even with the sheet closed', async () => {
let answer: (response: Response) => void = () => {}
stubAPI({
'POST /api/v1/settings/ai/reset-prompt': () => new Promise<Response>((resolve) => (answer = resolve)),
})
await renderPage()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
fireEvent.change(slider(), { target: { value: '0.2' } })
fireEvent.click(screen.getByRole('button', { name: 'Reset' }))
const confirm = screen.getByRole('dialog', { name: 'Reset the system prompt?' })
fireEvent.click(within(confirm).getByRole('button', { name: 'Reset prompt' }))
expect(await within(confirm).findByRole('button', { name: 'Resetting…' })).toBeDisabled()
fireEvent.click(within(confirm).getByRole('button', { name: 'Cancel' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
for (const field of [provider(), model(), slider(), prompt()]) expect(field).toBeDisabled()
expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled()
expect(save()).toBeDisabled()
expect(unload()).toBe(true)
answer(jsonResponse(200, { ...AI_SETTINGS, system_prompt: DEFAULT_PROMPT }))
await waitFor(() => expect(prompt()).toBeEnabled())
expect(prompt()).toHaveValue(DEFAULT_PROMPT)
expect(slider()).toHaveValue('0.2')
expect(unload()).toBe(true)
fireEvent.change(prompt(), { target: { value: 'After the reset.' } })
expect(prompt()).toHaveValue('After the reset.')
})
it('keeps 44 px tap targets', async () => {
stubAPI()
await renderPage()
for (const control of [
screen.getByRole('button', { name: 'Back to Settings' }),
screen.getByRole('button', { name: 'Replace' }),
provider(),
model(),
slider(),
screen.getByRole('button', { name: 'Reset' }),
save(),
]) {
expectMinHitArea(control)
}
})
})
// apiError is an API error response carrying message.
function apiError(status: number, code: string, message: string): Response {
return jsonResponse(status, { error: { code, message, retryable: false } })
}
const BRAVE_SETTINGS = { ...RECIPE_SEARCH_SETTINGS, provider: 'brave', api_key_set: true, api_key_last4: 'b7c2' }
describe('Recipe search card', () => {
const card = () => screen.getByRole('region', { name: 'Recipe search' })
const searxng = () => within(card()).getByRole('radio', { name: 'SearXNG' })
const brave = () => within(card()).getByRole('radio', { name: 'Brave' })
const url = () => within(card()).getByRole('textbox', { name: 'SearXNG URL' })
const saveSearch = () => within(card()).getByRole('button', { name: 'Save recipe search' })
const testSearch = () => within(card()).getByRole('button', { name: 'Test' })
async function renderCard() {
await renderPage()
await within(card()).findByRole('radiogroup', { name: 'Recipe search provider' })
}
// addKey enters key in the Brave key sheet.
function addKey(button: string, key: string) {
fireEvent.click(within(card()).getByRole('button', { name: button }))
const sheet = screen.getByRole('dialog')
fireEvent.change(within(sheet).getByLabelText('Brave API key'), { target: { value: key } })
fireEvent.click(within(sheet).getByRole('button', { name: 'Use this key' }))
}
it('shows the saved SearXNG URL', async () => {
stubAPI()
await renderCard()
expect(searxng()).toHaveAttribute('aria-checked', 'true')
expect(url()).toHaveValue('http://searxng:8080')
expect(within(card()).queryByText('Brave API key')).not.toBeInTheDocument()
expect(saveSearch()).toBeDisabled()
expect(testSearch()).toBeEnabled()
})
const UNCONFIGURED = {
provider: null,
base_url: null,
default_base_url: 'http://searxng:8080',
api_key_set: false,
api_key_last4: null,
updated_at: null,
}
it('starts on SearXNG with the deployment\'s URL before recipe search is set up, and saves it', async () => {
const fetchMock = stubAPI({
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED),
'PATCH /api/v1/settings/recipe-search': (body) =>
jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, ...(body as object) }),
})
await renderCard()
expect(searxng()).toHaveAttribute('aria-checked', 'true')
expect(url()).toHaveValue('http://searxng:8080')
expect(testSearch()).toBeDisabled()
expect(saveSearch()).toBeEnabled()
fireEvent.click(saveSearch())
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{
request: 'PATCH /api/v1/settings/recipe-search',
body: { provider: 'searxng', base_url: 'http://searxng:8080' },
},
])
})
it('starts with an empty URL when the deployment names no SearXNG', async () => {
stubAPI({
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, { ...UNCONFIGURED, default_base_url: null }),
})
await renderCard()
expect(url()).toHaveValue('')
})
it('shows the saved URL, not the deployment\'s', async () => {
stubAPI({
'GET /api/v1/settings/recipe-search': () =>
jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, base_url: 'https://search.example' }),
})
await renderCard()
expect(url()).toHaveValue('https://search.example')
expect(saveSearch()).toBeDisabled()
})
it('switches providers, keeping each provider\'s field', async () => {
stubAPI()
await renderCard()
fireEvent.change(url(), { target: { value: 'https://search.example' } })
fireEvent.click(brave())
expect(brave()).toHaveAttribute('aria-checked', 'true')
expect(within(card()).queryByRole('textbox', { name: 'SearXNG URL' })).not.toBeInTheDocument()
expect(within(card()).getByText('No key stored')).toBeInTheDocument()
expect(within(card()).getByRole('button', { name: 'Add key' })).toBeInTheDocument()
fireEvent.click(searxng())
expect(url()).toHaveValue('https://search.example')
})
it('saves a new SearXNG URL', async () => {
const fetchMock = stubAPI({
'PATCH /api/v1/settings/recipe-search': (body) =>
jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, ...(body as object) }),
})
await renderCard()
fireEvent.change(url(), { target: { value: 'https://search.example' } })
expect(testSearch()).toBeDisabled()
expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument()
fireEvent.click(saveSearch())
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{ request: 'PATCH /api/v1/settings/recipe-search', body: { base_url: 'https://search.example' } },
])
expect(saveSearch()).toBeDisabled()
expect(testSearch()).toBeEnabled()
})
it('saves a switch to Brave with its key, then shows only the key\'s last four characters', async () => {
const fetchMock = stubAPI({ 'PATCH /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) })
await renderCard()
fireEvent.click(brave())
addKey('Add key', 'BSA-secret-key-b7c2')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument()
expect(within(card()).getByText('New key not saved yet')).toBeInTheDocument()
expect(document.body).not.toHaveTextContent('BSA-secret-key-b7c2')
fireEvent.click(saveSearch())
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{ request: 'PATCH /api/v1/settings/recipe-search', body: { provider: 'brave', api_key: 'BSA-secret-key-b7c2' } },
])
expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument()
expect(within(card()).queryByText('New key not saved yet')).not.toBeInTheDocument()
expect(within(card()).getByRole('button', { name: 'Replace' })).toBeInTheDocument()
expect(document.body).not.toHaveTextContent('BSA-secret-key-b7c2')
})
it('masks a stored Brave key and replaces it', async () => {
const fetchMock = stubAPI({
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS),
'PATCH /api/v1/settings/recipe-search': () => jsonResponse(200, { ...BRAVE_SETTINGS, api_key_last4: '9d0e' }),
})
await renderCard()
expect(brave()).toHaveAttribute('aria-checked', 'true')
expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument()
expect(saveSearch()).toBeDisabled()
addKey('Replace', 'BSA-other-key-9d0e')
fireEvent.click(within(card()).getByRole('button', { name: 'Discard' }))
expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument()
expect(saveSearch()).toBeDisabled()
addKey('Replace', 'BSA-other-key-9d0e')
fireEvent.click(saveSearch())
expect(await within(card()).findByText('••••••••9d0e')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{ request: 'PATCH /api/v1/settings/recipe-search', body: { api_key: 'BSA-other-key-9d0e' } },
])
})
it('reports a save the server refuses', async () => {
stubAPI({
'PATCH /api/v1/settings/recipe-search': () =>
apiError(400, 'invalid_settings', 'invalid recipe search settings: api_key: must be at least 8 characters'),
})
await renderCard()
fireEvent.click(brave())
addKey('Add key', 'short')
fireEvent.click(saveSearch())
expect(await within(card()).findByRole('alert')).toHaveTextContent(
'Not saved: invalid recipe search settings: api_key: must be at least 8 characters.',
)
})
it('shows how many recipes the test found', async () => {
const fetchMock = stubAPI({
'POST /api/v1/settings/recipe-search/test': () =>
jsonResponse(200, { query: 'chicken tikka masala', count: 12 }),
})
await renderCard()
fireEvent.click(testSearch())
expect(await within(card()).findByText("Found 12 recipes for 'chicken tikka masala'")).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([{ request: 'POST /api/v1/settings/recipe-search/test', body: undefined }])
})
it('shows the error when the test fails', async () => {
stubAPI({
'POST /api/v1/settings/recipe-search/test': () =>
apiError(502, 'search_failed', 'recipes: SearXNG answered 403 Forbidden'),
})
await renderCard()
fireEvent.click(testSearch())
expect(await within(card()).findByRole('alert')).toHaveTextContent(
'Test failed: recipes: SearXNG answered 403 Forbidden',
)
})
it('clears a test result when the settings are edited', async () => {
stubAPI({
'POST /api/v1/settings/recipe-search/test': () =>
jsonResponse(200, { query: 'chicken tikka masala', count: 12 }),
})
await renderCard()
fireEvent.click(testSearch())
expect(await within(card()).findByText("Found 12 recipes for 'chicken tikka masala'")).toBeInTheDocument()
fireEvent.change(url(), { target: { value: 'https://search.example' } })
expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument()
expect(within(card()).queryByText(/Found 12 recipes/)).not.toBeInTheDocument()
})
it('clears a test error when the settings are edited', async () => {
stubAPI({
'POST /api/v1/settings/recipe-search/test': () =>
apiError(502, 'search_failed', 'recipes: SearXNG answered 403 Forbidden'),
})
await renderCard()
fireEvent.click(testSearch())
expect(await within(card()).findByRole('alert')).toHaveTextContent('Test failed')
fireEvent.click(brave())
expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument()
expect(within(card()).queryByRole('alert')).not.toBeInTheDocument()
})
it('asks before leaving with an unsaved recipe search change', async () => {
stubAPI()
await renderCard()
fireEvent.change(url(), { target: { value: 'https://search.example' } })
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
expect(await screen.findByRole('dialog', { name: 'Leave without saving?' })).toBeInTheDocument()
})
it('does not count the suggested SearXNG URL as an unsaved change until it is edited', async () => {
stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED) })
await renderCard()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
expect(saveSearch()).toBeEnabled()
expect(unload()).toBe(false)
fireEvent.change(url(), { target: { value: 'https://search.example' } })
expect(unload()).toBe(true)
fireEvent.change(url(), { target: { value: 'http://searxng:8080' } })
expect(unload()).toBe(false)
})
it('leaves the untouched first-use card without asking', async () => {
stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED) })
await renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('locks the card while a save is pending, and guards leaving until the save lands', async () => {
let answer: (response: Response) => void = () => {}
stubAPI({ 'PATCH /api/v1/settings/recipe-search': () => new Promise<Response>((resolve) => (answer = resolve)) })
await renderCard()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
fireEvent.change(url(), { target: { value: 'https://search.example' } })
fireEvent.click(saveSearch())
expect(await within(card()).findByRole('button', { name: 'Saving…' })).toBeDisabled()
for (const field of [url(), searxng(), brave()]) expect(field).toBeDisabled()
expect(unload()).toBe(true)
answer(jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, base_url: 'https://search.example' }))
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
expect(url()).toBeEnabled()
expect(url()).toHaveValue('https://search.example')
await waitFor(() => expect(unload()).toBe(false))
})
it('keeps 44 px tap targets', async () => {
stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) })
await renderCard()
for (const control of [
searxng(),
brave(),
within(card()).getByRole('button', { name: 'Replace' }),
testSearch(),
saveSearch(),
]) {
expectMinHitArea(control)
}
})
})
describe('maskedKey', () => {
it('shows only the last four characters', () => {
expect(maskedKey('3f9a')).toBe('••••••••3f9a')
})
})
+546
View File
@@ -0,0 +1,546 @@
import { ChevronLeft } from 'lucide-react'
import { useEffect, useId, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router'
import { ApiError } from '../api/index.ts'
import { Button } from '../ui/Button.tsx'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Select } from '../ui/Select.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Slider } from '../ui/Slider.tsx'
import { Tag } from '../ui/Tag.tsx'
import { TextInput } from '../ui/TextInput.tsx'
import {
aiKeyPolicy,
aiProviderLabels,
maskedKey,
providersWithBaseURL,
useAIModels,
useAISettings,
useResetAIPrompt,
useSaveAISettings,
useSetAIKey,
useVerifyAIKey,
type AIProvider,
type AISettings,
type AISettingsPatch,
} from './api.ts'
import styles from './AISettingsPage.module.css'
import { Confirm, LeaveGuard } from './LeaveGuard.tsx'
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
// TYPE_MODEL is the Model dropdown's "Type a model id" option.
const TYPE_MODEL = '\u0000type'
// Admin · AI assistant (handoff mock 1o). Provider, model, temperature and
// system prompt are edited as a draft and saved together; the API key is
// stored (and verified) on its own, straight away. The client only ever
// sees the key's last four characters. Recipe search is its own card, saved
// on its own.
export function AISettingsPage() {
const [aiDirty, setAIDirty] = useState(false)
const [recipeSearchDirty, setRecipeSearchDirty] = useState(false)
return (
<div className={styles.page}>
<Header />
<AIAssistantSettings onDirtyChange={setAIDirty} />
<RecipeSearchCard onDirtyChange={setRecipeSearchDirty} />
<LeaveGuard dirty={aiDirty || recipeSearchDirty} />
</div>
)
}
type AISettingsProps = {
// Called with whether the form holds unsaved changes.
onDirtyChange: (dirty: boolean) => void
}
// AIAssistantCard is the AI assistant's fields as a card, for the desktop
// settings (handoff mock 2e): the system prompt grows to fill the card.
export function AIAssistantCard({ onDirtyChange }: AISettingsProps) {
const titleId = useId()
return (
<Card className={`${styles.card} ${styles.fill}`} aria-labelledby={titleId} role="region">
<div className={styles.cardHeader}>
<h2 id={titleId} className={styles.cardTitle}>
AI assistant
</h2>
<Tag>Admin</Tag>
</div>
<AIAssistantSettings onDirtyChange={onDirtyChange} />
</Card>
)
}
// AIAssistantSettings is the AI assistant's form once its settings load.
function AIAssistantSettings({ onDirtyChange }: AISettingsProps) {
const settings = useAISettings()
return (
<>
{settings.isPending && <Skeleton className={styles.loading} />}
{settings.isError && (
<ErrorBanner onRetry={() => void settings.refetch()}>
AI settings could not load: {settings.error.message}
</ErrorBanner>
)}
{settings.isSuccess && <AISettingsForm saved={settings.data} onDirtyChange={onDirtyChange} />}
</>
)
}
function Header() {
const navigate = useNavigate()
return (
<header className={styles.header}>
<IconButton label="Back to Settings" onClick={() => void navigate('/settings')}>
<Icon icon={ChevronLeft} size="nav" />
</IconButton>
<div>
<p className={styles.kicker}>Settings · Admin</p>
<h1 className={styles.title}>AI assistant</h1>
</div>
</header>
)
}
type Draft = {
provider: AIProvider
base_url: string
model: string
temperature: number
system_prompt: string
}
function draftOf(saved: AISettings): Draft {
return {
provider: saved.provider,
base_url: saved.base_url,
model: saved.model,
temperature: saved.temperature,
system_prompt: saved.system_prompt ?? '',
}
}
// patchOf is the PATCH body for the fields draft changes.
function patchOf(saved: AISettings, draft: Draft): AISettingsPatch {
const before = draftOf(saved)
const patch: AISettingsPatch = {}
if (draft.provider !== before.provider) patch.provider = draft.provider
if (draft.base_url !== before.base_url) patch.base_url = draft.base_url
if (draft.model !== before.model) patch.model = draft.model
if (draft.temperature !== before.temperature) patch.temperature = draft.temperature
if (draft.system_prompt !== before.system_prompt) patch.system_prompt = draft.system_prompt
return patch
}
type AISettingsFormProps = AISettingsProps & {
saved: AISettings
}
function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) {
const [draft, setDraft] = useState(() => draftOf(saved))
const [typingModel, setTypingModel] = useState(false)
const [confirmingReset, setConfirmingReset] = useState(false)
const save = useSaveAISettings()
const resetPrompt = useResetAIPrompt()
const patch = patchOf(saved, draft)
const dirty = Object.keys(patch).length > 0
const providerChanged = draft.provider !== saved.provider
// The server keeps a stored key's provider and base URL fixed until the
// key is removed.
const keyLocked = saved.api_key_last4 !== null && (providerChanged || draft.base_url !== saved.base_url)
const temperatureId = useId()
// The model list is the saved provider's, so it is asked for only while
// that provider is chosen and has the key it needs.
const canList =
!providerChanged && (aiKeyPolicy[saved.provider] !== 'required' || saved.api_key_last4 !== null)
const models = useAIModels(canList)
const listed = canList && models.isSuccess ? models.data : []
const typedModel = typingModel || !(draft.model === '' || listed.some((m) => m.id === draft.model))
useEffect(() => {
onDirtyChange(dirty)
}, [dirty, onDirtyChange])
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
function edit(change: Partial<Draft>) {
save.reset()
setDraft((current) => ({ ...current, ...change }))
}
function chooseProvider(provider: AIProvider) {
setTypingModel(provider !== saved.provider)
if (provider === saved.provider) {
edit({ provider, base_url: saved.base_url, model: saved.model })
return
}
// A fixed-URL preset refuses any other base URL, and the old model is
// another provider's: the household types the new one.
edit({ provider, base_url: providersWithBaseURL.has(provider) ? draft.base_url : '', model: '' })
}
function onSave() {
save.mutate(patch, {
onSuccess: (settings) => {
setDraft(draftOf(settings))
setTypingModel(false)
},
})
}
function onConfirmReset() {
resetPrompt.mutate(undefined, {
onSuccess: (settings) => {
setDraft((current) => ({ ...current, system_prompt: settings.system_prompt ?? '' }))
setConfirmingReset(false)
},
})
}
return (
<fieldset className={styles.lock} disabled={save.isPending || resetPrompt.isPending}>
<div className={styles.columns}>
<Select
label="Provider"
value={draft.provider}
onChange={(event) => chooseProvider(event.target.value as AIProvider)}
>
{Object.entries(aiProviderLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</Select>
<div className={styles.model}>
<Select
label="Model"
value={typedModel ? TYPE_MODEL : draft.model}
onChange={(event) => {
const model = event.target.value
setTypingModel(model === TYPE_MODEL)
if (model !== TYPE_MODEL) edit({ model })
}}
>
{!typedModel && draft.model === '' && (
<option value="" disabled>
Choose a model
</option>
)}
{listed.map((model) => (
<option key={model.id} value={model.id}>
{model.display_name}
</option>
))}
<option value={TYPE_MODEL}>Type a model id</option>
</Select>
{canList && models.isPending && <p className={styles.note}>Loading models…</p>}
{canList && models.isError && <p className={styles.note}>Model list unavailable: {models.error.message}</p>}
{!canList && !providerChanged && (
<p className={styles.note}>Add the API key to choose from the provider's models.</p>
)}
</div>
</div>
{keyLocked && <p className={styles.hint}>{keyLockedGuidance}</p>}
{typedModel && (
<TextInput
label="Model id"
className={styles.mono}
value={draft.model}
placeholder="e.g. claude-sonnet-4-5"
autoComplete="off"
spellCheck={false}
onChange={(event) => edit({ model: event.target.value })}
/>
)}
{providersWithBaseURL.has(draft.provider) && (
<TextInput
label="Base URL"
type="url"
className={styles.mono}
value={draft.base_url}
placeholder="https://"
autoComplete="off"
spellCheck={false}
onChange={(event) => edit({ base_url: event.target.value })}
/>
)}
<APIKey saved={saved} draftProvider={draft.provider} />
<div className={styles.temperature}>
<div className={styles.temperatureHeader}>
<span id={temperatureId} className={styles.label}>
Creativity (temperature)
</span>
<output htmlFor={temperatureId} className={styles.value}>
{draft.temperature.toFixed(1)}
</output>
</div>
<Slider
aria-labelledby={temperatureId}
aria-valuetext={`${draft.temperature.toFixed(1)}, from Familiar (0) to Adventurous (1)`}
value={draft.temperature}
min={0}
max={1}
step={0.1}
onChange={(event) => edit({ temperature: Math.round(Number(event.target.value) * 10) / 10 })}
/>
<div className={styles.ends} aria-hidden="true">
<span>Familiar</span>
<span>Adventurous</span>
</div>
</div>
<PromptField value={draft.system_prompt} onChange={(system_prompt) => edit({ system_prompt })} />
<div className={styles.status} role="status">
{save.isSuccess && !dirty && 'Changes saved.'}
</div>
{save.isError && <p className={styles.error} role="alert">{saveErrorMessage(save.error)}</p>}
{resetPrompt.isError && !confirmingReset && (
<p className={styles.error} role="alert">
The system prompt was not reset: {resetPrompt.error.message}
</p>
)}
<div className={styles.actions}>
<Button variant="outline" className={styles.reset} onClick={() => setConfirmingReset(true)}>
Reset
</Button>
<Button className={styles.save} disabled={!dirty || save.isPending} onClick={onSave}>
{save.isPending ? 'Saving…' : 'Save changes'}
</Button>
</div>
<Confirm
open={confirmingReset}
title="Reset the system prompt?"
confirmLabel={resetPrompt.isPending ? 'Resetting…' : 'Reset prompt'}
busy={resetPrompt.isPending}
error={resetPrompt.isError ? `The system prompt was not reset: ${resetPrompt.error.message}` : null}
onCancel={() => setConfirmingReset(false)}
onConfirm={onConfirmReset}
>
This restores MealQuest's default system prompt for everyone in the household.
</Confirm>
</fieldset>
)
}
// formatVerifiedDate turns key_verified_at into the handoff's "Oct 1".
export function formatVerifiedDate(timestamp: string): string {
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) throw new Error(`key_verified_at "${timestamp}" is not a timestamp`)
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(date)
}
// keyLockedGuidance is how to change the provider or base URL while a key
// is stored.
const keyLockedGuidance =
'An API key is stored. Remove it (Replace → Remove key) before changing the provider or base URL.'
// APIKey stays mounted whatever provider the draft names, so a key stored
// (and the verify that follows it) completes even if the household changes
// the provider meanwhile, and a stored key can always be removed.
function APIKey({ saved, draftProvider }: { saved: AISettings; draftProvider: AIProvider }) {
const [replacing, setReplacing] = useState(false)
const [key, setKey] = useState('')
const store = useSetAIKey()
const verify = useVerifyAIKey()
const labelId = useId()
const sheetTitleId = useId()
const last4 = saved.api_key_last4
const providerChanged = draftProvider !== saved.provider
function open() {
store.reset()
setReplacing(true)
}
// close keeps the store mutation: a key already sent is still stored and
// verified after the sheet is dismissed.
function close() {
setReplacing(false)
setKey('')
}
function onStore() {
verify.reset()
store.mutate(key, {
onSuccess: () => {
close()
verify.mutate(undefined)
},
})
}
function onRemove() {
verify.reset()
store.mutate('', { onSuccess: close })
}
const storeError = store.isError && (
<p className={styles.error} role="alert">
The key was not stored: {store.error.message}
</p>
)
if (last4 === null && aiKeyPolicy[draftProvider] === 'none') {
return (
<div className={styles.key}>
<p className={styles.hint}>{aiProviderLabels[draftProvider]} takes no API key.</p>
{!replacing && storeError}
</div>
)
}
return (
<div className={styles.key}>
<span id={labelId} className={styles.label}>
API key
</span>
<div className={styles.keyRow} role="group" aria-labelledby={labelId}>
<span className={styles.keyValue}>{last4 === null ? 'No key stored' : maskedKey(last4)}</span>
<button
type="button"
className={styles.replace}
disabled={store.isPending || (last4 === null && providerChanged)}
onClick={open}
>
{store.isPending ? 'Saving…' : last4 === null ? 'Add key' : 'Replace'}
</button>
</div>
{last4 === null && providerChanged && (
<p className={styles.note}>Save the provider change before adding its key.</p>
)}
{!replacing && storeError}
<KeyStatus saved={saved} verify={verify} />
<Sheet open={replacing} onClose={close} aria-labelledby={sheetTitleId}>
<form
className={styles.sheet}
onSubmit={(event) => {
event.preventDefault()
onStore()
}}
>
<h2 id={sheetTitleId} className={styles.sheetTitle}>
{last4 === null ? 'Add API key' : 'Replace API key'}
</h2>
<p className={styles.sheetText}>
The key is stored on the server for {aiProviderLabels[saved.provider]}. This device only ever sees its
last four characters.
</p>
<TextInput
label="API key"
type="password"
className={styles.mono}
value={key}
autoComplete="off"
spellCheck={false}
onChange={(event) => setKey(event.target.value)}
/>
{storeError}
<div className={styles.sheetActions}>
{last4 !== null && (
<Button variant="outline" disabled={store.isPending} onClick={onRemove}>
Remove key
</Button>
)}
<Button type="submit" disabled={key === '' || store.isPending}>
{store.isPending ? 'Saving…' : 'Save and verify'}
</Button>
</div>
</form>
</Sheet>
</div>
)
}
type KeyStatusProps = {
saved: AISettings
verify: ReturnType<typeof useVerifyAIKey>
}
// KeyStatus is the line under the key: verifying, the verify error, when it
// was verified, or that it has not been.
function KeyStatus({ saved, verify }: KeyStatusProps) {
if (saved.api_key_last4 === null) return null
if (verify.isPending) return <p className={styles.note}>Verifying…</p>
if (verify.isError) {
return (
<div className={styles.keyStatus}>
<p className={styles.error} role="alert">
{verifyErrorMessage(verify.error)}
</p>
<VerifyButton verify={verify}>Try again</VerifyButton>
</div>
)
}
if (saved.key_verified_at !== null) {
return <p className={styles.verified}>Verified · {formatVerifiedDate(saved.key_verified_at)}</p>
}
return (
<div className={styles.keyStatus}>
<p className={styles.note}>Not verified yet</p>
<VerifyButton verify={verify}>Verify</VerifyButton>
</div>
)
}
function VerifyButton({ verify, children }: { verify: KeyStatusProps['verify']; children: ReactNode }) {
return (
<button type="button" className={styles.link} onClick={() => verify.mutate(undefined)}>
{children}
</button>
)
}
// verifyErrorMessage says why POST /settings/ai/verify failed, by the
// error code the server names (internal/server/aisettings.go).
export function verifyErrorMessage(error: Error): string {
if (!(error instanceof ApiError)) return `Verification failed: ${error.message}`
switch (error.code) {
case 'auth_failed':
return 'Verification failed: the provider rejected this key.'
case 'rate_limited':
return 'Verification failed: the provider is rate limiting requests. Try again shortly.'
case 'timeout':
return 'Verification failed: the provider did not answer in time.'
case 'settings_changed':
return 'Verification failed: the settings changed while verifying. Try again.'
default:
return `Verification failed: ${error.message}`
}
}
// saveErrorMessage says why a save failed. The server refuses to move a
// stored key to another provider or base URL; it names how to fix that.
function saveErrorMessage(error: Error): string {
if (error instanceof ApiError && error.code === 'invalid_settings') {
const guidance = error.message.includes('while an API key is stored') ? ` ${keyLockedGuidance}` : ''
return `Not saved: ${error.message}.${guidance}`
}
return `Not saved: ${error.message}`
}
function PromptField({ value, onChange }: { value: string; onChange: (value: string) => void }) {
const id = useId()
return (
<div className={styles.prompt}>
<label htmlFor={id} className={styles.label}>
System prompt
</label>
<textarea
id={id}
className={styles.textarea}
value={value}
rows={8}
spellCheck={false}
onChange={(event) => onChange(event.target.value)}
/>
</div>
)
}
@@ -0,0 +1,237 @@
/* Settings · Household. */
.page {
box-sizing: border-box;
min-height: 100%;
max-width: 560px;
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 6px;
}
.header {
display: flex;
align-items: center;
gap: 16px;
}
.kicker {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-accent-700);
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 28px;
font-weight: 400;
}
.shared {
margin: 0;
padding: 14px 16px;
border-radius: 26px;
background: var(--color-accent-2-100);
color: var(--color-accent-2-900);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
}
.loading {
height: 420px;
border-radius: 26px;
}
.card {
display: flex;
flex-direction: column;
gap: 18px;
border: 1px solid var(--color-neutral-300);
}
.cardTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.groupLabel {
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
.columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
align-items: end;
}
.time {
width: 100%;
}
/* Beside a day dropdown, a time matches its height. */
.pairedTime {
height: 56px;
}
.hint,
.error,
.status {
margin: 0;
font-family: var(--font-body);
font-size: 13px;
line-height: 1.4;
}
.hint {
color: var(--color-neutral-700);
}
.error {
font-weight: 700;
color: var(--color-accent-700);
}
.errors {
display: flex;
flex-direction: column;
gap: 4px;
}
.status {
color: var(--color-accent-2-800);
}
.status:empty {
display: none;
}
.zoneRow {
display: flex;
align-items: center;
gap: 10px;
min-height: 56px;
padding: 0 6px 0 20px;
border: 1px solid var(--color-neutral-300);
border-radius: var(--radius-pill);
background: var(--color-surface);
}
.zoneValue {
flex: 1;
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0 8px;
}
.zoneName {
min-width: 0;
overflow: hidden;
font-family: var(--font-body);
font-size: 15px;
font-weight: 700;
text-overflow: ellipsis;
}
.offset {
font-family: var(--font-mono);
font-size: 13px;
color: var(--color-neutral-700);
}
.change {
flex: none;
min-width: 44px;
min-height: 44px;
padding: 0 18px;
border: 0;
border-radius: var(--radius-pill);
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.actions {
margin-top: auto;
display: grid;
padding-top: 6px;
}
.save {
min-height: 52px;
}
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 560px;
margin: 0 auto;
}
.zoneList {
max-height: 50vh;
margin: 0;
padding: 0;
overflow-y: auto;
list-style: none;
}
.zoneOption {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-width: 44px;
min-height: 48px;
padding: 0 16px;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-text);
text-align: start;
cursor: pointer;
}
.zoneOption .zoneName {
flex: 1;
}
.zoneOption[aria-pressed='true'] {
background: var(--color-accent-2-100);
color: var(--color-accent-2-900);
}
.check {
flex: none;
}
/* A form locked while its save is pending, so no edit is made that the
saved settings would then replace. It lays out as if it were not there. */
.lock {
display: contents;
}
@@ -0,0 +1,394 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, ApiError, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { householdSettingsProblems } from './api.ts'
import { HouseholdSettingsPage } from './HouseholdSettingsPage.tsx'
import { matchesZone, utcOffset, zoneLabel } from './timezones.ts'
type Responder = (body: unknown) => Response | Promise<Response>
// stubAPI answers "METHOD path" from responders, each called with the
// request's decoded JSON body; GET /settings/household answers
// HOUSEHOLD_SETTINGS unless overridden. Any other request fails the test.
function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch> {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined
return Promise.resolve(respond(body))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// sent lists the requests other than GETs, as "METHOD path" and body.
function sent(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
.map(([input, init]) => ({
request: `${init?.method} ${String(input)}`,
body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined,
}))
}
afterEach(() => {
vi.unstubAllGlobals()
})
function CurrentPath() {
return <p data-testid="path">{useLocation().pathname}</p>
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the page at /settings/household in a data router, so its leave guard works.
async function renderPage() {
const api = createApiClient(
fakeLocation('/settings/household'),
createNetworkStatus(window),
createVerifiedSession(localStorage),
)
await api.loadMe()
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings/household">
<Routes>
<Route path="/settings/household" element={<HouseholdSettingsPage />} />
<Route path="*" element={<CurrentPath />} />
</Routes>
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
await screen.findByLabelText('Dinner time')
}
const save = () => screen.getByRole('button', { name: 'Save changes' })
const input = (name: string) => screen.getByLabelText(name)
const zoneGroup = () => screen.getByRole('group', { name: 'Time zone' })
describe('HouseholdSettingsPage', () => {
it('loads every household setting with plain-word copy', async () => {
stubAPI()
await renderPage()
expect(screen.getByRole('button', { name: 'Back to Settings' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: 'Household' })).toBeInTheDocument()
expect(screen.getByText('Changes apply to everyone in the household.')).toBeInTheDocument()
expect(zoneGroup()).toHaveTextContent('America / New York')
expect(zoneGroup()).toHaveTextContent(utcOffset('America/New_York', new Date()))
expect(input('Dinner time')).toHaveValue('17:30')
expect(input('Dinner time')).toHaveAccessibleDescription(/crock-pot dinners count back from it/)
expect(input('Day-of reminder')).toHaveValue('16:30')
expect(input('Rotation check')).toHaveValue('09:00')
expect(input('Rotation check')).toHaveAccessibleDescription(/When we ask whether to keep a new dinner/)
expect(input('Plan draft day')).toHaveValue('5')
expect(within(input('Plan draft day')).getByRole('option', { selected: true })).toHaveTextContent('Friday')
expect(input('Plan draft time')).toHaveValue('09:00')
expect(input('New suggestions day')).toHaveValue('0')
expect(input('New suggestions time')).toHaveValue('08:00')
expect(input('Google Chat Supply run day')).toHaveValue('6')
expect(input('Google Chat Supply run time')).toHaveValue('08:00')
expect(input('Google Chat Supply run time')).toHaveAccessibleDescription(/Supply run is sent to your Google Chat/)
expect(save()).toBeDisabled()
})
it('reports a load failure with a retry', async () => {
stubAPI({ 'GET /api/v1/settings/household': () => errorResponse(500, 'internal') })
const api = createApiClient(
fakeLocation('/settings/household'),
createNetworkStatus(window),
createVerifiedSession(localStorage),
)
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings/household">
<HouseholdSettingsPage />
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
expect(await screen.findByRole('alert')).toHaveTextContent('Household settings could not load: internal message')
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
})
it('saves only the edited fields', async () => {
const fetchMock = stubAPI({
'PATCH /api/v1/settings/household': (body) => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, ...(body as object) }),
})
await renderPage()
fireEvent.change(input('Dinner time'), { target: { value: '18:15' } })
fireEvent.change(input('Rotation check'), { target: { value: '10:00' } })
fireEvent.change(input('Plan draft day'), { target: { value: '4' } })
fireEvent.change(input('Google Chat Supply run time'), { target: { value: '07:30' } })
// Changed and changed back: not sent.
fireEvent.change(input('Day-of reminder'), { target: { value: '16:00' } })
fireEvent.change(input('Day-of reminder'), { target: { value: '16:30' } })
fireEvent.click(save())
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{
request: 'PATCH /api/v1/settings/household',
body: { dinner_time: '18:15', review_time: '10:00', plan_draft_dow: 4, chat_autosend_time: '07:30' },
},
])
expect(input('Dinner time')).toHaveValue('18:15')
expect(save()).toBeDisabled()
})
it('chooses a time zone from the searchable list', async () => {
const fetchMock = stubAPI({
'PATCH /api/v1/settings/household': (body) => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, ...(body as object) }),
})
await renderPage()
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
const sheet = screen.getByRole('dialog', { name: 'Choose a time zone' })
const list = within(sheet).getByRole('list', { name: 'Time zones' })
expect(within(list).getByRole('button', { pressed: true })).toHaveAccessibleName(
`America / New York, ${utcOffset('America/New_York', new Date())}`,
)
fireEvent.change(within(sheet).getByRole('searchbox', { name: 'Search time zones' }), {
target: { value: 'los angeles' },
})
const options = within(list).getAllByRole('button')
expect(options).toHaveLength(1)
expect(within(sheet).getByText('1 time zone')).toBeInTheDocument()
expect(options[0]).toHaveAccessibleName(`America / Los Angeles, ${utcOffset('America/Los_Angeles', new Date())}`)
fireEvent.click(options[0]!)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(zoneGroup()).toHaveTextContent('America / Los Angeles')
fireEvent.click(save())
await screen.findByText('Changes saved.')
expect(sent(fetchMock)).toEqual([
{ request: 'PATCH /api/v1/settings/household', body: { timezone: 'America/Los_Angeles' } },
])
})
it('offers the UTC zones the browser list leaves out', async () => {
expect(Intl.supportedValuesOf('timeZone')).not.toContain('Etc/UTC')
const fetchMock = stubAPI({
'PATCH /api/v1/settings/household': (body) => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, ...(body as object) }),
})
await renderPage()
expect(zoneGroup()).toHaveTextContent('America / New York')
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
const sheet = screen.getByRole('dialog', { name: 'Choose a time zone' })
const list = within(sheet).getByRole('list', { name: 'Time zones' })
fireEvent.change(within(sheet).getByRole('searchbox', { name: 'Search time zones' }), {
target: { value: 'utc' },
})
expect(within(list).getByRole('button', { name: 'UTC, UTC+00:00' })).toBeInTheDocument()
fireEvent.click(within(list).getByRole('button', { name: 'Etc / UTC, UTC+00:00' }))
expect(zoneGroup()).toHaveTextContent('Etc / UTC')
fireEvent.click(save())
await screen.findByText('Changes saved.')
expect(sent(fetchMock)).toEqual([
{ request: 'PATCH /api/v1/settings/household', body: { timezone: 'Etc/UTC' } },
])
})
it('keeps offering a saved zone the browser list leaves out after another is chosen', async () => {
expect(Intl.supportedValuesOf('timeZone')).not.toContain('US/Eastern')
stubAPI({
'GET /api/v1/settings/household': () => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, timezone: 'US/Eastern' }),
})
await renderPage()
const search = (value: string) =>
fireEvent.change(screen.getByRole('searchbox', { name: 'Search time zones' }), { target: { value } })
const option = (name: RegExp) =>
within(screen.getByRole('list', { name: 'Time zones' })).getByRole('button', { name })
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
search('los angeles')
fireEvent.click(option(/^America \/ Los Angeles,/))
expect(zoneGroup()).toHaveTextContent('America / Los Angeles')
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
search('us eastern')
fireEvent.click(option(/^US \/ Eastern,/))
expect(zoneGroup()).toHaveTextContent('US / Eastern')
expect(save()).toBeDisabled()
})
it('says when no time zone matches the search', async () => {
stubAPI()
await renderPage()
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
const sheet = screen.getByRole('dialog', { name: 'Choose a time zone' })
fireEvent.change(within(sheet).getByRole('searchbox'), { target: { value: 'Atlantis' } })
expect(within(sheet).getByText('No time zone matches.')).toBeInTheDocument()
expect(within(sheet).queryAllByRole('listitem')).toHaveLength(0)
})
it('shows the API validation errors beside their fields', async () => {
stubAPI({
'PATCH /api/v1/settings/household': () =>
jsonResponse(400, {
error: {
code: 'invalid_settings',
message:
'invalid household settings: timezone: unknown time zone "Mars/Olympus" (want an IANA zone such as America/New_York); dinner_time: invalid time "" (want 24-hour HH:MM, 00:00-23:59)',
retryable: false,
},
}),
})
await renderPage()
fireEvent.change(input('Dinner time'), { target: { value: '' } })
fireEvent.click(save())
expect(await screen.findByRole('alert')).toHaveTextContent('Not saved: fix the settings marked below their fields.')
expect(input('Dinner time')).toHaveAttribute('aria-invalid', 'true')
expect(input('Dinner time')).toHaveAccessibleDescription(/^invalid time "" \(want 24-hour HH:MM, 00:00-23:59\)/)
expect(zoneGroup()).toHaveAccessibleDescription(
'unknown time zone "Mars/Olympus" (want an IANA zone such as America/New_York)',
)
expect(input('Day-of reminder')).toHaveAttribute('aria-invalid', 'false')
// Editing again clears the refused save's errors.
fireEvent.change(input('Dinner time'), { target: { value: '18:00' } })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(input('Dinner time')).toHaveAttribute('aria-invalid', 'false')
})
it('shows an error that names no field as a whole-form error', async () => {
stubAPI({ 'PATCH /api/v1/settings/household': () => errorResponse(500, 'internal') })
await renderPage()
fireEvent.change(input('Dinner time'), { target: { value: '18:00' } })
fireEvent.click(save())
expect(await screen.findByRole('alert')).toHaveTextContent('Not saved: internal message')
expect(input('Dinner time')).toHaveValue('18:00')
expect(save()).toBeEnabled()
})
it('asks before leaving with unsaved changes', async () => {
stubAPI()
await renderPage()
fireEvent.change(input('New suggestions time'), { target: { value: '07:00' } })
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
const confirm = await screen.findByRole('dialog', { name: 'Leave without saving?' })
fireEvent.click(within(confirm).getByRole('button', { name: 'Keep editing' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(input('New suggestions time')).toHaveValue('07:00')
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Leave' }))
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('locks the form while a save is pending, and guards leaving until the save lands', async () => {
let answer: (response: Response) => void = () => {}
stubAPI({
'PATCH /api/v1/settings/household': () => new Promise<Response>((resolve) => (answer = resolve)),
})
await renderPage()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
fireEvent.change(input('Dinner time'), { target: { value: '18:15' } })
fireEvent.click(save())
expect(await screen.findByRole('button', { name: 'Saving…' })).toBeDisabled()
for (const field of ['Dinner time', 'Day-of reminder', 'Plan draft day', 'Google Chat Supply run time']) {
expect(input(field)).toBeDisabled()
}
expect(screen.getByRole('button', { name: 'Change' })).toBeDisabled()
expect(unload()).toBe(true)
answer(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, dinner_time: '18:15' }))
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
expect(input('Dinner time')).toBeEnabled()
expect(input('Dinner time')).toHaveValue('18:15')
await waitFor(() => expect(unload()).toBe(false))
})
it('leaves without asking when nothing changed', async () => {
stubAPI()
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('keeps 44 px tap targets', async () => {
stubAPI()
await renderPage()
for (const control of [
screen.getByRole('button', { name: 'Back to Settings' }),
within(zoneGroup()).getByRole('button', { name: 'Change' }),
input('Dinner time'),
input('Plan draft day'),
input('Plan draft time'),
save(),
]) {
expectMinHitArea(control)
}
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
expectMinHitArea(within(screen.getByRole('dialog')).getAllByRole('button', { pressed: true })[0]!)
})
})
describe('householdSettingsProblems', () => {
it('reads each field the server names', () => {
const error = new ApiError(
400,
'invalid_settings',
'invalid household settings: plan_draft_dow: invalid day of the week 9 (want 0-6, Sunday = 0); review_time: invalid time "9" (want 24-hour HH:MM, 00:00-23:59)',
false,
)
expect(householdSettingsProblems(error)).toEqual({
fields: {
plan_draft_dow: 'invalid day of the week 9 (want 0-6, Sunday = 0)',
review_time: 'invalid time "9" (want 24-hour HH:MM, 00:00-23:59)',
},
other: [],
})
})
it('keeps a problem naming no field, and any other error, whole', () => {
expect(
householdSettingsProblems(
new ApiError(400, 'invalid_settings', 'invalid household settings: settings: no settings to change', false),
),
).toEqual({ fields: {}, other: ['settings: no settings to change'] })
expect(
householdSettingsProblems(new ApiError(400, 'invalid_request', 'plan_draft_dow: want a JSON int', false)),
).toEqual({ fields: {}, other: ['plan_draft_dow: want a JSON int'] })
})
})
describe('time zones', () => {
it('formats the UTC offset', () => {
const winter = new Date('2026-01-15T12:00:00Z')
expect(utcOffset('America/New_York', winter)).toBe('UTC-05:00')
expect(utcOffset('Asia/Kolkata', winter)).toBe('UTC+05:30')
expect(utcOffset('UTC', winter)).toBe('UTC+00:00')
})
it('matches every search word against the name and offset', () => {
const option = { zone: 'America/Argentina/Buenos_Aires', offset: 'UTC-03:00' }
expect(zoneLabel(option.zone)).toBe('America / Argentina / Buenos Aires')
expect(matchesZone(option, 'buenos aires')).toBe(true)
expect(matchesZone(option, 'Buenos_Aires')).toBe(true)
expect(matchesZone(option, 'utc-03')).toBe(true)
expect(matchesZone(option, 'buenos paris')).toBe(false)
})
})
+412
View File
@@ -0,0 +1,412 @@
import { Check, ChevronLeft } from 'lucide-react'
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router'
import { Button } from '../ui/Button.tsx'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Select } from '../ui/Select.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { TextInput } from '../ui/TextInput.tsx'
import {
householdSettingsProblems,
useHouseholdSettings,
useSaveHouseholdSettings,
type HouseholdSettings,
type HouseholdSettingsField,
type HouseholdSettingsPatch,
} from './api.ts'
import styles from './HouseholdSettingsPage.module.css'
import { LeaveGuard } from './LeaveGuard.tsx'
import { matchesZone, timeZoneOptions, utcOffset, zoneLabel } from './timezones.ts'
// Days of the week as the server counts them, from Sunday = 0.
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const
// Settings · Household: the time zone and the times the household's
// reminders and weekly AI runs happen at. Every field is edited as a draft
// and saved together; only the changed fields are sent.
export function HouseholdSettingsPage() {
const [dirty, setDirty] = useState(false)
return (
<div className={styles.page}>
<Header />
<p className={styles.shared}>Changes apply to everyone in the household.</p>
<HouseholdSettingsSections onDirtyChange={setDirty} />
<LeaveGuard dirty={dirty} />
</div>
)
}
type HouseholdSettingsProps = {
// Called with whether the form holds unsaved changes.
onDirtyChange: (dirty: boolean) => void
}
// HouseholdSettingsSections is the screen's form once its settings load,
// without the screen's header, so the desktop settings (mock 2e) can embed
// it.
export function HouseholdSettingsSections({ onDirtyChange }: HouseholdSettingsProps) {
const settings = useHouseholdSettings()
return (
<>
{settings.isPending && <Skeleton className={styles.loading} />}
{settings.isError && (
<ErrorBanner onRetry={() => void settings.refetch()}>
Household settings could not load: {settings.error.message}
</ErrorBanner>
)}
{settings.isSuccess && <HouseholdSettingsForm saved={settings.data} onDirtyChange={onDirtyChange} />}
</>
)
}
function Header() {
const navigate = useNavigate()
return (
<header className={styles.header}>
<IconButton label="Back to Settings" onClick={() => void navigate('/settings')}>
<Icon icon={ChevronLeft} size="nav" />
</IconButton>
<div>
<p className={styles.kicker}>Settings</p>
<h1 className={styles.title}>Household</h1>
</div>
</header>
)
}
// patchOf is the PATCH body for the fields draft changes.
function patchOf(saved: HouseholdSettings, draft: HouseholdSettings): HouseholdSettingsPatch {
const patch: Record<string, string | number> = {}
for (const field of Object.keys(saved) as HouseholdSettingsField[]) {
if (draft[field] !== saved[field]) patch[field] = draft[field]
}
return patch as HouseholdSettingsPatch
}
type HouseholdSettingsFormProps = HouseholdSettingsProps & {
saved: HouseholdSettings
}
function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormProps) {
const [draft, setDraft] = useState(saved)
const save = useSaveHouseholdSettings()
const patch = patchOf(saved, draft)
const dirty = Object.keys(patch).length > 0
const problems = save.isError ? householdSettingsProblems(save.error) : { fields: {}, other: [] }
useEffect(() => {
onDirtyChange(dirty)
}, [dirty, onDirtyChange])
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
function edit(change: Partial<HouseholdSettings>) {
save.reset()
setDraft((current) => ({ ...current, ...change }))
}
function onSave() {
save.mutate(patch, { onSuccess: setDraft })
}
return (
<fieldset className={styles.lock} disabled={save.isPending}>
<Section title="Time zone">
<TimeZoneField
value={draft.timezone}
saved={saved.timezone}
onChange={(timezone) => edit({ timezone })}
error={problems.fields.timezone}
/>
<p className={styles.hint}>Every time on this screen is in this zone.</p>
</Section>
<Section title="Dinner">
<TimeField
draft={draft}
edit={edit}
problems={problems.fields}
name="dinner_time"
label="Dinner time"
hint="When dinner is on the table. Calendar events start then, and crock-pot dinners count back from it to show when to start."
/>
<TimeField
draft={draft}
edit={edit}
problems={problems.fields}
name="reminder_time"
label="Day-of reminder"
hint="When we remind you what's for dinner tonight."
/>
<TimeField
draft={draft}
edit={edit}
problems={problems.fields}
name="review_time"
label="Rotation check"
hint="When we ask whether to keep a new dinner: the day after you try a Quest-log dinner, we ask “Keep it in rotation?”"
/>
</Section>
<Section title="Every week">
<DayTimeField
draft={draft}
edit={edit}
problems={problems.fields}
day="plan_draft_dow"
time="plan_draft_time"
label="Plan draft"
hint="When the AI drafts next week's plan on the Draft board for you to adjust."
/>
<DayTimeField
draft={draft}
edit={edit}
problems={problems.fields}
day="suggestions_dow"
time="suggestions_time"
label="New suggestions"
hint="When a fresh batch of Side quests arrives to swipe through."
/>
<DayTimeField
draft={draft}
edit={edit}
problems={problems.fields}
day="chat_autosend_dow"
time="chat_autosend_time"
label="Google Chat Supply run"
hint="When the week's Supply run is sent to your Google Chat space."
/>
</Section>
<div className={styles.status} role="status">
{save.isSuccess && !dirty && 'Changes saved.'}
</div>
{save.isError && (
<div className={styles.errors} role="alert">
{Object.keys(problems.fields).length > 0 && (
<p className={styles.error}>Not saved: fix the settings marked below their fields.</p>
)}
{problems.other.map((reason) => (
<p key={reason} className={styles.error}>
Not saved: {reason}
</p>
))}
</div>
)}
<div className={styles.actions}>
<Button className={styles.save} disabled={!dirty || save.isPending} onClick={onSave}>
{save.isPending ? 'Saving…' : 'Save changes'}
</Button>
</div>
</fieldset>
)
}
function Section({ title, children }: { title: string; children: ReactNode }) {
const titleId = useId()
return (
<Card role="region" aria-labelledby={titleId} className={styles.card}>
<h2 id={titleId} className={styles.cardTitle}>
{title}
</h2>
{children}
</Card>
)
}
function FieldError({ id, error }: { id: string; error: string | undefined }) {
if (error === undefined) return null
return (
<p id={id} className={styles.error}>
{error}
</p>
)
}
type FieldProps = {
draft: HouseholdSettings
edit: (change: Partial<HouseholdSettings>) => void
problems: Partial<Record<HouseholdSettingsField, string>>
label: string
hint: string
}
type TimeFieldProps = FieldProps & { name: 'dinner_time' | 'reminder_time' | 'review_time' }
function TimeField({ draft, edit, problems, name, label, hint }: TimeFieldProps) {
const hintId = useId()
const errorId = useId()
const error = problems[name]
return (
<div className={styles.field}>
<TextInput
label={label}
type="time"
className={styles.time}
value={draft[name]}
aria-invalid={error !== undefined}
aria-describedby={error === undefined ? hintId : `${errorId} ${hintId}`}
onChange={(event) => edit({ [name]: event.target.value })}
/>
<FieldError id={errorId} error={error} />
<p id={hintId} className={styles.hint}>
{hint}
</p>
</div>
)
}
type DayTimeFieldProps = FieldProps & {
day: 'plan_draft_dow' | 'suggestions_dow' | 'chat_autosend_dow'
time: 'plan_draft_time' | 'suggestions_time' | 'chat_autosend_time'
}
function DayTimeField({ draft, edit, problems, day, time, label, hint }: DayTimeFieldProps) {
const hintId = useId()
const dayErrorId = useId()
const timeErrorId = useId()
const dayError = problems[day]
const timeError = problems[time]
return (
<div className={styles.field} role="group" aria-label={label}>
<div className={styles.columns}>
<Select
label={`${label} day`}
value={draft[day]}
aria-invalid={dayError !== undefined}
aria-describedby={dayError === undefined ? hintId : `${dayErrorId} ${hintId}`}
onChange={(event) => edit({ [day]: Number(event.target.value) })}
>
{days.map((name, index) => (
<option key={name} value={index}>
{name}
</option>
))}
</Select>
<TextInput
label={`${label} time`}
type="time"
className={`${styles.time} ${styles.pairedTime}`}
value={draft[time]}
aria-invalid={timeError !== undefined}
aria-describedby={timeError === undefined ? hintId : `${timeErrorId} ${hintId}`}
onChange={(event) => edit({ [time]: event.target.value })}
/>
</div>
<FieldError id={dayErrorId} error={dayError} />
<FieldError id={timeErrorId} error={timeError} />
<p id={hintId} className={styles.hint}>
{hint}
</p>
</div>
)
}
type TimeZoneFieldProps = {
value: string
saved: string
onChange: (zone: string) => void
error: string | undefined
}
// TimeZoneField shows the chosen zone and its current UTC offset; Change
// opens a searchable list of every IANA zone.
function TimeZoneField({ value, saved, onChange, error }: TimeZoneFieldProps) {
const [open, setOpen] = useState(false)
const labelId = useId()
const errorId = useId()
const offset = useMemo(() => utcOffset(value, new Date()), [value])
return (
<div className={styles.field}>
<span id={labelId} className={styles.groupLabel}>
Time zone
</span>
<div
className={styles.zoneRow}
role="group"
aria-labelledby={labelId}
aria-describedby={error === undefined ? undefined : errorId}
>
<span className={styles.zoneValue}>
<span className={styles.zoneName}>{zoneLabel(value)}</span>
<span className={styles.offset}>{offset}</span>
</span>
<button type="button" className={styles.change} onClick={() => setOpen(true)}>
Change
</button>
</div>
<FieldError id={errorId} error={error} />
{open && (
<TimeZoneSheet
value={value}
saved={saved}
onClose={() => setOpen(false)}
onChoose={(zone) => {
onChange(zone)
setOpen(false)
}}
/>
)}
</div>
)
}
type TimeZoneSheetProps = {
value: string
// The saved zone, offered even after another is chosen.
saved: string
onClose: () => void
onChoose: (zone: string) => void
}
function TimeZoneSheet({ value, saved, onClose, onChoose }: TimeZoneSheetProps) {
const [search, setSearch] = useState('')
const titleId = useId()
const options = useMemo(() => timeZoneOptions([saved, value], new Date()), [saved, value])
const shown = options.filter((option) => matchesZone(option, search))
return (
<Sheet open onClose={onClose} aria-labelledby={titleId}>
<div className={styles.sheet}>
<h2 id={titleId} className={styles.cardTitle}>
Choose a time zone
</h2>
<TextInput
label="Search time zones"
type="search"
value={search}
placeholder="City, region or UTC offset"
autoComplete="off"
spellCheck={false}
onChange={(event) => setSearch(event.target.value)}
/>
<p className={styles.hint} role="status">
{shown.length === 0 ? 'No time zone matches.' : `${shown.length} ${shown.length === 1 ? 'time zone' : 'time zones'}`}
</p>
<ul className={styles.zoneList} aria-label="Time zones">
{shown.map((option) => {
const current = option.zone === value
return (
<li key={option.zone}>
<button
type="button"
className={styles.zoneOption}
aria-pressed={current}
aria-label={`${zoneLabel(option.zone)}, ${option.offset}`}
onClick={() => onChoose(option.zone)}
>
<span className={styles.zoneName}>{zoneLabel(option.zone)}</span>
<span className={styles.offset}>{option.offset}</span>
{current && <Icon icon={Check} className={styles.check} />}
</button>
</li>
)
})}
</ul>
</div>
</Sheet>
)
}
+38
View File
@@ -0,0 +1,38 @@
/* The confirm sheet the settings screens share. */
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 560px;
margin: 0 auto;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.text {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-neutral-700);
}
.error {
margin: 0;
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
line-height: 1.4;
color: var(--color-accent-700);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useId, type ReactNode } from 'react'
import { useBlocker } from 'react-router'
import { Button } from '../ui/Button.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import styles from './LeaveGuard.module.css'
// LeaveGuard asks before leaving the screen with unsaved changes.
export function LeaveGuard({ dirty }: { dirty: boolean }) {
const blocker = useBlocker(
({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname,
)
useEffect(() => {
if (!dirty) return
const onBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault()
window.addEventListener('beforeunload', onBeforeUnload)
return () => window.removeEventListener('beforeunload', onBeforeUnload)
}, [dirty])
return (
<Confirm
open={blocker.state === 'blocked'}
title="Leave without saving?"
confirmLabel="Leave"
cancelLabel="Keep editing"
onCancel={() => blocker.reset?.()}
onConfirm={() => blocker.proceed?.()}
>
Your changes on this screen have not been saved.
</Confirm>
)
}
type ConfirmProps = {
open: boolean
title: string
children: ReactNode
confirmLabel: string
cancelLabel?: string
busy?: boolean
error?: string | null
onCancel: () => void
onConfirm: () => void
}
// Confirm is a sheet asking the household to confirm one action.
export function Confirm({
open,
title,
children,
confirmLabel,
cancelLabel = 'Cancel',
busy = false,
error = null,
onCancel,
onConfirm,
}: ConfirmProps) {
const titleId = useId()
return (
<Sheet open={open} onClose={onCancel} aria-labelledby={titleId}>
<div className={styles.sheet}>
<h2 id={titleId} className={styles.title}>
{title}
</h2>
<p className={styles.text}>{children}</p>
{error !== null && (
<p className={styles.error} role="alert">
{error}
</p>
)}
<div className={styles.actions}>
<Button variant="outline" onClick={onCancel}>
{cancelLabel}
</Button>
<Button disabled={busy} onClick={onConfirm}>
{confirmLabel}
</Button>
</div>
</div>
</Sheet>
)
}
+275
View File
@@ -0,0 +1,275 @@
import { useEffect, useId, useState } from 'react'
import { ApiError } from '../api/index.ts'
import { Button } from '../ui/Button.tsx'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { TextInput } from '../ui/TextInput.tsx'
import {
maskedKey,
recipeSearchProviderLabels,
useRecipeSearchSettings,
useSaveRecipeSearchSettings,
useTestRecipeSearch,
type RecipeSearchProvider,
type RecipeSearchSettings,
type RecipeSearchSettingsPatch,
} from './api.ts'
import styles from './AISettingsPage.module.css'
const providerOptions = Object.entries(recipeSearchProviderLabels).map(([value, label]) => ({
value: value as RecipeSearchProvider,
label,
}))
type RecipeSearchCardProps = {
// Called with whether the card holds unsaved changes.
onDirtyChange: (dirty: boolean) => void
}
// RecipeSearchCard is where recipes are searched for: a SearXNG instance or
// Brave. The provider, SearXNG URL and a new Brave key are edited as a draft
// and saved together; the client only ever sees the stored key's last four
// characters. Test searches with the saved settings.
export function RecipeSearchCard({ onDirtyChange }: RecipeSearchCardProps) {
const settings = useRecipeSearchSettings()
const titleId = useId()
return (
<Card className={styles.card} aria-labelledby={titleId} role="region">
<h2 id={titleId} className={styles.cardTitle}>
Recipe search
</h2>
<p className={styles.hint}>Where MealQuest looks for real recipes behind each Side quest.</p>
{settings.isPending && <Skeleton className={styles.cardLoading} />}
{settings.isError && (
<ErrorBanner onRetry={() => void settings.refetch()}>
Recipe search settings could not load: {settings.error.message}
</ErrorBanner>
)}
{settings.isSuccess && <RecipeSearchForm saved={settings.data} onDirtyChange={onDirtyChange} />}
</Card>
)
}
type Draft = {
provider: RecipeSearchProvider
base_url: string
// A new Brave key to store, null when the stored one stays.
api_key: string | null
}
// draftOf starts a household that has not set up recipe search on SearXNG,
// the sidecar every deployment runs. With no URL saved the field holds the
// deployment's SearXNG URL, which saving stores.
function draftOf(saved: RecipeSearchSettings): Draft {
return {
provider: saved.provider ?? 'searxng',
base_url: saved.base_url ?? saved.default_base_url ?? '',
api_key: null,
}
}
// patchOf is the PATCH body for what draft changes. Only the chosen
// provider's field is sent: the other provider's is left as it is.
function patchOf(saved: RecipeSearchSettings, draft: Draft): RecipeSearchSettingsPatch {
const patch: RecipeSearchSettingsPatch = {}
if (draft.provider !== saved.provider) patch.provider = draft.provider
if (draft.provider === 'searxng' && draft.base_url !== (saved.base_url ?? '')) patch.base_url = draft.base_url
if (draft.provider === 'brave' && draft.api_key !== null) patch.api_key = draft.api_key
return patch
}
function sameDraft(a: Draft, b: Draft): boolean {
return a.provider === b.provider && a.base_url === b.base_url && a.api_key === b.api_key
}
function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSettings } & RecipeSearchCardProps) {
const [draft, setDraft] = useState(() => draftOf(saved))
const save = useSaveRecipeSearchSettings()
const test = useTestRecipeSearch()
const patch = patchOf(saved, draft)
const dirty = Object.keys(patch).length > 0
// Only the household's own edits are unsaved changes. Before recipe search
// is set up the untouched draft already differs from what is saved — it
// suggests SearXNG and the deployment's URL — but leaving it loses nothing.
const edited = dirty && !sameDraft(draft, draftOf(saved))
useEffect(() => {
onDirtyChange(edited)
}, [edited, onDirtyChange])
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
// An edit clears the last save and test results: they describe the saved
// settings, not the draft.
function edit(change: Partial<Draft>) {
save.reset()
test.reset()
setDraft((current) => ({ ...current, ...change }))
}
function onSave() {
save.mutate(patch, {
onSuccess: (settings) => {
setDraft(draftOf(settings))
test.reset()
},
})
}
return (
<fieldset className={styles.lock} disabled={save.isPending}>
<Segmented
label="Recipe search provider"
options={providerOptions}
value={draft.provider}
onValueChange={(provider) => edit({ provider })}
/>
{draft.provider === 'searxng' ? (
<TextInput
label="SearXNG URL"
type="url"
className={styles.mono}
value={draft.base_url}
placeholder="https://"
autoComplete="off"
spellCheck={false}
onChange={(event) => edit({ base_url: event.target.value })}
/>
) : (
<BraveKey
last4={saved.api_key_last4}
newKey={draft.api_key}
onKey={(api_key) => edit({ api_key })}
/>
)}
<div className={styles.status} role="status">
{save.isSuccess && !dirty && 'Recipe search saved.'}
{test.isSuccess && `Found ${test.data.count} ${test.data.count === 1 ? 'recipe' : 'recipes'} for '${test.data.query}'`}
</div>
{save.isError && (
<p className={styles.error} role="alert">
{saveErrorMessage(save.error)}
</p>
)}
{test.isError && (
<p className={styles.error} role="alert">
{testErrorMessage(test.error)}
</p>
)}
{dirty && saved.provider !== null && <p className={styles.note}>Save your changes to test them.</p>}
<div className={styles.cardActions}>
<Button
variant="outline"
disabled={dirty || saved.provider === null || test.isPending}
onClick={() => test.mutate()}
>
{test.isPending ? 'Testing…' : 'Test'}
</Button>
<Button disabled={!dirty || save.isPending} onClick={onSave}>
{save.isPending ? 'Saving…' : 'Save recipe search'}
</Button>
</div>
</fieldset>
)
}
type BraveKeyProps = {
last4: string | null
newKey: string | null
onKey: (key: string | null) => void
}
// BraveKey shows the stored key masked, or the new one waiting to be saved,
// and takes a new key in a sheet. The key is sent only when the card is
// saved.
function BraveKey({ last4, newKey, onKey }: BraveKeyProps) {
const [replacing, setReplacing] = useState(false)
const [key, setKey] = useState('')
const labelId = useId()
const sheetTitleId = useId()
function close() {
setReplacing(false)
setKey('')
}
let shown = 'No key stored'
if (newKey !== null) shown = maskedKey(newKey.slice(-4))
else if (last4 !== null) shown = maskedKey(last4)
return (
<div className={styles.key}>
<span id={labelId} className={styles.label}>
Brave API key
</span>
<div className={styles.keyRow} role="group" aria-labelledby={labelId}>
<span className={styles.keyValue}>{shown}</span>
<button type="button" className={styles.replace} onClick={() => setReplacing(true)}>
{last4 === null && newKey === null ? 'Add key' : 'Replace'}
</button>
</div>
{newKey !== null && (
<div className={styles.keyStatus}>
<p className={styles.note}>New key not saved yet</p>
<button type="button" className={styles.link} onClick={() => onKey(null)}>
Discard
</button>
</div>
)}
<Sheet open={replacing} onClose={close} aria-labelledby={sheetTitleId}>
<form
className={styles.sheet}
onSubmit={(event) => {
event.preventDefault()
onKey(key)
close()
}}
>
<h2 id={sheetTitleId} className={styles.sheetTitle}>
{last4 === null ? 'Add Brave API key' : 'Replace Brave API key'}
</h2>
<p className={styles.sheetText}>
The key is stored on the server when you save recipe search. This device only ever sees its last four
characters.
</p>
<TextInput
label="Brave API key"
type="password"
className={styles.mono}
value={key}
autoComplete="off"
spellCheck={false}
onChange={(event) => setKey(event.target.value)}
/>
<div className={styles.sheetActions}>
<Button variant="outline" onClick={close}>
Cancel
</Button>
<Button type="submit" disabled={key === ''}>
Use this key
</Button>
</div>
</form>
</Sheet>
</div>
)
}
function saveErrorMessage(error: Error): string {
if (error instanceof ApiError && error.code === 'invalid_settings') return `Not saved: ${error.message}.`
return `Not saved: ${error.message}`
}
// testErrorMessage says why POST /settings/recipe-search/test failed, by
// the error code the server names (internal/server/recipesearch.go).
function testErrorMessage(error: Error): string {
if (error instanceof ApiError && error.code === 'not_configured') {
return 'Test failed: recipe search is not set up yet.'
}
return `Test failed: ${error.message}`
}
@@ -0,0 +1,295 @@
/* Settings · Regular dinners. */
.page {
box-sizing: border-box;
min-height: 100%;
max-width: 560px;
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 6px;
}
.header {
display: flex;
align-items: center;
gap: 16px;
}
.kicker {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-accent-700);
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 28px;
font-weight: 400;
}
.shared {
margin: 0;
padding: 14px 16px;
border-radius: 26px;
background: var(--color-accent-2-100);
color: var(--color-accent-2-900);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
}
/* The add field: the input fills the row beside the grape + button. */
.add {
display: flex;
align-items: center;
gap: 8px;
}
.add > :first-child {
flex: 1;
min-width: 0;
}
.add .addInput {
width: 100%;
}
.loading {
height: 320px;
border-radius: 26px;
}
.section {
display: flex;
flex-direction: column;
gap: 10px;
}
.groupLabel {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
.card {
padding: 6px;
border: 1px solid var(--color-neutral-300);
}
.rows {
margin: 0;
padding: 0;
list-style: none;
}
.item + .item {
border-top: 1px solid var(--color-divider);
}
.pending {
opacity: 0.6;
}
.photo {
width: 48px;
height: 48px;
}
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
margin-top: 4px;
}
/* Beat the Tag's own tones whatever order the sheets load in. */
.meta .finding {
background: var(--color-neutral-200);
color: var(--color-neutral-700);
}
.meta .found {
background: var(--color-accent-2-200);
color: var(--color-accent-2-800);
}
.meta .needs {
background: var(--color-accent-100);
color: var(--color-accent-800);
}
.filled {
color: var(--color-accent);
fill: currentColor;
}
.hint,
.problem,
.status {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.4;
}
.hint {
color: var(--color-neutral-700);
}
.problem {
font-weight: 700;
color: var(--color-accent-700);
}
.status {
color: var(--color-accent-2-800);
}
.status:empty {
display: none;
}
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 560px;
margin: 0 auto;
}
.sheetTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.form {
display: flex;
flex-direction: column;
gap: 14px;
}
.columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
align-items: end;
}
/* Beside a dropdown, a minutes field matches its height. */
.columns .paired {
width: 100%;
height: 56px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
.textarea {
box-sizing: border-box;
width: 100%;
min-height: 120px;
padding: 14px 18px;
border: 1px solid var(--color-neutral-300);
border-radius: 22px;
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-body);
font-size: 15px;
line-height: 1.5;
resize: vertical;
}
.actions {
display: grid;
gap: 10px;
}
.actions > button {
min-height: 52px;
}
.candidatesLoading {
height: 200px;
border-radius: 22px;
}
.candidates {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.candidate {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-width: 44px;
min-height: 72px;
padding: 8px 16px 8px 8px;
border: 1px solid var(--color-neutral-300);
border-radius: 22px;
background: var(--color-surface);
color: var(--color-text);
text-align: start;
cursor: pointer;
}
.candidate:hover {
background: var(--color-neutral-200);
}
.candidatePhoto {
flex: none;
width: 56px;
height: 56px;
}
.candidateBody {
display: flex;
flex-direction: column;
min-width: 0;
}
.candidateTitle {
font-family: var(--font-body);
font-size: 15px;
font-weight: 700;
}
.candidateSite {
font-family: var(--font-body);
font-size: 12px;
font-weight: 600;
color: var(--color-neutral-700);
}
/* A form locked while its save is pending. It lays out as if it were not
there. */
.lock {
display: contents;
}
@@ -0,0 +1,579 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { Route, Routes } from 'react-router'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { GROUNDING_POLL_MS } from '../dinners/api.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { RegularsSettingsPage } from './RegularsSettingsPage.tsx'
const REGULARS = '/api/v1/dinners?status=regular'
type Responder = (body: unknown) => Response | Promise<Response>
// stubServer stands in for the dinners API: GET of the Regulars answers
// the current server.regulars, so a test changes what the next read sees;
// every other request is answered by responders, keyed "METHOD path" and
// called with the request's decoded JSON body. Any other request fails the
// test.
function stubServer(regulars: DinnerBody[], responders: Record<string, Responder> = {}) {
const server = { regulars, fetchMock: undefined as unknown as Mock<typeof fetch> }
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
[`GET ${REGULARS}`]: () => jsonResponse(200, { dinners: server.regulars }),
...responders,
}
server.fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined
return Promise.resolve(respond(body))
})
vi.stubGlobal('fetch', server.fetchMock)
return server
}
// sent lists the requests other than GETs, as "METHOD path" and body.
function sent(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
.map(([input, init]) => ({
request: `${init?.method} ${String(input)}`,
body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined,
}))
}
function reads(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === REGULARS)
.length
}
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
// renderPage loads /me (for the CSRF token every write needs) and renders
// the page at /settings/regulars once its Regulars have loaded.
async function renderPage() {
const api = createApiClient(fakeLocation('/settings/regulars'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings/regulars">
<Routes>
<Route path="/settings/regulars" element={<RegularsSettingsPage />} />
</Routes>
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
await screen.findByRole('heading', { level: 2, name: /^On rotation/ })
}
const TACOS = dinnerBody('d1', 'Beef tacos', {
method: 'stovetop',
minutes_total: 40,
difficulty: 'easy',
is_favorite: true,
photo_url: '/media/photos/p1-400.jpg',
})
const CHILI = dinnerBody('d2', 'Chili', { method: 'crockpot', minutes_total: 480, grounding_status: 'pending' })
const PIZZA = dinnerBody('d3', 'Pizza night', { grounding_status: 'failed', grounding_error: 'no_recipe_found' })
const ROAST = dinnerBody('d4', 'Pot roast', { method: 'oven', grounding_status: 'manual' })
function rows() {
return within(screen.getByRole('list', { name: 'Regulars' })).queryAllByRole('listitem')
}
function row(name: string) {
const item = rows().find((li) => within(li).queryByText(name) !== null)
if (item === undefined) throw new Error(`no Regular row named ${name}`)
return item
}
function addField() {
return screen.getByRole('textbox', { name: 'Add a Regular' })
}
function add(text: string) {
fireEvent.change(addField(), { target: { value: text } })
fireEvent.click(screen.getByRole('button', { name: 'Add Regular' }))
}
function openRow(name: string) {
fireEvent.click(within(row(name)).getByRole('button', { name: new RegExp(`^${name}`) }))
}
describe('RegularsSettingsPage', () => {
it('lists each Regular with its photo, method and minutes, favorite heart and grounding badge', async () => {
stubServer([TACOS, CHILI, PIZZA, ROAST])
await renderPage()
expect(screen.getByRole('heading', { level: 1, name: 'Regular dinners' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 4' })).toBeInTheDocument()
expect(rows()).toHaveLength(4)
const tacos = within(row('Beef tacos'))
expect(tacos.getByRole('img', { name: 'Beef tacos' })).toHaveAttribute('src', '/media/photos/p1-400.jpg')
expect(tacos.getByText('Stovetop · 40 min')).toBeInTheDocument()
expect(tacos.getByText('Recipe found')).toBeInTheDocument()
expect(tacos.getByRole('button', { name: 'Favorite Beef tacos' })).toHaveAttribute('aria-pressed', 'true')
const chili = within(row('Chili'))
expect(chili.getByText('Crock pot · 480 min')).toBeInTheDocument()
expect(chili.getByText('Finding recipe…')).toBeInTheDocument()
expect(chili.getByRole('button', { name: 'Favorite Chili' })).toHaveAttribute('aria-pressed', 'false')
const pizza = within(row('Pizza night'))
// No photo yet: the striped placeholder stands in.
expect(pizza.getByRole('img', { name: 'Pizza night' })).toHaveTextContent('dish photo')
expect(pizza.getByText('Needs a recipe')).toBeInTheDocument()
expect(within(row('Pot roast')).getByText('Oven')).toBeInTheDocument()
expect(within(row('Pot roast')).getByText('Recipe found')).toBeInTheDocument()
})
it('keeps every control at least 44 px', async () => {
stubServer([TACOS])
await renderPage()
expectMinHitArea(screen.getByRole('button', { name: 'Add Regular' }))
expectMinHitArea(screen.getByRole('button', { name: 'Favorite Beef tacos' }))
expectMinHitArea(within(row('Beef tacos')).getByRole('button', { name: /^Beef tacos/ }))
expectMinHitArea(screen.getByRole('button', { name: 'Back to Settings' }))
})
it('says so when there are no Regulars yet', async () => {
stubServer([])
await renderPage()
expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 0' })).toBeInTheDocument()
expect(screen.getByText(/No Regulars yet/)).toBeInTheDocument()
})
it('reports Regulars that could not load, and retries', async () => {
const server = stubServer([], { [`GET ${REGULARS}`]: () => errorResponse(500, 'internal') })
const api = createApiClient(fakeLocation('/settings/regulars'), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings/regulars">
<RegularsSettingsPage />
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
expect(await screen.findByRole('alert')).toHaveTextContent('Your Regulars could not load: internal message')
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
})
it('adds a Regular by name, showing it at once and finding its recipe', async () => {
const server = stubServer([TACOS], {
'POST /api/v1/dinners': (body) => {
const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' })
server.regulars = [...server.regulars, created]
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
},
})
await renderPage()
add(' Chicken enchiladas ')
expect(addField()).toHaveValue('')
expect(await screen.findByText('Chicken enchiladas')).toBeInTheDocument()
expect(within(row('Chicken enchiladas')).getByText('Finding recipe…')).toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual([{ request: 'POST /api/v1/dinners', body: { name: 'Chicken enchiladas' } }])
await waitFor(() => expect(row('Chicken enchiladas')).not.toHaveAttribute('aria-busy'))
expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
})
it('adds a Regular from a pasted recipe link', async () => {
const link = 'https://example.com/recipes/12345/beef-stew/'
const server = stubServer([], {
'POST /api/v1/dinners': () => {
const created = dinnerBody('d9', 'Beef stew', { grounding_status: 'pending', recipe_url: link })
server.regulars = [created]
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
},
})
await renderPage()
add(link)
expect(await screen.findByText('Beef stew')).toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual([{ request: 'POST /api/v1/dinners', body: { recipe_url: link } }])
expect(within(row('Beef stew')).getByText('Finding recipe…')).toBeInTheDocument()
})
it('takes back an add the server refuses and says why', async () => {
stubServer([TACOS], { 'POST /api/v1/dinners': () => errorResponse(409, 'duplicate_dinner') })
await renderPage()
add('Beef tacos!')
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Beef tacos!: duplicate_dinner message')
expect(rows()).toHaveLength(1)
})
it('toggles the favorite heart', async () => {
const server = stubServer([TACOS], {
'POST /api/v1/dinners/d1/favorite': () => {
server.regulars = [{ ...TACOS, is_favorite: false }]
return jsonResponse(200, server.regulars[0])
},
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Favorite Beef tacos' }))
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Favorite Beef tacos' })).toHaveAttribute('aria-pressed', 'false'),
)
expect(sent(server.fetchMock)).toEqual([{ request: 'POST /api/v1/dinners/d1/favorite', body: undefined }])
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
expect(screen.getByRole('button', { name: 'Favorite Beef tacos' })).toHaveAttribute('aria-pressed', 'false')
})
it('edits a Regular: name, method, minutes and difficulty', async () => {
const server = stubServer([TACOS], {
'PATCH /api/v1/dinners/d1': (body) => {
const edited = { ...TACOS, ...(body as object) }
server.regulars = [edited]
return jsonResponse(200, edited)
},
})
await renderPage()
openRow('Beef tacos')
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
expect(sheet.getByLabelText('Name')).toHaveValue('Beef tacos')
expect(sheet.getByLabelText('Method')).toHaveValue('stovetop')
expect(sheet.getByLabelText('Minutes')).toHaveValue(40)
expect(sheet.getByLabelText('Difficulty')).toHaveValue('easy')
expect(sheet.getByRole('button', { name: 'Save changes' })).toBeDisabled()
fireEvent.change(sheet.getByLabelText('Name'), { target: { value: 'Fish tacos' } })
fireEvent.change(sheet.getByLabelText('Method'), { target: { value: 'grill' } })
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '25' } })
fireEvent.change(sheet.getByLabelText('Difficulty'), { target: { value: 'medium' } })
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(sent(server.fetchMock)).toEqual([
{
request: 'PATCH /api/v1/dinners/d1',
body: { name: 'Fish tacos', method: 'grill', minutes_total: 25, difficulty: 'medium' },
},
])
expect(within(row('Fish tacos')).getByText('Grill · 25 min')).toBeInTheDocument()
})
it('sends only the changed fields, refuses bad minutes and shows a refused edit', async () => {
const server = stubServer([TACOS], { 'PATCH /api/v1/dinners/d1': () => errorResponse(400, 'invalid_dinner') })
await renderPage()
openRow('Beef tacos')
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '0' } })
expect(sheet.getByText('Minutes must be a whole number from 1 to 1440.')).toBeInTheDocument()
expect(sheet.getByRole('button', { name: 'Save changes' })).toBeDisabled()
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '45' } })
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
expect(await sheet.findByRole('alert')).toHaveTextContent('Not saved: invalid_dinner message')
expect(sent(server.fetchMock)).toEqual([{ request: 'PATCH /api/v1/dinners/d1', body: { minutes_total: 45 } }])
})
it('removes a Regular only once confirmed', async () => {
const server = stubServer([TACOS, ROAST], {
'DELETE /api/v1/dinners/d1': () => {
server.regulars = [ROAST]
return jsonResponse(200, { ...TACOS, status: 'archived' })
},
})
await renderPage()
openRow('Beef tacos')
fireEvent.click(screen.getByRole('button', { name: 'Remove from Regulars' }))
const confirm = within(screen.getByRole('dialog', { name: 'Remove Beef tacos?' }))
fireEvent.click(confirm.getByRole('button', { name: 'Keep it' }))
expect(screen.getByRole('dialog', { name: 'Edit Beef tacos' })).toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual([])
fireEvent.click(screen.getByRole('button', { name: 'Remove from Regulars' }))
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await waitFor(() => expect(rows()).toHaveLength(1))
expect(within(rows()[0]!).getByText('Pot roast')).toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual([{ request: 'DELETE /api/v1/dinners/d1', body: undefined }])
})
it('puts a refused removal back and says why', async () => {
stubServer([TACOS], { 'DELETE /api/v1/dinners/d1': () => errorResponse(500, 'internal') })
await renderPage()
openRow('Beef tacos')
fireEvent.click(screen.getByRole('button', { name: 'Remove from Regulars' }))
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
expect(rows()).toHaveLength(1)
})
describe('Fix recipe', () => {
const CANDIDATES = {
candidates: [
{
candidate_id: 'https://example.com/pizza',
title: 'Weeknight pizza',
site_name: 'Example Kitchen',
photo_url: '/media/photos/p7-400.jpg',
minutes: 45,
},
{ candidate_id: 'https://cook.test/pizza', title: 'Sheet-pan pizza', site_name: 'Cook Test', photo_url: null, minutes: null },
{ candidate_id: 'https://food.test/p', title: 'Pizza margherita', site_name: 'Food Test', photo_url: null, minutes: 30 },
],
}
function grounded(server: { regulars: DinnerBody[] }, overrides: Partial<DinnerBody>) {
const fixed = { ...PIZZA, grounding_error: null, ...overrides }
server.regulars = [fixed]
return jsonResponse(200, fixed)
}
function fixSheet() {
return within(screen.getByRole('dialog', { name: 'Fix recipe' }))
}
it('opens from a Needs a recipe row and picks one of up to three matches', async () => {
const server = stubServer([PIZZA], {
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
'POST /api/v1/dinners/d3/ground': () =>
grounded(server, { grounding_status: 'grounded', photo_url: '/media/photos/p7-400.jpg', method: 'oven', minutes_total: 45 }),
})
await renderPage()
openRow('Pizza night')
const matches = within(await fixSheet().findByRole('list', { name: 'Recipe matches' }))
const options = matches.getAllByRole('button')
expect(options.map((option) => option.getAttribute('aria-label'))).toEqual([
'Use Weeknight pizza from Example Kitchen',
'Use Sheet-pan pizza from Cook Test',
'Use Pizza margherita from Food Test',
])
expect(within(options[0]!).getByRole('img', { name: 'Weeknight pizza' })).toHaveAttribute('src', '/media/photos/p7-400.jpg')
expect(within(options[0]!).getByText('Example Kitchen')).toBeInTheDocument()
expectMinHitArea(options[0]!)
fireEvent.click(options[0]!)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(sent(server.fetchMock)).toEqual([
{ request: 'POST /api/v1/dinners/d3/ground', body: { candidate_id: 'https://example.com/pizza' } },
])
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
expect(within(row('Pizza night')).getByText('Oven · 45 min')).toBeInTheDocument()
})
it('says so when no match was found', async () => {
stubServer([PIZZA], { 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, { candidates: [] }) })
await renderPage()
openRow('Pizza night')
expect(await fixSheet().findByText('No matches were found. Paste a recipe link or type it in.')).toBeInTheDocument()
})
it('grounds a pasted recipe link, and says why a link was refused', async () => {
let answers = 0
const server = stubServer([PIZZA], {
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
'POST /api/v1/dinners/d3/ground': () =>
++answers === 1 ? errorResponse(422, 'ungroundable') : grounded(server, { grounding_status: 'grounded' }),
})
await renderPage()
openRow('Pizza night')
fireEvent.click(fixSheet().getByRole('radio', { name: 'Paste a link' }))
fireEvent.change(fixSheet().getByLabelText('Recipe link'), { target: { value: ' https://example.com/blog/pizza ' } })
fireEvent.click(fixSheet().getByRole('button', { name: 'Use this recipe' }))
expect(await fixSheet().findByRole('alert')).toHaveTextContent('Not fixed: ungroundable message')
fireEvent.change(fixSheet().getByLabelText('Recipe link'), { target: { value: 'https://example.com/pizza-2' } })
fireEvent.click(fixSheet().getByRole('button', { name: 'Use this recipe' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(sent(server.fetchMock)).toEqual([
{ request: 'POST /api/v1/dinners/d3/ground', body: { recipe_url: 'https://example.com/blog/pizza' } },
{ request: 'POST /api/v1/dinners/d3/ground', body: { recipe_url: 'https://example.com/pizza-2' } },
])
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
})
it('saves ingredients and steps typed in', async () => {
const server = stubServer([PIZZA], {
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
'POST /api/v1/dinners/d3/ground': () =>
grounded(server, { grounding_status: 'manual', method: 'oven', minutes_total: 35 }),
})
await renderPage()
openRow('Pizza night')
fireEvent.click(fixSheet().getByRole('radio', { name: 'Type it in' }))
const save = fixSheet().getByRole('button', { name: 'Save recipe' })
expect(save).toBeDisabled()
fireEvent.change(fixSheet().getByLabelText('Ingredients, one per line'), {
target: { value: '1 pizza dough\n\n 1 cup tomato sauce \n2 cups mozzarella' },
})
fireEvent.change(fixSheet().getByLabelText('Steps, one per line'), {
target: { value: 'Heat the oven to 250°C.\nTop the dough and bake 12 minutes.\n' },
})
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: 'oven' } })
fireEvent.change(fixSheet().getByLabelText('Minutes'), { target: { value: '35' } })
fireEvent.click(save)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(sent(server.fetchMock)).toEqual([
{
request: 'POST /api/v1/dinners/d3/ground',
body: {
manual: {
ingredients: ['1 pizza dough', '1 cup tomato sauce', '2 cups mozzarella'],
steps: ['Heat the oven to 250°C.', 'Top the dough and bake 12 minutes.'],
minutes: 35,
method: 'oven',
},
},
},
])
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
expect(within(row('Pizza night')).getByText('Oven · 35 min')).toBeInTheDocument()
})
it('saves a typed-in recipe only once its method and minutes are set', async () => {
const server = stubServer([PIZZA], {
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
})
await renderPage()
openRow('Pizza night')
fireEvent.click(fixSheet().getByRole('radio', { name: 'Type it in' }))
const save = fixSheet().getByRole('button', { name: 'Save recipe' })
fireEvent.change(fixSheet().getByLabelText('Ingredients, one per line'), { target: { value: '1 pizza dough' } })
expect(fixSheet().getByText('Choose a method and minutes to save.')).toBeInTheDocument()
expect(save).toBeDisabled()
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: 'oven' } })
expect(save).toBeDisabled()
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: '' } })
fireEvent.change(fixSheet().getByLabelText('Minutes'), { target: { value: '35' } })
expect(save).toBeDisabled()
// Even a submit that skips the button sends nothing.
fireEvent.submit(save.closest('form')!)
expect(sent(server.fetchMock)).toEqual([])
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: 'oven' } })
expect(fixSheet().queryByText('Choose a method and minutes to save.')).not.toBeInTheDocument()
expect(save).toBeEnabled()
})
it('switches to editing the Regular', async () => {
stubServer([PIZZA], { 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES) })
await renderPage()
openRow('Pizza night')
fireEvent.click(fixSheet().getByRole('button', { name: 'Edit or remove Pizza night' }))
expect(screen.getByRole('dialog', { name: 'Edit Pizza night' })).toBeInTheDocument()
})
})
it('refreshes grounding status while any Regular is finding its recipe, then stops', async () => {
// Only intervals are faked: TanStack Query polls with setInterval, and
// Testing Library's waits keep their real timers.
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
const server = stubServer([TACOS, CHILI])
await renderPage()
expect(within(row('Chili')).getByText('Finding recipe…')).toBeInTheDocument()
expect(reads(server.fetchMock)).toBe(1)
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
expect(within(row('Chili')).getByText('Finding recipe…')).toBeInTheDocument()
server.regulars = [TACOS, { ...CHILI, grounding_status: 'grounded', photo_url: '/media/photos/p2-400.jpg' }]
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
expect(await within(row('Chili')).findByText('Recipe found')).toBeInTheDocument()
expect(within(row('Chili')).getByRole('img', { name: 'Chili' })).toHaveAttribute('src', '/media/photos/p2-400.jpg')
expect(reads(server.fetchMock)).toBe(3)
// Nothing is pending any more, so nothing is read again.
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
expect(reads(server.fetchMock)).toBe(3)
})
// held answers a request only when release is called, so a test can look
// at the page while a save is in flight.
function held(answer: () => Response) {
let release = () => {}
const response = new Promise<Response>((resolve) => {
release = () => resolve(answer())
})
return { respond: () => response, release: () => release() }
}
it('pauses the refresh while an edit is being saved, then resumes', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
const edited = { ...TACOS, name: 'Fish tacos' }
const patch = held(() => jsonResponse(200, edited))
const server = stubServer([TACOS, CHILI], { 'PATCH /api/v1/dinners/d1': patch.respond })
await renderPage()
openRow('Beef tacos')
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
fireEvent.change(sheet.getByLabelText('Name'), { target: { value: 'Fish tacos' } })
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
await waitFor(() => expect(sent(server.fetchMock)).toHaveLength(1))
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
expect(reads(server.fetchMock)).toBe(1)
server.regulars = [edited, CHILI]
patch.release()
// Settling reads the list once, then the refresh carries on.
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
await waitFor(() => expect(reads(server.fetchMock)).toBe(3))
})
it('pauses the refresh while a recipe fix is being saved, then resumes', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
const fixed = { ...PIZZA, grounding_status: 'grounded' as const, grounding_error: null }
const ground = held(() => jsonResponse(200, fixed))
const server = stubServer([CHILI, PIZZA], {
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, { candidates: [] }),
'POST /api/v1/dinners/d3/ground': ground.respond,
})
await renderPage()
openRow('Pizza night')
const sheet = within(screen.getByRole('dialog', { name: 'Fix recipe' }))
fireEvent.click(sheet.getByRole('radio', { name: 'Paste a link' }))
fireEvent.change(sheet.getByLabelText('Recipe link'), { target: { value: 'https://example.com/pizza' } })
fireEvent.click(sheet.getByRole('button', { name: 'Use this recipe' }))
await waitFor(() => expect(sent(server.fetchMock)).toHaveLength(1))
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
expect(reads(server.fetchMock)).toBe(1)
server.regulars = [CHILI, fixed]
ground.release()
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
await waitFor(() => expect(reads(server.fetchMock)).toBe(3))
})
it('does not poll when no Regular is finding its recipe', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
const server = stubServer([TACOS, PIZZA])
await renderPage()
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
expect(reads(server.fetchMock)).toBe(1)
})
})
+616
View File
@@ -0,0 +1,616 @@
import { ChevronLeft, Heart, Plus } from 'lucide-react'
import { useId, useState, type FormEvent, type ReactNode } from 'react'
import { useNavigate } from 'react-router'
import {
DIFFICULTIES,
METHODS,
useAddRegular,
useEditRegular,
useGroundRegular,
useRecipeCandidates,
useRegulars,
useRemoveRegular,
useToggleFavorite,
type Difficulty,
type GroundingStatus,
type ManualRecipe,
type Method,
type NewRegular,
type RecipeFix,
type Regular,
type RegularPatch,
} from '../dinners/api.ts'
import { useRefusals } from '../dinners/useRefusals.ts'
import { Button } from '../ui/Button.tsx'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { ListRow } from '../ui/ListRow.tsx'
import { Photo } from '../ui/Photo.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Select } from '../ui/Select.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import { TextInput } from '../ui/TextInput.tsx'
import styles from './RegularsSettingsPage.module.css'
// The most minutes a dinner may take, as the dinners API allows: a day.
const MAX_MINUTES = 1440
export const methodLabels: Record<Method, string> = {
stovetop: 'Stovetop',
oven: 'Oven',
crockpot: 'Crock pot',
grill: 'Grill',
other: 'Other',
}
const difficultyLabels: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
// A pasted link is added as a recipe link; anything else is a dinner name.
const LINK = /^https?:\/\//i
// Settings · Regular dinners: the household's Regulars, each with its photo,
// method and minutes, favorite heart and whether its recipe was found. The
// field at the top adds one by name or pasted recipe link; tapping one
// edits or removes it, or fixes its recipe when none was found.
export function RegularsSettingsPage() {
const navigate = useNavigate()
return (
<div className={styles.page}>
<header className={styles.header}>
<IconButton label="Back to Settings" onClick={() => void navigate('/settings')}>
<Icon icon={ChevronLeft} size="nav" />
</IconButton>
<div>
<p className={styles.kicker}>Settings</p>
<h1 className={styles.title}>Regular dinners</h1>
</div>
</header>
<p className={styles.shared}>Changes apply to everyone in the household.</p>
<RegularsManager />
</div>
)
}
// Which sheet is open, for which Regular.
type Open = { sheet: 'edit' | 'fix'; id: string } | null
function RegularsManager() {
const regulars = useRegulars()
const add = useAddRegular()
const remove = useRemoveRegular()
const favorite = useToggleFavorite()
const { refusals, track } = useRefusals()
const [draft, setDraft] = useState('')
const [open, setOpen] = useState<Open>(null)
const headingId = useId()
const list = regulars.data ?? []
// A Regular removed meanwhile closes its sheet.
const opened = open && list.find((regular) => regular.id === open.id && !regular.pending)
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const text = draft.trim()
if (text === '') return
const body: NewRegular = LINK.test(text) ? { recipe_url: text } : { name: text }
track(`add:${text.toLowerCase()}`, `add ${text}`, add.mutateAsync(body))
setDraft('')
}
function onRemove(regular: Regular) {
setOpen(null)
track(`remove:${regular.id}`, `remove ${regular.name}`, remove.mutateAsync(regular))
}
function onFavorite(regular: Regular) {
track(`favorite:${regular.id}`, `change the favorite heart on ${regular.name}`, favorite.mutateAsync(regular))
}
return (
<>
<form className={styles.add} onSubmit={onSubmit}>
<TextInput
label="Add a Regular"
labelHidden
className={styles.addInput}
placeholder="Dinner name or recipe link"
autoComplete="off"
enterKeyHint="done"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<IconButton type="submit" label="Add Regular" tone="accent" disabled={draft.trim() === ''}>
<Icon icon={Plus} size="nav" />
</IconButton>
</form>
{refusals.map((refusal) => (
<p key={refusal.key} role="alert" className={styles.problem}>
{refusal.text}
</p>
))}
{regulars.isPending && <Skeleton className={styles.loading} />}
{regulars.isError && (
<ErrorBanner onRetry={() => void regulars.refetch()}>
Your Regulars could not load: {regulars.error.message}
</ErrorBanner>
)}
{regulars.isSuccess && (
<section aria-labelledby={headingId} className={styles.section}>
<h2 id={headingId} className={styles.groupLabel}>
On rotation · {list.length}
</h2>
{list.length === 0 ? (
<p className={styles.hint}>No Regulars yet. Add the dinners your household makes on repeat.</p>
) : (
<Card className={styles.card}>
<ul className={styles.rows} aria-label="Regulars">
{list.map((regular) => (
<RegularRow
key={regular.id}
regular={regular}
onOpen={() =>
setOpen({ sheet: regular.grounding_status === 'failed' ? 'fix' : 'edit', id: regular.id })
}
onFavorite={() => onFavorite(regular)}
/>
))}
</ul>
</Card>
)}
</section>
)}
{opened && open.sheet === 'edit' && (
<EditRegularSheet
key={opened.id}
regular={opened}
onClose={() => setOpen(null)}
onRemove={() => onRemove(opened)}
/>
)}
{opened && open.sheet === 'fix' && (
<FixRecipeSheet
key={opened.id}
regular={opened}
onClose={() => setOpen(null)}
onEdit={() => setOpen({ sheet: 'edit', id: opened.id })}
/>
)}
</>
)
}
// details is a Regular's "method · minutes" line, or null when neither is
// known yet.
export function details(regular: Regular): string | null {
const parts = [
regular.method && methodLabels[regular.method],
regular.minutes_total !== null && `${regular.minutes_total} min`,
].filter((part): part is string => typeof part === 'string')
return parts.length === 0 ? null : parts.join(' · ')
}
const badges: Record<GroundingStatus, { text: string; className: string | undefined }> = {
pending: { text: 'Finding recipe…', className: styles.finding },
grounded: { text: 'Recipe found', className: styles.found },
manual: { text: 'Recipe found', className: styles.found },
failed: { text: 'Needs a recipe', className: styles.needs },
}
function GroundingBadge({ status }: { status: GroundingStatus }) {
const badge = badges[status]
return <Tag className={`${styles.badge} ${badge.className}`}>{badge.text}</Tag>
}
type RegularRowProps = {
regular: Regular
onOpen: () => void
onFavorite: () => void
}
function RegularRow({ regular, onOpen, onFavorite }: RegularRowProps) {
const line = details(regular)
return (
<li className={styles.item} aria-busy={regular.pending}>
<ListRow
className={regular.pending && styles.pending}
leading={<Photo id={regular.photo_id} name={regular.name} radius="14px" sizes="48px" className={styles.photo} />}
title={regular.name}
meta={
<span className={styles.meta}>
{line && <span>{line}</span>}
<GroundingBadge status={regular.grounding_status} />
</span>
}
onClick={regular.pending ? undefined : onOpen}
trailing={
<IconButton
label={`Favorite ${regular.name}`}
aria-pressed={regular.is_favorite}
disabled={regular.pending}
onClick={onFavorite}
>
<Icon icon={Heart} size="nav" className={regular.is_favorite ? styles.filled : undefined} />
</IconButton>
}
/>
</li>
)
}
type SheetFrameProps = {
title: string
onClose: () => void
children: ReactNode
}
function SheetFrame({ title, onClose, children }: SheetFrameProps) {
const titleId = useId()
return (
<Sheet open onClose={onClose} aria-labelledby={titleId}>
<div className={styles.sheet}>
<h2 id={titleId} className={styles.sheetTitle}>
{title}
</h2>
{children}
</div>
</Sheet>
)
}
// minutesProblem is why a typed minutes value cannot be saved, or null.
function minutesProblem(text: string): string | null {
if (text === '') return null
const minutes = Number(text)
if (!Number.isInteger(minutes) || minutes < 1 || minutes > MAX_MINUTES) {
return `Minutes must be a whole number from 1 to ${MAX_MINUTES}.`
}
return null
}
type Draft = { name: string; method: Method | ''; minutes: string; difficulty: Difficulty | '' }
function draftOf(regular: Regular): Draft {
return {
name: regular.name,
method: regular.method ?? '',
minutes: regular.minutes_total === null ? '' : String(regular.minutes_total),
difficulty: regular.difficulty ?? '',
}
}
// patchOf is the PATCH body for the fields draft changes. A field the
// Regular has no value for stays unset until one is chosen.
function patchOf(regular: Regular, draft: Draft): RegularPatch {
const saved = draftOf(regular)
const patch: RegularPatch = {}
if (draft.name.trim() !== saved.name) patch.name = draft.name.trim()
if (draft.method !== saved.method && draft.method !== '') patch.method = draft.method
if (draft.minutes !== saved.minutes && draft.minutes !== '') patch.minutes_total = Number(draft.minutes)
if (draft.difficulty !== saved.difficulty && draft.difficulty !== '') patch.difficulty = draft.difficulty
return patch
}
type EditRegularSheetProps = {
regular: Regular
onClose: () => void
onRemove: () => void
}
// EditRegularSheet renames a Regular and sets its method, minutes and
// difficulty, or removes it from the rotation once confirmed.
function EditRegularSheet({ regular, onClose, onRemove }: EditRegularSheetProps) {
const [draft, setDraft] = useState(() => draftOf(regular))
const [confirming, setConfirming] = useState(false)
const edit = useEditRegular()
const patch = patchOf(regular, draft)
const minutesError = minutesProblem(draft.minutes)
const nameError = draft.name.trim() === '' ? 'A Regular needs a name.' : null
const canSave = Object.keys(patch).length > 0 && minutesError === null && nameError === null
function change(next: Partial<Draft>) {
edit.reset()
setDraft((current) => ({ ...current, ...next }))
}
function onSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!canSave) return
edit.mutate({ id: regular.id, patch }, { onSuccess: onClose })
}
if (confirming) {
return (
<SheetFrame title={`Remove ${regular.name}?`} onClose={onClose}>
<p className={styles.hint}>
It leaves your Regulars, so suggestions and plans stop using it. You can add it again later.
</p>
<div className={styles.actions}>
<Button onClick={onRemove}>Remove</Button>
<Button variant="outline" onClick={() => setConfirming(false)}>
Keep it
</Button>
</div>
</SheetFrame>
)
}
return (
<SheetFrame title={`Edit ${regular.name}`} onClose={onClose}>
<form className={styles.form} onSubmit={onSave}>
<fieldset className={styles.lock} disabled={edit.isPending}>
<TextInput
label="Name"
value={draft.name}
autoComplete="off"
aria-invalid={nameError !== null}
onChange={(event) => change({ name: event.target.value })}
/>
{nameError && <p className={styles.problem}>{nameError}</p>}
<div className={styles.columns}>
<Select label="Method" value={draft.method} onChange={(event) => change({ method: event.target.value as Method })}>
{regular.method === null && (
<option value="" disabled>
Not set
</option>
)}
{METHODS.map((method) => (
<option key={method} value={method}>
{methodLabels[method]}
</option>
))}
</Select>
<TextInput
label="Minutes"
className={styles.paired}
type="number"
inputMode="numeric"
min={1}
max={MAX_MINUTES}
value={draft.minutes}
aria-invalid={minutesError !== null}
onChange={(event) => change({ minutes: event.target.value })}
/>
</div>
{minutesError && <p className={styles.problem}>{minutesError}</p>}
<Select
label="Difficulty"
value={draft.difficulty}
onChange={(event) => change({ difficulty: event.target.value as Difficulty })}
>
{regular.difficulty === null && (
<option value="" disabled>
Not set
</option>
)}
{DIFFICULTIES.map((difficulty) => (
<option key={difficulty} value={difficulty}>
{difficultyLabels[difficulty]}
</option>
))}
</Select>
{edit.isError && (
<p role="alert" className={styles.problem}>
Not saved: {edit.error.message}
</p>
)}
<div className={styles.actions}>
<Button type="submit" disabled={!canSave}>
{edit.isPending ? 'Saving…' : 'Save changes'}
</Button>
<Button variant="outline" onClick={() => setConfirming(true)}>
Remove from Regulars
</Button>
</div>
</fieldset>
</form>
</SheetFrame>
)
}
type FixPath = 'pick' | 'link' | 'manual'
const fixPaths = [
{ value: 'pick', label: 'Pick one' },
{ value: 'link', label: 'Paste a link' },
{ value: 'manual', label: 'Type it in' },
] as const
type FixRecipeSheetProps = {
regular: Regular
onClose: () => void
onEdit: () => void
}
// lines is text split into its non-blank lines, trimmed.
function lines(text: string): string[] {
return text
.split('\n')
.map((line) => line.trim())
.filter((line) => line !== '')
}
// FixRecipeSheet grounds a Regular whose recipe was not found: in one of
// up to three recipe pages found for its name, in a pasted recipe link, or
// in ingredients and steps typed in.
function FixRecipeSheet({ regular, onClose, onEdit }: FixRecipeSheetProps) {
const [path, setPath] = useState<FixPath>('pick')
const ground = useGroundRegular()
function fix(body: RecipeFix) {
ground.mutate({ id: regular.id, fix: body }, { onSuccess: onClose })
}
return (
<SheetFrame title="Fix recipe" onClose={onClose}>
<p className={styles.hint}>
We couldn&apos;t find a recipe for {regular.name}. Pick a match below, paste a recipe link or type it in.
</p>
<Segmented
label="How to fix the recipe"
options={fixPaths}
value={path}
onValueChange={(next) => {
ground.reset()
setPath(next)
}}
/>
<fieldset className={styles.lock} disabled={ground.isPending}>
{path === 'pick' && <PickCandidate id={regular.id} onPick={(candidate_id) => fix({ candidate_id })} />}
{path === 'link' && <PasteLink onSubmit={(recipe_url) => fix({ recipe_url })} />}
{path === 'manual' && <ManualEntry onSubmit={(manual) => fix({ manual })} />}
</fieldset>
<p role="status" className={styles.status}>
{ground.isPending && 'Finding recipe… this can take a minute.'}
</p>
{ground.isError && (
<p role="alert" className={styles.problem}>
Not fixed: {ground.error.message}
</p>
)}
<Button variant="outline" disabled={ground.isPending} onClick={onEdit}>
Edit or remove {regular.name}
</Button>
</SheetFrame>
)
}
function PickCandidate({ id, onPick }: { id: string; onPick: (candidateId: string) => void }) {
const candidates = useRecipeCandidates(id)
if (candidates.isPending) return <Skeleton className={styles.candidatesLoading} />
if (candidates.isError) {
return (
<ErrorBanner onRetry={() => void candidates.refetch()}>
Recipe matches could not load: {candidates.error.message}
</ErrorBanner>
)
}
if (candidates.data.length === 0) {
return <p className={styles.hint}>No matches were found. Paste a recipe link or type it in.</p>
}
return (
<ul className={styles.candidates} aria-label="Recipe matches">
{candidates.data.map((candidate) => (
<li key={candidate.candidate_id}>
<button
type="button"
className={styles.candidate}
aria-label={`Use ${candidate.title} from ${candidate.site_name}`}
onClick={() => onPick(candidate.candidate_id)}
>
<Photo id={candidate.photo_id} name={candidate.title} radius="14px" sizes="56px" className={styles.candidatePhoto} />
<span className={styles.candidateBody}>
<span className={styles.candidateTitle}>{candidate.title}</span>
<span className={styles.candidateSite}>{candidate.site_name}</span>
</span>
</button>
</li>
))}
</ul>
)
}
function PasteLink({ onSubmit }: { onSubmit: (recipeUrl: string) => void }) {
const [link, setLink] = useState('')
const url = link.trim()
return (
<form
className={styles.form}
onSubmit={(event) => {
event.preventDefault()
if (url !== '') onSubmit(url)
}}
>
<TextInput
label="Recipe link"
type="url"
inputMode="url"
placeholder="https://"
autoComplete="off"
value={link}
onChange={(event) => setLink(event.target.value)}
/>
<Button type="submit" disabled={url === ''}>
Use this recipe
</Button>
</form>
)
}
function ManualEntry({ onSubmit }: { onSubmit: (manual: ManualRecipe) => void }) {
const [ingredients, setIngredients] = useState('')
const [steps, setSteps] = useState('')
const [minutes, setMinutes] = useState('')
const [method, setMethod] = useState<Method | ''>('')
const ingredientsId = useId()
const stepsId = useId()
const minutesError = minutesProblem(minutes)
const ingredientLines = lines(ingredients)
const missing = method === '' || minutes === ''
const canSave = ingredientLines.length > 0 && !missing && minutesError === null
function onSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!canSave) return
onSubmit({ ingredients: ingredientLines, steps: lines(steps), minutes: Number(minutes), method })
}
return (
<form className={styles.form} onSubmit={onSave}>
<div className={styles.field}>
<label htmlFor={ingredientsId} className={styles.label}>
Ingredients, one per line
</label>
<textarea
id={ingredientsId}
className={styles.textarea}
rows={5}
value={ingredients}
onChange={(event) => setIngredients(event.target.value)}
/>
</div>
<div className={styles.field}>
<label htmlFor={stepsId} className={styles.label}>
Steps, one per line
</label>
<textarea
id={stepsId}
className={styles.textarea}
rows={5}
value={steps}
onChange={(event) => setSteps(event.target.value)}
/>
</div>
<div className={styles.columns}>
<Select label="Method" value={method} onChange={(event) => setMethod(event.target.value as Method | '')}>
<option value="">Not set</option>
{METHODS.map((m) => (
<option key={m} value={m}>
{methodLabels[m]}
</option>
))}
</Select>
<TextInput
label="Minutes"
className={styles.paired}
type="number"
inputMode="numeric"
min={1}
max={MAX_MINUTES}
value={minutes}
aria-invalid={minutesError !== null}
onChange={(event) => setMinutes(event.target.value)}
/>
</div>
{minutesError && <p className={styles.problem}>{minutesError}</p>}
{ingredientLines.length > 0 && missing && <p className={styles.problem}>Choose a method and minutes to save.</p>}
<Button type="submit" disabled={!canSave}>
Save recipe
</Button>
</form>
)
}
+153
View File
@@ -0,0 +1,153 @@
/* Settings home, handoff mock 1p. */
.page {
max-width: 560px;
display: flex;
flex-direction: column;
}
.title {
margin: 0;
padding-top: 6px;
font-family: var(--font-heading);
font-size: 28px;
font-weight: 400;
}
.banner {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
padding: 14px 16px;
border-radius: 26px;
background: var(--color-accent-2-100);
color: var(--color-accent-2-900);
}
.avatar {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--color-accent-2-300);
color: var(--color-accent-2-900);
font-family: var(--font-heading);
font-size: 18px;
}
.bannerText {
flex: 1;
display: flex;
flex-direction: column;
margin: 0;
font-family: var(--font-body);
font-size: 13px;
line-height: 1.35;
}
.bannerTitle {
font-size: 15px;
font-weight: 700;
}
.alert {
margin-top: 14px;
}
.groupLabel {
margin: 0;
padding: 18px 4px 8px;
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-neutral-700);
}
.group {
padding: 0;
border-radius: 26px;
}
.admin {
box-shadow: inset 0 0 0 2px var(--color-accent-300);
}
.rows {
margin: 0;
padding: 0;
list-style: none;
}
/* Rows in one card are split by a hairline inset to the row padding. */
.item + .item {
border-top: 1px solid var(--color-neutral-300);
margin: 0 16px;
}
.item + .item .row {
padding-left: 0;
padding-right: 0;
}
.row {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 14px;
min-width: 44px;
min-height: 44px;
padding: 14px 16px;
border-radius: 26px;
color: var(--color-text);
text-decoration: none;
}
.icon {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
background: var(--color-bg);
}
.icon svg {
width: 18px;
height: 18px;
}
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
font-family: var(--font-body);
}
.rowTitle {
font-size: 15px;
font-weight: 700;
}
.status {
font-size: 12px;
color: var(--color-neutral-700);
}
.statusSkeleton {
width: 60%;
height: 12px;
margin-top: 3px;
background: var(--color-neutral-300);
}
.tag {
flex: none;
}
+267
View File
@@ -0,0 +1,267 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { formatClockTime, modelDisplayName } from './api.ts'
import { SettingsPage } from './SettingsPage.tsx'
const HOUSEHOLD = HOUSEHOLD_SETTINGS
const AI = AI_SETTINGS
afterEach(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
})
const REGULARS_PATH = '/api/v1/dinners?status=regular'
const CALENDAR_PATH = '/api/v1/settings/calendar'
const NOTIFICATIONS_PATH = '/api/v1/settings/notifications'
// stubSettings answers as today's server does (see SETTINGS_API), with
// overrides for the paths a test changes.
function stubSettings(overrides: Record<string, () => Response> = {}) {
return stubFetchByPath({
...SETTINGS_API,
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD),
'/api/v1/settings/ai': () => jsonResponse(200, AI),
...overrides,
})
}
function CurrentPath() {
return <p data-testid="path">{useLocation().pathname}</p>
}
// renderSettings renders the settings home on a 390 px phone (mock 1p) in a
// data router (the hidden sub-tabs' leave guard needs one);
// SettingsTabs.test.tsx covers 768 px and up.
function renderSettings() {
mockViewport(390)
const api = createApiClient(fakeLocation('/settings'), createNetworkStatus(window), createVerifiedSession(localStorage))
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings">
<Routes>
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<CurrentPath />} />
</Routes>
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
function row(name: string) {
return screen.getByRole('link', { name: new RegExp(`^${name}`) })
}
describe('SettingsPage', () => {
it('renders the title and the shared household banner', async () => {
stubSettings()
renderSettings()
expect(screen.getByRole('heading', { level: 1, name: 'Settings' })).toBeInTheDocument()
expect(screen.getByText('Our kitchen')).toBeInTheDocument()
expect(screen.getByText('Shared household · changes apply to everyone')).toBeInTheDocument()
expect(await screen.findByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
})
it('groups the rows under Household, Alerts and Admin', async () => {
stubSettings()
renderSettings()
const group = (name: string) => within(screen.getByRole('region', { name }))
expect(group('Household').getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([
'/settings/regulars',
'/settings/calendar',
'/settings/household',
])
expect(group('Alerts').getByRole('link')).toHaveAttribute('href', '/settings/notifications')
expect(group('Admin').getByRole('link')).toHaveAttribute('href', '/settings/ai')
await screen.findByText('Anthropic · Claude Sonnet')
})
it('reads every status line from the server', async () => {
const fetchMock = stubSettings()
renderSettings()
expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
expect(within(row('AI assistant')).getByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
REGULARS_PATH,
'/api/v1/settings/ai',
'/api/v1/settings/ai/models',
CALENDAR_PATH,
'/api/v1/settings/household',
NOTIFICATIONS_PATH,
'/api/v1/settings/recipe-search',
])
})
it('shows the not-yet-available states while the server has no endpoint for them', async () => {
stubSettings()
renderSettings()
expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument()
expect(await within(row('Google Calendar')).findByText('Not connected')).toBeInTheDocument()
expect(
await within(row('Notifications & Google Chat')).findByText('Push off · Google Chat not connected'),
).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('shows the Regulars on rotation, the connected calendar and the notifications once served', async () => {
stubSettings({
[REGULARS_PATH]: () => jsonResponse(200, { dinners: Array.from({ length: 7 }, (_, i) => ({ id: `d${i}` })) }),
[CALENDAR_PATH]: () => jsonResponse(200, { connected: true, calendar_name: 'Dinners calendar' }),
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: true, chat_space: 'Dinner Crew space' }),
})
renderSettings()
expect(await within(row('Regular dinners')).findByText('7 on rotation')).toBeInTheDocument()
expect(await within(row('Google Calendar')).findByText('Connected · Dinners calendar')).toBeInTheDocument()
expect(
await within(row('Notifications & Google Chat')).findByText('Push on · Dinner Crew space'),
).toBeInTheDocument()
})
it('shows no Regulars, a disconnected calendar and push off with no Google Chat space', async () => {
stubSettings({
[REGULARS_PATH]: () => jsonResponse(200, { dinners: [] }),
[CALENDAR_PATH]: () => jsonResponse(200, { connected: false }),
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: false, chat_space: null }),
})
renderSettings()
expect(await within(row('Regular dinners')).findByText('None on rotation yet')).toBeInTheDocument()
expect(await within(row('Google Calendar')).findByText('Not connected')).toBeInTheDocument()
expect(
await within(row('Notifications & Google Chat')).findByText('Push off · Google Chat not connected'),
).toBeInTheDocument()
})
it('reports a status endpoint failure other than a missing endpoint', async () => {
stubSettings({ [CALENDAR_PATH]: () => errorResponse(500, 'internal') })
renderSettings()
expect(await screen.findByRole('alert')).toHaveTextContent('Settings could not load: internal message')
expect(row('Google Calendar')).toHaveAccessibleName('Google Calendar')
})
it('refuses a connected calendar without a name', async () => {
stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { connected: true }) })
renderSettings()
expect(await screen.findByRole('alert')).toHaveTextContent('response is not a calendar status')
})
it('shows another provider and model as the settings name them', async () => {
stubSettings({
'/api/v1/settings/ai': () => jsonResponse(200, { ...AI, provider: 'openai_compatible', model: 'llama3.1:8b' }),
})
renderSettings()
expect(await screen.findByText('OpenAI-compatible · llama3.1:8b')).toBeInTheDocument()
})
it('says no model is chosen for the seeded AI settings', async () => {
stubSettings({ '/api/v1/settings/ai': () => jsonResponse(200, { ...AI, model: '' }) })
renderSettings()
expect(await within(row('AI assistant')).findByText('Anthropic · No model chosen')).toBeInTheDocument()
})
it('marks the AI assistant row Admin and rings its card in grape-300', async () => {
stubSettings()
renderSettings()
expect(within(row('AI assistant')).getByText('Admin')).toBeInTheDocument()
const adminCard = screen.getByRole('region', { name: 'Admin' }).querySelector('ul')!.parentElement!
expect(declaredStyle(adminCard).boxShadow).toBe('inset 0 0 0 2px var(--color-accent-300)')
const householdCard = screen.getByRole('region', { name: 'Household' }).querySelector('ul')!.parentElement!
expect(declaredStyle(householdCard).boxShadow).toBe('')
await screen.findByText('Anthropic · Claude Sonnet')
})
it.each([
['Regular dinners', '/settings/regulars'],
['Google Calendar', '/settings/calendar'],
['Household', '/settings/household'],
['Notifications & Google Chat', '/settings/notifications'],
['AI assistant', '/settings/ai'],
])('the %s row navigates to %s', async (name, path) => {
stubSettings()
renderSettings()
await screen.findByText('Anthropic · Claude Sonnet')
fireEvent.click(row(name))
expect(screen.getByTestId('path')).toHaveTextContent(path)
})
it('gives every row a tap target of at least 44 px', async () => {
stubSettings()
renderSettings()
await screen.findByText('Anthropic · Claude Sonnet')
for (const link of screen.getAllByRole('link')) expectMinHitArea(link)
})
it('reports a failed status with a retry that loads it', async () => {
let householdCalls = 0
stubSettings({
'/api/v1/settings/household': () => {
householdCalls += 1
return householdCalls === 1 ? errorResponse(500, 'internal') : jsonResponse(200, HOUSEHOLD)
},
})
renderSettings()
expect(await screen.findByRole('alert')).toHaveTextContent('Settings could not load: internal message')
expect(screen.getByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
expect(row('Household')).toHaveAccessibleName('Household')
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('refuses an AI provider it has no name for', async () => {
stubSettings({ '/api/v1/settings/ai': () => jsonResponse(200, { ...AI, provider: 'mystery', model: 'm1' }) })
renderSettings()
expect(await screen.findByRole('alert')).toHaveTextContent('unknown AI provider "mystery"')
expect(row('AI assistant')).toHaveAccessibleName('AI assistant Admin')
})
it('refuses a dinner time that is not HH:MM', async () => {
stubSettings({
'/api/v1/settings/household': () => jsonResponse(200, { ...HOUSEHOLD, timezone: 'UTC', dinner_time: '5:30pm' }),
})
renderSettings()
expect(await screen.findByRole('alert')).toHaveTextContent('dinner_time "5:30pm" is not HH:MM')
})
})
describe('modelDisplayName', () => {
it.each([
['claude-sonnet-4-5', 'Claude Sonnet'],
['claude-opus-4-1-20250805', 'Claude Opus'],
['claude-3-5-haiku-20241022', 'Claude Haiku'],
['claude-fable-5-1', 'Claude Fable'],
['gpt-4o', 'gpt-4o'],
['anthropic/claude-sonnet-4.5', 'anthropic/claude-sonnet-4.5'],
])('names %s %s', (model, name) => {
expect(modelDisplayName(model)).toBe(name)
})
})
describe('formatClockTime', () => {
it.each([
['00:05', '12:05 am'],
['09:00', '9:00 am'],
['12:00', '12:00 pm'],
['17:30', '5:30 pm'],
['23:59', '11:59 pm'],
])('formats %s as %s', (time, formatted) => {
expect(formatClockTime(time)).toBe(formatted)
})
it('refuses a time that is not HH:MM', () => {
expect(() => formatClockTime('24:00')).toThrow('not a 24-hour HH:MM time')
})
})
+167
View File
@@ -0,0 +1,167 @@
import type { UseQueryResult } from '@tanstack/react-query'
import type { LucideIcon } from 'lucide-react'
import { Bell, Calendar, ChevronRight, Crosshair, Heart, House } from 'lucide-react'
import { useId, type ReactNode } from 'react'
import { Link } from 'react-router'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import { useBreakpoint } from '../shell/useBreakpoint.ts'
import {
aiStatus,
calendarStatus,
formatClockTime,
notificationsStatus,
regularDinnersStatus,
useAISettings,
useCalendarStatus,
useHouseholdSettings,
useNotificationsStatus,
useRegularDinnersCount,
} from './api.ts'
import styles from './SettingsPage.module.css'
import { SettingsTabs } from './SettingsTabs.tsx'
// Settings home: the list of settings pages on mobile (mock 1p), one page
// with sub-tabs from 768 px (mock 2e). The sub-tabs stay mounted, hidden, on
// mobile, so an unsaved draft survives the viewport crossing 768 px and its
// leave guard keeps asking before the list navigates away.
export function SettingsPage() {
const mobile = useBreakpoint() === 'mobile'
return (
<>
{mobile && <SettingsList />}
<SettingsTabs hidden={mobile} />
</>
)
}
// The mobile settings list (handoff mock 1p). Each row opens its settings
// page and shows a live status line. Regular dinners, Google Calendar and
// Notifications & Google Chat read endpoints their features have not served
// yet; until then each shows its not-yet-available state (see api.ts).
function SettingsList() {
const household = useHouseholdSettings()
const ai = useAISettings()
const regulars = useRegularDinnersCount()
const calendar = useCalendarStatus()
const notifications = useNotificationsStatus()
const failed = [regulars, calendar, household, notifications, ai].filter((query) => query.isError)
return (
<div className={styles.page}>
<h1 className={styles.title}>Settings</h1>
<div className={styles.banner}>
<span className={styles.avatar} aria-hidden="true">
K
</span>
<p className={styles.bannerText}>
<strong className={styles.bannerTitle}>Our kitchen</strong>
<span>Shared household · changes apply to everyone</span>
</p>
</div>
{failed.length > 0 && (
<div className={styles.alert}>
<ErrorBanner onRetry={() => failed.forEach((query) => void query.refetch())}>
Settings could not load: {failed.map((query) => query.error?.message).join('; ')}
</ErrorBanner>
</div>
)}
<Group label="Household">
<SettingsRow
to="/settings/regulars"
icon={Heart}
title="Regular dinners"
status={status(regulars, regularDinnersStatus)}
/>
<SettingsRow
to="/settings/calendar"
icon={Calendar}
title="Google Calendar"
status={status(calendar, calendarStatus)}
/>
<SettingsRow
to="/settings/household"
icon={House}
title="Household"
status={status(household, (s) => `${s.timezone} · Dinner at ${formatClockTime(s.dinner_time)}`)}
/>
</Group>
<Group label="Alerts">
<SettingsRow
to="/settings/notifications"
icon={Bell}
title="Notifications & Google Chat"
status={status(notifications, notificationsStatus)}
/>
</Group>
<Group label="Admin" admin>
<SettingsRow
to="/settings/ai"
icon={Crosshair}
title="AI assistant"
status={status(ai, aiStatus)}
tag={<Tag className={styles.tag}>Admin</Tag>}
/>
</Group>
</div>
)
}
// status is a row's status line: a skeleton while its query loads, the
// formatted data once it has, and nothing when it failed (the error banner
// says why).
function status<T>(query: UseQueryResult<T>, format: (data: T) => string): ReactNode {
if (query.isPending) return <Skeleton className={styles.statusSkeleton} />
if (query.isError) return null
return format(query.data)
}
type GroupProps = {
label: string
// The Admin group's card carries the grape-300 inset ring.
admin?: boolean
children: ReactNode
}
function Group({ label, admin = false, children }: GroupProps) {
const id = useId()
return (
<section aria-labelledby={id}>
<h2 id={id} className={styles.groupLabel}>
{label}
</h2>
<Card className={[styles.group, admin && styles.admin].filter(Boolean).join(' ')}>
<ul className={styles.rows}>{children}</ul>
</Card>
</section>
)
}
type SettingsRowProps = {
to: string
icon: LucideIcon
title: string
status?: ReactNode
tag?: ReactNode
}
function SettingsRow({ to, icon, title, status, tag }: SettingsRowProps) {
return (
<li className={styles.item}>
<Link to={to} className={styles.row}>
<span className={styles.icon}>
<Icon icon={icon} />
</span>
<span className={styles.body}>
<span className={styles.rowTitle}>{title}</span>
{status && <span className={styles.status}>{status}</span>}
</span>
{tag}
<Icon icon={ChevronRight} />
</Link>
</li>
)
}
+118
View File
@@ -0,0 +1,118 @@
/* Settings at tablet and desktop widths, handoff mock 2e. */
.page {
box-sizing: border-box;
min-height: 100%;
display: flex;
flex-direction: column;
gap: 20px;
}
.header {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px;
}
.heading {
flex: 1;
min-width: 0;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 40px;
font-weight: 400;
line-height: 1.1;
}
.shared {
margin: 6px 0 0;
font-family: var(--font-body);
font-size: 14px;
color: var(--color-neutral-800);
}
.tabs {
display: flex;
gap: 6px;
}
.tab {
min-width: 44px;
min-height: 44px;
padding: 0 16px;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-text);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.selected {
background: var(--color-text);
color: var(--color-bg);
}
/* A page's or panel's display would override the hidden attribute. */
.page[hidden],
.single[hidden],
.columns[hidden] {
display: none;
}
.single {
max-width: 560px;
display: flex;
flex-direction: column;
gap: 18px;
}
/* Notifications on the left; the AI assistant and Recipe search on the
right, the AI assistant card growing to fill the column. */
.columns {
flex: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
align-items: stretch;
gap: 20px;
}
.column {
min-width: 0;
display: flex;
flex-direction: column;
gap: 20px;
}
/* Mock 2e's cards: 24 px padding, 30 px corners. */
.column > * {
padding: 24px;
border-radius: 30px;
}
.placeholder {
display: flex;
flex-direction: column;
gap: 14px;
}
.cardTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.hint {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-neutral-700);
}
+193
View File
@@ -0,0 +1,193 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { AI_MODELS, AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { SettingsPage } from './SettingsPage.tsx'
type Responder = (body: unknown) => Response
// stubAPI answers "METHOD path" from responders, each called with the
// request's decoded JSON body; the settings the page loads answer their
// fixtures unless overridden. Any other request fails the test.
function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch> {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'GET /api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined
return Promise.resolve(respond(body))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
afterEach(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
})
// renderAt renders /settings at width px in a data router (the page's leave
// guard needs one), after loading /me for the CSRF token writes need.
// It returns the function that resizes the viewport.
async function renderAt(width: number) {
const resize = mockViewport(width)
const api = createApiClient(fakeLocation('/settings'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings">
<SettingsPage />
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
return resize
}
const tab = (name: string) => screen.getByRole('tab', { name })
// panel is the panel a tab controls, shown or hidden.
function panel(name: string): HTMLElement {
const id = tab(name).getAttribute('aria-controls')
const element = id === null ? null : document.getElementById(id)
if (element === null) throw new Error(`tab "${name}" controls no panel`)
expect(element).toHaveAttribute('role', 'tabpanel')
return element
}
describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
it('shows the title, the shared line and the sub-tabs, Notifications & AI first', async () => {
stubAPI()
await renderAt(width)
expect(screen.getByRole('heading', { level: 1, name: 'Settings' })).toBeInTheDocument()
expect(screen.getByText('Changes apply to everyone in the household.')).toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
const tabs = within(screen.getByRole('tablist', { name: 'Settings sections' })).getAllByRole('tab')
expect(tabs.map((t) => t.textContent)).toEqual(['Household', 'Regular dinners', 'Notifications & AI'])
expect(tab('Notifications & AI')).toHaveAttribute('aria-selected', 'true')
tabs.forEach(expectMinHitArea)
const columns = panel('Notifications & AI')
expect(columns).toBeVisible()
const [left, right] = Array.from(columns.children)
expect(within(left as HTMLElement).getByRole('region', { name: 'Notifications & Google Chat' })).toBeInTheDocument()
const ai = within(right as HTMLElement).getByRole('region', { name: 'AI assistant' })
expect(within(ai).getByText('Admin')).toBeInTheDocument()
expect(await within(ai).findByRole('combobox', { name: 'Provider' })).toHaveValue('anthropic')
expect(within(ai).getByRole('textbox', { name: 'System prompt' })).toHaveValue(AI_SETTINGS.system_prompt)
expect(within(right as HTMLElement).getByRole('region', { name: 'Recipe search' })).toBeInTheDocument()
})
it('switches tabs by click and by arrow keys', async () => {
stubAPI()
await renderAt(width)
fireEvent.click(tab('Household'))
expect(tab('Household')).toHaveAttribute('aria-selected', 'true')
expect(panel('Household')).toBeVisible()
expect(panel('Notifications & AI')).not.toBeVisible()
expect(await within(panel('Household')).findByRole('button', { name: 'Save changes' })).toBeInTheDocument()
fireEvent.keyDown(tab('Household'), { key: 'ArrowRight' })
expect(tab('Regular dinners')).toHaveAttribute('aria-selected', 'true')
expect(tab('Regular dinners')).toHaveFocus()
expect(panel('Regular dinners')).toBeVisible()
expect(within(panel('Regular dinners')).getByRole('region', { name: 'Regular dinners' })).toBeInTheDocument()
expect(panel('Household')).not.toBeVisible()
fireEvent.keyDown(tab('Regular dinners'), { key: 'End' })
expect(tab('Notifications & AI')).toHaveAttribute('aria-selected', 'true')
fireEvent.keyDown(tab('Notifications & AI'), { key: 'ArrowRight' })
expect(tab('Household')).toHaveAttribute('aria-selected', 'true')
expect(tab('Household')).toHaveFocus()
})
it('saves the AI assistant card through the AI settings API', async () => {
const fetchMock = stubAPI({
'PATCH /api/v1/settings/ai': (body) => jsonResponse(200, { ...AI_SETTINGS, ...(body as object) }),
})
await renderAt(width)
const ai = within(screen.getByRole('region', { name: 'AI assistant' }))
const prompt = await ai.findByRole('textbox', { name: 'System prompt' })
const save = ai.getByRole('button', { name: 'Save changes' })
expect(save).toBeDisabled()
fireEvent.change(prompt, { target: { value: 'Plan dinners for us.' } })
fireEvent.click(save)
expect(await ai.findByText('Changes saved.')).toBeInTheDocument()
const writes = fetchMock.mock.calls.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
expect(writes.map(([input, init]) => [init?.method, String(input), init?.body])).toEqual([
['PATCH', '/api/v1/settings/ai', JSON.stringify({ system_prompt: 'Plan dinners for us.' })],
])
expect(prompt).toHaveValue('Plan dinners for us.')
expect(save).toBeDisabled()
})
it('keeps an unsaved draft while another tab is shown', async () => {
stubAPI()
await renderAt(width)
const prompt = await screen.findByRole('textbox', { name: 'System prompt' })
fireEvent.change(prompt, { target: { value: 'A draft' } })
fireEvent.click(tab('Household'))
fireEvent.click(tab('Notifications & AI'))
expect(screen.getByRole('textbox', { name: 'System prompt' })).toHaveValue('A draft')
})
})
describe('SettingsPage across the 768 px breakpoint', () => {
it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => {
stubAPI({
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'GET /api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
})
const resize = await renderAt(767)
expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai')
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
resize(768)
expect(screen.getByRole('tablist', { name: 'Settings sections' })).toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
})
it('keeps an unsaved draft and its leave guard across 1180 px, 390 px and back', async () => {
stubAPI({
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'GET /api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
})
const resize = await renderAt(1180)
fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), {
target: { value: 'A draft' },
})
resize(390)
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('link', { name: /^AI assistant/ }))
const confirm = await screen.findByRole('dialog', { name: 'Leave without saving?' })
fireEvent.click(within(confirm).getByRole('button', { name: 'Keep editing' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
resize(1180)
expect(screen.getByRole('textbox', { name: 'System prompt' })).toHaveValue('A draft')
const unload = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(unload)
expect(unload.defaultPrevented).toBe(true)
})
})
+138
View File
@@ -0,0 +1,138 @@
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
import { Card } from '../ui/Card.tsx'
import { AIAssistantCard } from './AISettingsPage.tsx'
import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx'
import { LeaveGuard } from './LeaveGuard.tsx'
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
import styles from './SettingsTabs.module.css'
type Tab = 'household' | 'regulars' | 'notifications-ai'
const tabs: readonly { value: Tab; label: string }[] = [
{ value: 'household', label: 'Household' },
{ value: 'regulars', label: 'Regular dinners' },
{ value: 'notifications-ai', label: 'Notifications & AI' },
]
const step: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1 }
// Settings at tablet and desktop widths (handoff mock 2e): one page with
// sub-tabs instead of the mobile list. Every tab stays mounted while hidden,
// so a draft survives switching tabs, and one leave guard covers them all.
// hidden hides the page but keeps it mounted, drafts and leave guard included.
export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
const [tab, setTab] = useState<Tab>('notifications-ai')
const [householdDirty, setHouseholdDirty] = useState(false)
const [aiDirty, setAIDirty] = useState(false)
const [recipeSearchDirty, setRecipeSearchDirty] = useState(false)
const refs = useRef<(HTMLButtonElement | null)[]>([])
const id = useId()
const tabId = (value: Tab) => `${id}-tab-${value}`
const panelId = (value: Tab) => `${id}-panel-${value}`
// Arrow keys move between the tabs, wrapping at the ends; Home and End
// jump to the first and last. Moving selects the tab.
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const selected = tabs.findIndex((t) => t.value === tab)
const delta = step[event.key]
let next: number
if (delta !== undefined) next = (selected + delta + tabs.length) % tabs.length
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = tabs.length - 1
else return
const nextTab = tabs[next]
if (!nextTab) throw new Error(`Settings tabs: no tab at index ${next}`)
event.preventDefault()
setTab(nextTab.value)
refs.current[next]?.focus()
}
function panel(value: Tab, className: string | undefined, children: ReactNode) {
return (
<div
id={panelId(value)}
role="tabpanel"
aria-labelledby={tabId(value)}
hidden={tab !== value}
className={className}
>
{children}
</div>
)
}
return (
<div className={styles.page} hidden={hidden}>
<header className={styles.header}>
<div className={styles.heading}>
<h1 className={styles.title}>Settings</h1>
<p className={styles.shared}>Changes apply to everyone in the household.</p>
</div>
<div role="tablist" aria-label="Settings sections" className={styles.tabs} onKeyDown={onKeyDown}>
{tabs.map(({ value, label }, index) => (
<button
key={value}
ref={(el) => {
refs.current[index] = el
}}
id={tabId(value)}
type="button"
role="tab"
aria-selected={tab === value}
aria-controls={panelId(value)}
tabIndex={tab === value ? 0 : -1}
className={tab === value ? `${styles.tab} ${styles.selected}` : styles.tab}
onClick={() => setTab(value)}
>
{label}
</button>
))}
</div>
</header>
{panel('household', styles.single, <HouseholdSettingsSections onDirtyChange={setHouseholdDirty} />)}
{panel(
'regulars',
styles.single,
// TODO(Feature 12): embed the Regular dinners screen here once Feature
// 12 builds it.
<Placeholder title="Regular dinners">Managing the household's Regulars arrives in a later update.</Placeholder>,
)}
{panel(
'notifications-ai',
styles.columns,
<>
<div className={styles.column}>
{/* TODO(Feature 19): the Notifications matrix and Google Chat
webhook cards replace this placeholder. */}
<Placeholder title="Notifications & Google Chat">
Notification and Google Chat webhook settings arrive in a later update.
</Placeholder>
</div>
<div className={styles.column}>
<AIAssistantCard onDirtyChange={setAIDirty} />
<RecipeSearchCard onDirtyChange={setRecipeSearchDirty} />
</div>
</>,
)}
<LeaveGuard dirty={householdDirty || aiDirty || recipeSearchDirty} />
</div>
)
}
// Placeholder is a neutral card holding a section's place until the feature
// that owns it renders it.
function Placeholder({ title, children }: { title: string; children: ReactNode }) {
const titleId = useId()
return (
<Card className={styles.placeholder} aria-labelledby={titleId} role="region">
<h2 id={titleId} className={styles.cardTitle}>
{title}
</h2>
<p className={styles.hint}>{children}</p>
</Card>
)
}
+580
View File
@@ -0,0 +1,580 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ApiError, useApi, type ApiClient } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
export const HOUSEHOLD_SETTINGS_PATH = '/api/v1/settings/household'
export const AI_SETTINGS_PATH = '/api/v1/settings/ai'
export const AI_MODELS_PATH = '/api/v1/settings/ai/models'
export const AI_KEY_PATH = '/api/v1/settings/ai/api-key'
export const AI_VERIFY_PATH = '/api/v1/settings/ai/verify'
export const AI_RESET_PROMPT_PATH = '/api/v1/settings/ai/reset-prompt'
export const RECIPE_SEARCH_PATH = '/api/v1/settings/recipe-search'
export const RECIPE_SEARCH_TEST_PATH = '/api/v1/settings/recipe-search/test'
// TODO(Feature 12): GET /api/v1/dinners?status=regular is Feature 12's
// dinners read API. Until it lands the server answers 404 and the Regular
// dinners row says the count is unavailable.
export const REGULAR_DINNERS_PATH = '/api/v1/dinners?status=regular'
// TODO(Feature 16): the Google Calendar integration status. Until Feature 16
// serves it the server answers 404, which is the truth: not connected.
export const CALENDAR_STATUS_PATH = '/api/v1/settings/calendar'
// TODO(notifications feature): the Web Push and Google Chat status. Until it
// is served the server answers 404, which is the truth: push is off and no
// Google Chat space is connected.
export const NOTIFICATIONS_STATUS_PATH = '/api/v1/settings/notifications'
// HouseholdSettings is the part of GET /api/v1/settings/household the
// settings pages read. Times are zero-padded 24-hour HH:MM in timezone; days
// of the week count from Sunday = 0.
export type HouseholdSettings = {
timezone: string
dinner_time: string
reminder_time: string
// The rotation check: when the household is asked "Keep it in rotation?".
review_time: string
plan_draft_dow: number
plan_draft_time: string
suggestions_dow: number
suggestions_time: string
chat_autosend_dow: number
chat_autosend_time: string
// When the household finished onboarding; null until it has, and every
// app route sends it to /onboarding. Not editable in settings.
onboarding_completed_at: string | null
}
// HouseholdSettingsField is a household setting the settings pages edit.
export type HouseholdSettingsField = Exclude<keyof HouseholdSettings, 'onboarding_completed_at'>
// HouseholdSettingsPatch is the PATCH /api/v1/settings/household body: only
// the fields that change.
export type HouseholdSettingsPatch = Partial<Pick<HouseholdSettings, HouseholdSettingsField>>
const householdTimeFields = [
'dinner_time',
'reminder_time',
'review_time',
'plan_draft_time',
'suggestions_time',
'chat_autosend_time',
] as const satisfies readonly HouseholdSettingsField[]
const householdDayFields = [
'plan_draft_dow',
'suggestions_dow',
'chat_autosend_dow',
] as const satisfies readonly HouseholdSettingsField[]
const householdFields: readonly HouseholdSettingsField[] = ['timezone', ...householdTimeFields, ...householdDayFields]
// aiProviderLabels names every AI provider preset the server accepts
// (internal/ai/llm/presets) as the household reads it.
export const aiProviderLabels = {
anthropic: 'Anthropic',
openai: 'OpenAI',
openrouter: 'OpenRouter',
xai: 'xAI',
ollama: 'Ollama',
openai_compatible: 'OpenAI-compatible',
} as const
export type AIProvider = keyof typeof aiProviderLabels
// providersWithBaseURL are the presets with no fixed API root: the
// household supplies its base URL.
export const providersWithBaseURL: ReadonlySet<AIProvider> = new Set(['ollama', 'openai_compatible'])
// aiKeyPolicy is whether each preset needs an API key, may take one, or
// takes none (internal/ai/llm/presets).
export const aiKeyPolicy: Record<AIProvider, 'required' | 'optional' | 'none'> = {
anthropic: 'required',
openai: 'required',
openrouter: 'required',
xai: 'required',
ollama: 'none',
openai_compatible: 'optional',
}
// AISettings is GET /api/v1/settings/ai. The API key itself is never sent:
// only its last four characters, null when none is stored.
export type AISettings = {
provider: AIProvider
base_url: string
model: string
temperature: number
// Null when none is stored.
system_prompt: string | null
api_key_last4: string | null
// When the stored key was last verified, null when it has not been.
key_verified_at: string | null
updated_at: string
}
// AISettingsPatch is the PATCH /api/v1/settings/ai body: only the fields
// that change.
export type AISettingsPatch = Partial<Pick<AISettings, 'provider' | 'base_url' | 'model' | 'temperature'>> & {
system_prompt?: string
}
// AIModel is one model GET /api/v1/settings/ai/models lists.
export type AIModel = { id: string; display_name: string }
function isAIProvider(value: string): value is AIProvider {
return Object.hasOwn(aiProviderLabels, value)
}
function isStringOrNull(value: unknown): value is string | null {
return value === null || typeof value === 'string'
}
// parseAISettings refuses a body that is not the AI settings, or names a
// provider this client has no name for, so the settings never show a value
// the household cannot read.
function parseAISettings(where: string, body: unknown): AISettings {
if (
!isRecord(body) ||
typeof body.provider !== 'string' ||
typeof body.base_url !== 'string' ||
typeof body.model !== 'string' ||
typeof body.temperature !== 'number' ||
!isStringOrNull(body.system_prompt) ||
!isStringOrNull(body.api_key_last4) ||
!isStringOrNull(body.key_verified_at) ||
typeof body.updated_at !== 'string'
) {
throw new Error(`${where}: response is not the AI settings`)
}
if (!isAIProvider(body.provider)) throw new Error(`${where}: unknown AI provider "${body.provider}"`)
return {
provider: body.provider,
base_url: body.base_url,
model: body.model,
temperature: body.temperature,
system_prompt: body.system_prompt,
api_key_last4: body.api_key_last4,
key_verified_at: body.key_verified_at,
updated_at: body.updated_at,
}
}
const AI_SETTINGS_KEY = ['settings', 'ai'] as const
const AI_MODELS_KEY = ['settings', 'ai', 'models'] as const
export function useAISettings() {
const { apiFetch } = useApi()
return useQuery({
queryKey: AI_SETTINGS_KEY,
queryFn: async () => parseAISettings(`GET ${AI_SETTINGS_PATH}`, await apiFetch<unknown>(AI_SETTINGS_PATH)),
})
}
// useAIModels is the saved provider's live model list. Without a usable
// provider (no key yet, say) it fails with the server's 409 not_configured.
export function useAIModels(enabled: boolean) {
const { apiFetch } = useApi()
return useQuery({
queryKey: AI_MODELS_KEY,
enabled,
queryFn: async () => {
const body = await apiFetch<unknown>(AI_MODELS_PATH)
const models = isRecord(body) ? body.models : undefined
if (
!Array.isArray(models) ||
!models.every((m) => isRecord(m) && typeof m.id === 'string' && typeof m.display_name === 'string')
) {
throw new Error(`GET ${AI_MODELS_PATH}: response is not a model list`)
}
return models as AIModel[]
},
})
}
// useAISettingsMutation sends one AI settings change and stores the
// settings the server answers with. A change that can alter which models
// the provider offers refetches the model list.
function useAISettingsMutation<V>(path: string, method: 'POST' | 'PUT' | 'PATCH', body: (variables: V) => unknown) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (variables: V) =>
parseAISettings(`${method} ${path}`, await apiFetch<unknown>(path, { method, body: body(variables) })),
onSuccess: (settings) => {
const before = queryClient.getQueryData<AISettings>(AI_SETTINGS_KEY)
queryClient.setQueryData(AI_SETTINGS_KEY, settings)
if (
before === undefined ||
before.provider !== settings.provider ||
before.base_url !== settings.base_url ||
before.api_key_last4 !== settings.api_key_last4 ||
before.key_verified_at !== settings.key_verified_at
) {
void queryClient.invalidateQueries({ queryKey: AI_MODELS_KEY })
}
},
})
}
export function useSaveAISettings() {
return useAISettingsMutation(AI_SETTINGS_PATH, 'PATCH', (patch: AISettingsPatch) => patch)
}
// useSetAIKey stores a new API key; an empty key removes the stored one.
export function useSetAIKey() {
return useAISettingsMutation(AI_KEY_PATH, 'PUT', (apiKey: string) => ({ api_key: apiKey }))
}
export function useVerifyAIKey() {
return useAISettingsMutation(AI_VERIFY_PATH, 'POST', () => undefined)
}
export function useResetAIPrompt() {
return useAISettingsMutation(AI_RESET_PROMPT_PATH, 'POST', () => undefined)
}
// recipeSearchProviderLabels names every recipe search provider the server
// accepts (internal/recipes/searchsettings.go).
export const recipeSearchProviderLabels = {
searxng: 'SearXNG',
brave: 'Brave',
} as const
export type RecipeSearchProvider = keyof typeof recipeSearchProviderLabels
// RecipeSearchSettings is GET /api/v1/settings/recipe-search. The Brave API
// key itself is never sent: only whether one is stored and its last four
// characters.
export type RecipeSearchSettings = {
// Null until recipe search is first set up.
provider: RecipeSearchProvider | null
// The saved SearXNG instance's URL, null when none is set.
base_url: string | null
// The deployment's SearXNG URL (MEALQUEST_SEARXNG_URL) to suggest while
// base_url is null; null when the deployment names none. Only saving
// base_url stores a URL.
default_base_url: string | null
api_key_set: boolean
api_key_last4: string | null
updated_at: string | null
}
// RecipeSearchSettingsPatch is the PATCH /api/v1/settings/recipe-search
// body: only the fields that change. api_key is the Brave key in plain text.
export type RecipeSearchSettingsPatch = {
provider?: RecipeSearchProvider
base_url?: string
api_key?: string
}
// RecipeSearchTest is POST /api/v1/settings/recipe-search/test: how many
// recipes the saved provider found for query.
export type RecipeSearchTest = { query: string; count: number }
function isRecipeSearchProvider(value: string): value is RecipeSearchProvider {
return Object.hasOwn(recipeSearchProviderLabels, value)
}
function parseRecipeSearchSettings(where: string, body: unknown): RecipeSearchSettings {
if (
!isRecord(body) ||
!isStringOrNull(body.provider) ||
!isStringOrNull(body.base_url) ||
!isStringOrNull(body.default_base_url) ||
typeof body.api_key_set !== 'boolean' ||
!isStringOrNull(body.api_key_last4) ||
!isStringOrNull(body.updated_at)
) {
throw new Error(`${where}: response is not the recipe search settings`)
}
if (body.provider !== null && !isRecipeSearchProvider(body.provider)) {
throw new Error(`${where}: unknown recipe search provider "${body.provider}"`)
}
return {
provider: body.provider,
base_url: body.base_url,
default_base_url: body.default_base_url,
api_key_set: body.api_key_set,
api_key_last4: body.api_key_last4,
updated_at: body.updated_at,
}
}
const RECIPE_SEARCH_KEY = ['settings', 'recipe-search'] as const
export function useRecipeSearchSettings() {
const { apiFetch } = useApi()
return useQuery({
queryKey: RECIPE_SEARCH_KEY,
queryFn: async () =>
parseRecipeSearchSettings(`GET ${RECIPE_SEARCH_PATH}`, await apiFetch<unknown>(RECIPE_SEARCH_PATH)),
})
}
export function useSaveRecipeSearchSettings() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (patch: RecipeSearchSettingsPatch) =>
parseRecipeSearchSettings(
`PATCH ${RECIPE_SEARCH_PATH}`,
await apiFetch<unknown>(RECIPE_SEARCH_PATH, { method: 'PATCH', body: patch }),
),
onSuccess: (settings) => queryClient.setQueryData(RECIPE_SEARCH_KEY, settings),
})
}
// useTestRecipeSearch searches with the saved settings.
export function useTestRecipeSearch() {
const { apiFetch } = useApi()
return useMutation({
mutationFn: async () => {
const body = await apiFetch<unknown>(RECIPE_SEARCH_TEST_PATH, { method: 'POST' })
if (!isRecord(body) || typeof body.query !== 'string' || typeof body.count !== 'number') {
throw new Error(`POST ${RECIPE_SEARCH_TEST_PATH}: response is not a search test result`)
}
return { query: body.query, count: body.count } satisfies RecipeSearchTest
},
})
}
const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
// parseHouseholdSettings refuses a body that is not the household settings,
// or has a time that is not HH:MM, so formatClockTime never sees one.
function parseHouseholdSettings(where: string, body: unknown): HouseholdSettings {
if (!isRecord(body) || typeof body.timezone !== 'string') {
throw new Error(`${where}: response is not the household settings`)
}
const times = {} as Record<(typeof householdTimeFields)[number], string>
for (const field of householdTimeFields) {
const value = body[field]
if (typeof value !== 'string' || !clockTime.test(value)) {
throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not HH:MM`)
}
times[field] = value
}
const days = {} as Record<(typeof householdDayFields)[number], number>
for (const field of householdDayFields) {
const value = body[field]
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 6) {
throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not a day of the week (0-6)`)
}
days[field] = value
}
if (!isStringOrNull(body.onboarding_completed_at)) {
throw new Error(`${where}: onboarding_completed_at ${JSON.stringify(body.onboarding_completed_at)} is not a time or null`)
}
return { timezone: body.timezone, ...times, ...days, onboarding_completed_at: body.onboarding_completed_at }
}
export const HOUSEHOLD_SETTINGS_KEY = ['settings', 'household'] as const
export function useHouseholdSettings() {
const { apiFetch } = useApi()
return useQuery({
queryKey: HOUSEHOLD_SETTINGS_KEY,
queryFn: async () =>
parseHouseholdSettings(`GET ${HOUSEHOLD_SETTINGS_PATH}`, await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH)),
})
}
export function useSaveHouseholdSettings() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (patch: HouseholdSettingsPatch) =>
parseHouseholdSettings(
`PATCH ${HOUSEHOLD_SETTINGS_PATH}`,
await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH, { method: 'PATCH', body: patch }),
),
onSuccess: (settings) => queryClient.setQueryData(HOUSEHOLD_SETTINGS_KEY, settings),
})
}
// useCompleteOnboarding records that the household finished onboarding: the
// server sets onboarding_completed_at, which ends the first-run redirect.
export function useCompleteOnboarding() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async () =>
parseHouseholdSettings(
`PATCH ${HOUSEHOLD_SETTINGS_PATH}`,
await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH, { method: 'PATCH', body: { onboarding_completed: true } }),
),
onSuccess: (settings) => queryClient.setQueryData(HOUSEHOLD_SETTINGS_KEY, settings),
})
}
// HouseholdSettingsProblems is a refused household settings save: the reason
// for each field the server named, and any reasons that name no field.
export type HouseholdSettingsProblems = {
fields: Partial<Record<HouseholdSettingsField, string>>
other: string[]
}
const invalidHouseholdPrefix = 'invalid household settings: '
// A problem starts with a field name and ": " (internal/household
// ValidationError.Error joins them with "; ").
const problemStart = /; (?=[a-z_]+: )/
// householdSettingsProblems reads the server's invalid_settings error into
// the problems it lists. Any other error is one problem naming no field.
export function householdSettingsProblems(error: Error): HouseholdSettingsProblems {
const problems: HouseholdSettingsProblems = { fields: {}, other: [] }
if (
!(error instanceof ApiError) ||
error.code !== 'invalid_settings' ||
!error.message.startsWith(invalidHouseholdPrefix)
) {
problems.other.push(error.message)
return problems
}
for (const problem of error.message.slice(invalidHouseholdPrefix.length).split(problemStart)) {
const colon = problem.indexOf(': ')
const field = problem.slice(0, colon)
if (colon !== -1 && (householdFields as readonly string[]).includes(field)) {
problems.fields[field as HouseholdSettingsField] = problem.slice(colon + 2)
} else {
problems.other.push(problem)
}
}
return problems
}
// needsMoreRegulars reports whether error is the server refusing to finish
// onboarding because the household has fewer than three Regulars
// (internal/household MinRegulars) — the only refusal of
// {"onboarding_completed": true}.
export function needsMoreRegulars(error: Error): boolean {
return (
error instanceof ApiError &&
error.code === 'invalid_settings' &&
error.message.startsWith(`${invalidHouseholdPrefix}onboarding_completed: `)
)
}
// aiStatus is the AI assistant row's status line: the provider and the
// model's display name, or that no model is chosen yet (the seed).
export function aiStatus(settings: Pick<AISettings, 'provider' | 'model'>): string {
const provider = aiProviderLabels[settings.provider]
if (settings.model === '') return `${provider} · No model chosen`
return `${provider} · ${modelDisplayName(settings.model)}`
}
// Claude model ids, current ("claude-sonnet-4-5") and older
// ("claude-3-5-sonnet-20241022"), name their family.
const claudeModel = /^claude-(?:[0-9-]+-)?(opus|sonnet|haiku|fable)(?:-|$)/
const claudeFamilies: Record<string, string> = { opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku', fable: 'Fable' }
// modelDisplayName names a model as the handoff does ("Claude Sonnet"); a
// model with no known family is shown by its id.
export function modelDisplayName(model: string): string {
const family = claudeModel.exec(model)?.[1]
return family === undefined ? model : `Claude ${claudeFamilies[family]}`
}
// fetchUnlessAbsent GETs path and parses it, or returns null when the server
// has no such endpoint yet (404 not_found). Every other failure is an error.
async function fetchUnlessAbsent<T>(
apiFetch: ApiClient['apiFetch'],
path: string,
parse: (body: unknown) => T,
): Promise<T | null> {
let body: unknown
try {
body = await apiFetch<unknown>(path)
} catch (error) {
if (error instanceof ApiError && error.status === 404 && error.code === 'not_found') return null
throw error
}
return parse(body)
}
// useRegularDinnersCount is how many Regulars the household has, or null
// while the dinners read API does not exist.
export function useRegularDinnersCount() {
const { apiFetch } = useApi()
return useQuery({
queryKey: ['dinners', 'regular', 'count'],
queryFn: () =>
fetchUnlessAbsent(apiFetch, REGULAR_DINNERS_PATH, (body) => {
if (!isRecord(body) || !Array.isArray(body.dinners)) {
throw new Error(`GET ${REGULAR_DINNERS_PATH}: response has no dinners list`)
}
return body.dinners.length
}),
})
}
export function regularDinnersStatus(count: number | null): string {
if (count === null) return 'Rotation count unavailable'
if (count === 0) return 'None on rotation yet'
return `${count} on rotation`
}
// CalendarStatus is the Google Calendar integration status; calendar_name
// is the connected calendar's name.
export type CalendarStatus = { connected: false } | { connected: true; calendar_name: string }
export function useCalendarStatus() {
const { apiFetch } = useApi()
return useQuery({
queryKey: ['settings', 'calendar'],
queryFn: () =>
fetchUnlessAbsent(apiFetch, CALENDAR_STATUS_PATH, (body): CalendarStatus => {
if (isRecord(body) && body.connected === false) return { connected: false }
if (isRecord(body) && body.connected === true && typeof body.calendar_name === 'string') {
return { connected: true, calendar_name: body.calendar_name }
}
throw new Error(`GET ${CALENDAR_STATUS_PATH}: response is not a calendar status`)
}),
})
}
export function calendarStatus(status: CalendarStatus | null): string {
if (status === null || !status.connected) return 'Not connected'
return `Connected · ${status.calendar_name}`
}
// NotificationsStatus is whether Web Push is on and the connected Google
// Chat space's name, null when none is.
export type NotificationsStatus = { push: boolean; chat_space: string | null }
export function useNotificationsStatus() {
const { apiFetch } = useApi()
return useQuery({
queryKey: ['settings', 'notifications'],
queryFn: () =>
fetchUnlessAbsent(apiFetch, NOTIFICATIONS_STATUS_PATH, (body): NotificationsStatus => {
if (
!isRecord(body) ||
typeof body.push !== 'boolean' ||
(body.chat_space !== null && typeof body.chat_space !== 'string')
) {
throw new Error(`GET ${NOTIFICATIONS_STATUS_PATH}: response is not a notifications status`)
}
return { push: body.push, chat_space: body.chat_space }
}),
})
}
// notificationsStatus is the handoff's "Push on · Dinner Crew space".
export function notificationsStatus(status: NotificationsStatus | null): string {
const push = status?.push === true ? 'Push on' : 'Push off'
return `${push} · ${status?.chat_space ?? 'Google Chat not connected'}`
}
// maskedKey shows a stored key as dots and its last four characters, all
// the client ever knows of it.
export function maskedKey(last4: string): string {
return `••••••••${last4}`
}
// formatClockTime turns a 24-hour HH:MM time into the handoff's "5:30 pm".
export function formatClockTime(time: string): string {
const match = clockTime.exec(time)
if (match === null) throw new Error(`not a 24-hour HH:MM time: "${time}"`)
const hours = Number(match[1])
const suffix = hours < 12 ? 'am' : 'pm'
return `${hours % 12 === 0 ? 12 : hours % 12}:${match[2]} ${suffix}`
}
+46
View File
@@ -0,0 +1,46 @@
// The IANA time zones the browser knows, with their current UTC offsets, for
// the household time zone picker.
export type TimeZoneOption = {
zone: string
// "UTC-04:00"; "UTC+00:00" for UTC itself.
offset: string
}
// utcOffset is zone's offset from UTC at instant, as "UTC+05:30".
export function utcOffset(zone: string, instant: Date): string {
const name = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'longOffset' })
.formatToParts(instant)
.find((part) => part.type === 'timeZoneName')?.value
if (name === undefined) throw new Error(`no UTC offset for time zone "${zone}"`)
if (name === 'GMT') return 'UTC+00:00'
if (!/^GMT[+-]\d\d:\d\d$/.test(name)) throw new Error(`time zone "${zone}" has an unreadable offset "${name}"`)
return `UTC${name.slice(3)}`
}
// Zones the server accepts that Intl.supportedValuesOf('timeZone') leaves
// out: it lists only canonical zones, and UTC is not one of them.
const unlistedZones = ['UTC', 'Etc/UTC', 'GMT', 'Etc/GMT']
// timeZoneOptions lists every zone the browser supports, the unlisted UTC
// zones, and kept (the saved and chosen zones, which may be aliases the
// list leaves out), sorted by name.
export function timeZoneOptions(kept: string[], instant: Date): TimeZoneOption[] {
const zones = new Set([...Intl.supportedValuesOf('timeZone'), ...unlistedZones, ...kept])
return [...zones].sort().map((zone) => ({ zone, offset: utcOffset(zone, instant) }))
}
// zoneLabel reads a zone as words: "America/Argentina/Buenos_Aires" is
// "America / Argentina / Buenos Aires".
export function zoneLabel(zone: string): string {
return zone.replaceAll('_', ' ').replaceAll('/', ' / ')
}
// matchesZone is whether every word of the search text is in option's name
// or offset, in any case.
export function matchesZone(option: TimeZoneOption, search: string): boolean {
const needle = search.trim().toLowerCase()
if (needle === '') return true
const haystack = `${option.zone} ${zoneLabel(option.zone)} ${option.offset}`.toLowerCase()
return needle.split(/\s+/).every((word) => haystack.includes(word))
}
+32 -25
View File
@@ -1,9 +1,11 @@
import { render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AppRoutes } from '../App.tsx'
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
import { declaredStyle, outerSize } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { expectMinHitArea } from '../test/hitArea.ts'
import { stubFetchByPath } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import bottomBarStyles from './BottomBar.module.css'
import railStyles from './Rail.module.css'
@@ -16,15 +18,21 @@ const DESKTOP = 1440
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
vi.unstubAllGlobals()
})
function renderAt(path: string, width: number) {
async function renderAt(path: string, width: number) {
const resize = mockViewport(width)
stubFetchByPath(SETTINGS_API)
render(
<MemoryRouter initialEntries={[path]}>
<AppRoutes />
</MemoryRouter>,
<InDataRouter path={path}>
<ApiProviders>
<AppRoutes />
</ApiProviders>
</InDataRouter>,
)
// The routes wait for the household settings (OnboardingGate).
await screen.findByRole('navigation', { name: 'Primary' })
return resize
}
@@ -43,8 +51,8 @@ function current() {
}
describe('AppShell', () => {
it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', (width) => {
renderAt('/', width)
it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', async (width) => {
await renderAt('/', width)
expect(navLinks().map((link) => link.textContent)).toEqual(ORDER)
expect(navLinks().map((link) => link.getAttribute('href'))).toEqual([
'/',
@@ -55,8 +63,8 @@ describe('AppShell', () => {
])
})
it('shows only the mobile bottom bar below 768 px', () => {
renderAt('/', 767)
it('shows only the mobile bottom bar below 768 px', async () => {
await renderAt('/', 767)
expect(screen.getAllByRole('navigation')).toHaveLength(1)
expect(nav()).toHaveClass(bottomBarStyles.bar!)
expect(screen.queryByText('M')).not.toBeInTheDocument()
@@ -74,8 +82,8 @@ describe('AppShell', () => {
expect(bar.borderRadius).toBe('var(--radius-pill)')
})
it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', () => {
renderAt('/discover', MOBILE)
it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', async () => {
await renderAt('/discover', MOBILE)
const pills = nav().querySelectorAll(`.${bottomBarStyles.pill}`)
expect(pills).toHaveLength(1)
const pill = declaredStyle(pills[0]!)
@@ -95,24 +103,24 @@ describe('AppShell', () => {
expect(within(inactive).getByText('Plan')).toHaveClass(bottomBarStyles.hiddenLabel!)
})
it('makes the full 62 px bar height the mobile tap target', () => {
renderAt('/', MOBILE)
it('makes the full 62 px bar height the mobile tap target', async () => {
await renderAt('/', MOBILE)
for (const link of navLinks()) {
expect(declaredStyle(link).height).toBe('62px')
expectMinHitArea(link)
}
})
it('reserves at least 100 px below the mobile scroll area', () => {
renderAt('/', MOBILE)
it('reserves at least 100 px below the mobile scroll area', async () => {
await renderAt('/', MOBILE)
const padding = declaredStyle(screen.getByRole('main')).paddingBottom
const reserved = /^calc\((\d+)px \+ env\(safe-area-inset-bottom\)\)$/.exec(padding)
expect(reserved, `padding-bottom ${JSON.stringify(padding)}`).not.toBeNull()
expect(Number(reserved![1])).toBeGreaterThanOrEqual(100)
})
it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', (width) => {
renderAt('/plan', width)
it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', async (width) => {
await renderAt('/plan', width)
expect(screen.getAllByRole('navigation')).toHaveLength(1)
expect(nav()).toHaveClass(railStyles.rail!)
expect(declaredStyle(nav()).width).toBe('96px')
@@ -140,8 +148,8 @@ describe('AppShell', () => {
expect(screen.getByRole('link', { name: 'Home' }).firstElementChild).not.toHaveClass(railStyles.active!)
})
it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', (width) => {
renderAt('/quick', width)
it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', async (width) => {
await renderAt('/quick', width)
expect(screen.getAllByRole('navigation')).toHaveLength(1)
const sidebar = nav().closest('aside')!
expect(sidebar).toHaveClass(sidebarStyles.sidebar!)
@@ -170,8 +178,8 @@ describe('AppShell', () => {
for (const link of navLinks()) expect(declaredStyle(link).minHeight).toBe('44px')
})
it('switches layout when the viewport crosses a breakpoint', () => {
const resize = renderAt('/', MOBILE)
it('switches layout when the viewport crosses a breakpoint', async () => {
const resize = await renderAt('/', MOBILE)
expect(nav()).toHaveClass(bottomBarStyles.bar!)
resize(TABLET)
expect(nav()).toHaveClass(railStyles.rail!)
@@ -200,11 +208,10 @@ describe('AppShell', () => {
['/settings/household', ['Settings']],
['/settings/regulars', ['Settings']],
['/settings/calendar', ['Settings']],
['/onboarding', []],
['/cook-from-pantry', []],
['/dinner/abc123', []],
])('%s marks %j current', (path, expected) => {
renderAt(path, width)
])('%s marks %j current', async (path, expected) => {
await renderAt(path, width)
expect(current()).toEqual(expected)
})
})
+36 -28
View File
@@ -1,8 +1,10 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { AppRoutes } from '../App.tsx'
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { stubFetchByPath } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import styles from './BottomBar.module.css'
@@ -65,15 +67,21 @@ afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(Element.prototype, 'animate')
Reflect.deleteProperty(window, 'matchMedia')
vi.unstubAllGlobals()
})
function renderAt(path: string, reducedMotion = false) {
async function renderAt(path: string, reducedMotion = false) {
mockViewport(MOBILE, { reducedMotion })
stubFetchByPath(SETTINGS_API)
render(
<MemoryRouter initialEntries={[path]}>
<AppRoutes />
</MemoryRouter>,
<InDataRouter path={path}>
<ApiProviders>
<AppRoutes />
</ApiProviders>
</InDataRouter>,
)
// The routes wait for the household settings (OnboardingGate).
await screen.findByRole('navigation', { name: 'Primary' })
}
function nav() {
@@ -103,21 +111,21 @@ function animationsOf(element: Element) {
}
describe('BottomBar', () => {
it('places the pill on the active item without animating on first render', () => {
renderAt('/plan')
it('places the pill on the active item without animating on first render', async () => {
await renderAt('/plan')
expect(pill().style.left).toBe('54px')
expect(pill().style.width).toBe('110px')
expect(pill()).toBeVisible()
expect(animate).not.toHaveBeenCalled()
})
it('hides the pill when no item is active', () => {
renderAt('/onboarding')
it('hides the pill when no item is active', async () => {
await renderAt('/cook-from-pantry')
expect(pill()).not.toBeVisible()
})
it('slides the pill from the old item to the new one over 420 ms on the spring curve', () => {
renderAt('/')
it('slides the pill from the old item to the new one over 420 ms on the spring curve', async () => {
await renderAt('/')
fireEvent.click(link('Quick'))
expect(animationsOf(pill())).toEqual([
@@ -133,8 +141,8 @@ describe('BottomBar', () => {
expect(pill().style.width).toBe('110px')
})
it('FLIPs every item that moved from its old left to its new one with the same timing', () => {
renderAt('/')
it('FLIPs every item that moved from its old left to its new one with the same timing', async () => {
await renderAt('/')
fireEvent.click(link('Quick'))
// Home shrinks from 110 to 48 px, so Plan and Discover shift 62 px left
@@ -157,8 +165,8 @@ describe('BottomBar', () => {
expect(animationsOf(link('Settings'))).toEqual([])
})
it('fades the new label in over 220 ms ease-out after 120 ms', () => {
renderAt('/')
it('fades the new label in over 220 ms ease-out after 120 ms', async () => {
await renderAt('/')
fireEvent.click(link('Discover'))
const label = within(link('Discover')).getByText('Discover')
@@ -174,14 +182,14 @@ describe('BottomBar', () => {
])
})
it('does not animate when the active item stays the same', () => {
renderAt('/plan')
it('does not animate when the active item stays the same', async () => {
await renderAt('/plan')
fireEvent.click(link('Plan'))
expect(animate).not.toHaveBeenCalled()
})
it('jumps the pill to the new item with no animation under reduced motion', () => {
renderAt('/', true)
it('jumps the pill to the new item with no animation under reduced motion', async () => {
await renderAt('/', true)
fireEvent.click(link('Settings'))
expect(link('Settings')).toHaveAttribute('aria-current', 'page')
@@ -190,8 +198,8 @@ describe('BottomBar', () => {
expect(pill().style.width).toBe('110px')
})
it('re-places the pill when the viewport resizes', () => {
renderAt('/discover')
it('re-places the pill when the viewport resizes', async () => {
await renderAt('/discover')
expect(pill().style.left).toBe('102px')
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(120)
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96)
@@ -201,8 +209,8 @@ describe('BottomBar', () => {
expect(animate).not.toHaveBeenCalled()
})
it('starts a second slide from where the first one is on screen, not from its destination', () => {
renderAt('/')
it('starts a second slide from where the first one is on screen, not from its destination', async () => {
await renderAt('/')
fireEvent.click(link('Quick'))
const [firstPill] = runningOn(pill())
const firstPlan = runningOn(link('Plan'))[0]!
@@ -241,8 +249,8 @@ describe('BottomBar', () => {
])
})
it('stops a running slide and places the pill on the active item when the viewport resizes', () => {
renderAt('/')
it('stops a running slide and places the pill on the active item when the viewport resizes', async () => {
await renderAt('/')
fireEvent.click(link('Quick'))
const slide = [pill(), link('Plan'), link('Discover'), link('Quick')].flatMap(runningOn)
expect(slide).toHaveLength(4)
@@ -263,7 +271,7 @@ describe('BottomBar', () => {
const ready = new Promise<void>((resolve) => (fontsReady = resolve))
Object.defineProperty(document, 'fonts', { configurable: true, value: { ready } })
try {
renderAt('/discover')
await renderAt('/discover')
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(124)
fontsReady()
await ready
@@ -273,8 +281,8 @@ describe('BottomBar', () => {
}
})
it('crossfades the icon colour with a .2s colour transition', () => {
renderAt('/')
it('crossfades the icon colour with a .2s colour transition', async () => {
await renderAt('/')
for (const item of within(nav()).getAllByRole('link')) {
expect(declaredStyle(item).transition).toBe('color .2s')
}
+80
View File
@@ -0,0 +1,80 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { useState, type ReactNode } from 'react'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { errorResponse, fakeLocation, jsonResponse } from './fetchStub.ts'
// AI_SETTINGS is a GET /api/v1/settings/ai body: Anthropic with a verified key.
export const AI_SETTINGS = {
provider: 'anthropic',
base_url: '',
model: 'claude-sonnet-4-5',
temperature: 0.7,
system_prompt: 'You are MealQuest, a dinner planning assistant for one household.',
api_key_last4: '3f9a',
key_verified_at: '2026-10-01T15:00:00.000Z',
updated_at: '2026-10-01T15:00:00.000Z',
}
// AI_MODELS is a GET /api/v1/settings/ai/models body: the model AI_SETTINGS
// chooses.
export const AI_MODELS = { models: [{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }] }
// HOUSEHOLD_SETTINGS is a GET /api/v1/settings/household body: the seed
// times in New York, onboarding finished.
export const HOUSEHOLD_SETTINGS = {
timezone: 'America/New_York',
dinner_time: '17:30',
reminder_time: '16:30',
review_time: '09:00',
plan_draft_dow: 5,
plan_draft_time: '09:00',
suggestions_dow: 0,
suggestions_time: '08:00',
chat_autosend_dow: 6,
chat_autosend_time: '08:00',
onboarding_completed_at: '2026-10-01T15:30:00.000Z',
updates_seen_at: null,
updated_by: null,
created_at: '2026-10-01T15:00:00.000Z',
updated_at: '2026-10-01T15:00:00.000Z',
}
// RECIPE_SEARCH_SETTINGS is a GET /api/v1/settings/recipe-search body: the
// SearXNG sidecar, no Brave key stored.
export const RECIPE_SEARCH_SETTINGS = {
provider: 'searxng',
base_url: 'http://searxng:8080',
default_base_url: 'http://searxng:8080',
api_key_set: false,
api_key_last4: null,
updated_at: '2026-10-01T15:00:00.000Z',
}
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
// today's server does: the dinners, Google Calendar and notifications status
// endpoints do not exist yet. The home's sub-tabs stay mounted on mobile, so
// it also reads the AI models and recipe search settings.
export const SETTINGS_API = {
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'/api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
}
// ApiProviders gives the pages under test what App gives them: an API client
// on https://mealquest.test and a fresh query client.
export function ApiProviders({ children }: { children: ReactNode }) {
const [api] = useState(() =>
createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)),
)
const [queryClient] = useState(createQueryClient)
return (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ApiProvider>
)
}
+5 -1
View File
@@ -8,9 +8,13 @@ function cssRules(): CSSRule[] {
return Array.from(document.styleSheets).flatMap((sheet) => Array.from(sheet.cssRules))
}
// A selector with a pseudo-element never matches an element, and jsdom's
// matches() throws on the vendor ones (::-moz-range-track), so those rules
// are skipped.
function collect(element: Element, rules: CSSRule[], style: CSSStyleDeclaration) {
for (const rule of rules) {
if (!(rule instanceof CSSStyleRule) || !element.matches(rule.selectorText)) continue
if (!(rule instanceof CSSStyleRule) || rule.selectorText.includes('::')) continue
if (!element.matches(rule.selectorText)) continue
for (const property of Array.from(rule.style)) {
style.setProperty(property, rule.style.getPropertyValue(property))
}
+10
View File
@@ -0,0 +1,10 @@
import { useState, type ReactNode } from 'react'
import { createMemoryRouter, RouterProvider } from 'react-router'
// InDataRouter renders children under a memory data router's splat route at
// path, as main.tsx renders App, so pages that block navigation (useBlocker)
// work in tests. The router is created once, on first render.
export function InDataRouter({ path, children }: { path: string; children: ReactNode }) {
const [router] = useState(() => createMemoryRouter([{ path: '*', element: children }], { initialEntries: [path] }))
return <RouterProvider router={router} />
}
+45
View File
@@ -0,0 +1,45 @@
// DinnerBody is a dinner as the dinners API sends it.
export type DinnerBody = {
id: string
name: string
status: string
origin: string
method: string | null
minutes_total: number | null
difficulty: string | null
cuisine: string | null
is_favorite: boolean
recipe_id: string | null
recipe_url: string | null
photo_url: string | null
grounding_status: string
grounding_error: string | null
updated_by: string | null
created_at: string
updated_at: string
}
// dinnerBody is a Regular as the server sends it: grounded with no photo,
// method or minutes unless overrides set them.
export function dinnerBody(id: string, name: string, overrides: Partial<DinnerBody> = {}): DinnerBody {
return {
id,
name,
status: 'regular',
origin: 'regular',
method: null,
minutes_total: null,
difficulty: null,
cuisine: null,
is_favorite: false,
recipe_id: null,
recipe_url: null,
photo_url: null,
grounding_status: 'grounded',
grounding_error: null,
updated_by: null,
created_at: '2026-10-01T15:00:00.000Z',
updated_at: '2026-10-01T15:00:00.000Z',
...overrides,
}
}
+14
View File
@@ -47,3 +47,17 @@ export function request(fetchMock: Mock<typeof fetch>, n: number) {
export function fakeLocation(pathname = '/', search = ''): ApiLocation & { assign: Mock<(url: string | URL) => void> } {
return { origin: 'https://mealquest.test', pathname, search, assign: vi.fn<(url: string | URL) => void>() }
}
// stubFetchByPath replaces the global fetch with a mock that answers each
// path from responses with a fresh response from its factory, so requests
// sent in parallel need no order; a path not in responses fails the test.
// Undo with vi.unstubAllGlobals.
export function stubFetchByPath(responses: Record<string, () => Response>): Mock<typeof fetch> {
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const respond = Object.hasOwn(responses, String(input)) ? responses[String(input)] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${String(input)}`)
return Promise.resolve(respond())
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
+50
View File
@@ -0,0 +1,50 @@
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
}
.label {
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
.control {
position: relative;
display: block;
}
.select {
appearance: none;
box-sizing: border-box;
width: 100%;
min-width: 44px;
height: 56px;
padding: 0 36px 0 16px;
border: 1px solid var(--color-neutral-300);
border-radius: var(--radius-pill);
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-body);
font-size: 15px;
font-weight: 700;
text-overflow: ellipsis;
cursor: pointer;
}
.select:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* The chevron sits over the select's right padding and lets taps through. */
.chevron {
position: absolute;
top: 50%;
right: 14px;
transform: translateY(-50%);
pointer-events: none;
}
+38
View File
@@ -0,0 +1,38 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { Select } from './Select.tsx'
describe('Select', () => {
it('is a native select named by its label', () => {
const onChange = vi.fn()
render(
<Select label="Provider" value="a" onChange={onChange}>
<option value="a">Alpha</option>
<option value="b">Beta</option>
</Select>,
)
const select = screen.getByRole('combobox', { name: 'Provider' })
expect(select).toHaveValue('a')
fireEvent.change(select, { target: { value: 'b' } })
expect(onChange).toHaveBeenCalledOnce()
})
it('keeps a 44 px tap target', () => {
render(
<Select label="Model" defaultValue="a">
<option value="a">Alpha</option>
</Select>,
)
expectMinHitArea(screen.getByRole('combobox', { name: 'Model' }))
})
it('hides its chevron from assistive technology', () => {
const { container } = render(
<Select label="Model" defaultValue="a">
<option value="a">Alpha</option>
</Select>,
)
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true')
})
})

Some files were not shown because too many files have changed in this diff Show More