Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 f4bed5d82d Fail interrupted groundings at boot and bound hand grounding
CI / ci (push) Successful in 2m25s
CI / ci (pull_request) Successful in 2m20s
At boot, after the job runner fails the jobs a previous process left
running, FailInterruptedGroundings sets every dinner still pending with
no ground_dinner job queued or running to failed (interrupted). A crash
mid-job, or between saving a dinner and enqueuing its job, no longer
leaves the dinner pending forever.

A search failure on recipe-candidates or ground now logs only the reason
code search_failed, never the wrapped error, which may quote the
provider; the response stays redacted.

When the grounding job cannot be enqueued, POST /dinners answers 201
with the saved dinner failed (grounding_error) and grounding_job_id
null, instead of a 500 that hid the dinner it had created.

Grounding a candidate or link by hand runs under a 90-second deadline
(fetch, photo, normalize_recipe); past it the request answers 504
ground_timeout and the dinner is unchanged.

Maestro task: 0cbaa5bf-8be4-4cbd-aab2-891e7f1bc2b5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:30:53 -04:00
dev_agentandClaude Opus 5.5 d6d82e8368 Ground new Regulars in the background and add recipe fix-up API
CI / ci (push) Successful in 2m37s
CI / ci (pull_request) Successful in 2m18s
Creating a Regular now enqueues a ground_dinner job and returns at once
with its grounding_job_id. The job grounds the dinner's recipe_url with
GroundURL, else its name with Ground, and sets grounding_status grounded
(recipe, photo, method, minutes, difficulty) or failed with a
grounding_error reason code (migration 0009). It writes only a dinner
still pending, so a fix made meanwhile wins.

GET /api/v1/dinners/{id}/recipe-candidates returns up to 3 ranked
candidates {candidate_id, title, site_name, photo_url, minutes} without
downloading photos or running the model. POST /api/v1/dinners/{id}/ground
takes exactly one of {candidate_id}, {recipe_url} or {manual: {...}} and
sets grounded or manual.

PhotoCache takes an ImageFetcher interface and recipestest provides fakes
so other packages can test grounding with fake search, fetch and model.
main wires the Grounder with normalize_recipe over the AI settings.

Maestro task: 0cbaa5bf-8be4-4cbd-aab2-891e7f1bc2b5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:14:31 -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
77 changed files with 10450 additions and 161 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`):
+90 -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), 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,94 @@ 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,
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`. It only writes a dinner still `pending`, so a fix-up
made meanwhile wins. At boot, after the job runner fails the jobs a
previous process left running, every dinner still `pending` with no
`ground_dinner` job 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. 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.
- `/health` — liveness.
- Everything else serves the embedded PWA with SPA fallback.
@@ -320,7 +405,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.
+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
}
+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) {
+31 -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)
@@ -197,6 +206,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 +237,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 +261,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.ID)
}
assertIDs(t, "unarchived", unarchivedIDs, other.ID, third.ID)
_, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt})
mustNoRows(t, "ArchiveDinner missing", err)
}
+276 -9
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
`
type ArchiveDinnerParams struct {
@@ -46,6 +46,8 @@ func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (D
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
)
return i, err
}
@@ -53,14 +55,14 @@ func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (D
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
`
type CreateDinnerParams struct {
@@ -75,6 +77,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 +98,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 +123,70 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
)
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 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
)
`
type FailOrphanedPendingDinnerGroundingsParams struct {
GroundingError sql.NullString
UpdatedAt string
Kind string
}
// Fails every dinner still pending whose grounding job is no longer queued
// or running: the process stopped before the job, or before its handler
// recorded a result. 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 grounding_status = 'pending'
`
type FailPendingDinnerGroundingParams struct {
GroundingError sql.NullString
UpdatedAt string
ID string
}
// Records why the background grounding of a dinner still pending failed; a
// dinner grounded some other way 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)
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 FROM dinners
WHERE id = ?
`
@@ -148,12 +210,120 @@ func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) {
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
)
return i, err
}
const groundDinner = `-- name: GroundDinner :one
UPDATE dinners
SET recipe_id = ?,
photo_id = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
grounding_status = ?,
grounding_error = NULL,
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
`
type GroundDinnerParams struct {
RecipeID sql.NullString
PhotoID sql.NullString
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// Records a dinner's grounding (grounded or manual) and clears any
// grounding_error. An unknown id returns sql.ErrNoRows.
func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error) {
row := q.db.QueryRowContext(ctx, groundDinner,
arg.RecipeID,
arg.PhotoID,
arg.Method,
arg.MinutesTotal,
arg.Difficulty,
arg.GroundingStatus,
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,
)
return i, err
}
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
UPDATE dinners
SET recipe_id = ?,
photo_id = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
grounding_status = 'grounded',
grounding_error = NULL,
updated_at = ?
WHERE id = ? AND grounding_status = 'pending'
`
type GroundPendingDinnerParams struct {
RecipeID sql.NullString
PhotoID sql.NullString
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
UpdatedAt string
ID string
}
// Records the background grounding of a dinner still pending; a dinner
// grounded some other way meanwhile is left as it is (0 rows).
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,
)
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 FROM dinners
WHERE status = ?
ORDER BY normalized_name, id
`
@@ -184,6 +354,57 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
); 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 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 FROM dinners
WHERE status <> 'archived'
ORDER BY id
`
// Every dinner a new or renamed dinner must not repeat.
func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]Dinner, error) {
rows, err := q.db.QueryContext(ctx, listUnarchivedDinners)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Dinner
for rows.Next() {
var i Dinner
if err := rows.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,
); err != nil {
return nil, err
}
@@ -204,7 +425,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
`
type SetDinnerFavoriteParams struct {
@@ -240,6 +461,50 @@ func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoritePa
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
)
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
`
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,
)
return i, err
}
@@ -260,7 +525,7 @@ SET name = ?,
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
`
type UpdateDinnerParams struct {
@@ -316,6 +581,8 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
)
return i, err
}
+2
View File
@@ -67,6 +67,8 @@ type Dinner struct {
UpdatedBy sql.NullString
CreatedAt string
UpdatedAt string
RecipeUrl sql.NullString
GroundingError sql.NullString
}
type HouseholdSetting struct {
+21
View File
@@ -24,6 +24,9 @@ type Querier interface {
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 +34,14 @@ 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 dinner still pending whose grounding job is no longer queued
// or running: the process stopped before the job, or before its handler
// recorded a result. 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 a dinner still pending failed; a
// dinner grounded some other way 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 +62,12 @@ 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 (grounded or manual) and clears any
// grounding_error. An unknown id returns sql.ErrNoRows.
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
// Records the background grounding of a dinner still pending; a dinner
// grounded some other way meanwhile is left as it is (0 rows).
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 +77,16 @@ 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.
ListUnarchivedDinners(ctx context.Context) ([]Dinner, 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,
+535
View File
@@ -0,0 +1,535 @@
// 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"
"net/url"
"slices"
"strings"
"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]
}
// 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}
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.groundJob.Enqueue(ctx, GroundJob{DinnerID: d.ID})
if err == nil {
return Created{Dinner: d, GroundingJobID: &jobID}, nil
}
// The dinner is stored; record that it will not be grounded so it is
// not left pending with no job. The error is a database error, never a
// dependency's text.
s.logger.ErrorContext(ctx, "dinner grounding not enqueued", "dinner_id", d.ID, "error", err)
write := context.WithoutCancel(ctx)
if _, err := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
UpdatedAt: store.Now().String(),
ID: d.ID,
}); err != nil {
return Created{}, fmt.Errorf("dinners: record failed grounding of %s: %w", d.ID, err)
}
row, err := db.New(s.st.Reader).GetDinner(write, d.ID)
if err != nil {
return Created{}, fmt.Errorf("dinners: read %s: %w", d.ID, err)
}
return Created{Dinner: fromRow(row)}, nil
}
func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return 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 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 Dinner{}, err
}
name = n
case r.RecipeURL != nil:
u, err := checkRecipeURL(*r.RecipeURL)
if err != nil {
return Dinner{}, err
}
n, ok := nameFromURL(u)
if !ok {
return 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 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 Dinner{}, err
}
return fromRow(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.
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
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,
PhotoID: cur.PhotoID,
GroundingStatus: cur.GroundingStatus,
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 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)
}
return nil
})
if err != nil {
return Dinner{}, 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.
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)
}
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.
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.ID == self {
continue
}
if harness.Similar(name, row.Name) || (recipeURL.Valid && row.RecipeUrl == recipeURL) {
return &DuplicateError{Existing: fromRow(row)}
}
}
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)
}
}
}
+14
View File
@@ -0,0 +1,14 @@
package dinners
import (
"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 })
}
+497
View File
@@ -0,0 +1,497 @@
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")
// GroundJob is the KindGroundDinner payload.
type GroundJob struct {
DinnerID string `json:"dinner_id"`
}
// groundResult is the KindGroundDinner result: the dinner's grounding
// status once the job ends. It is not grounded when the dinner was
// grounded by hand before the job finished.
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, photo, method, minutes and difficulty with grounding_status
// grounded; failure records grounding_status failed with its Reason, which
// is also the job's failure code. A dinner no longer pending (grounded by
// hand meanwhile) is left as it is.
func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundResult) error) (groundResult, error) {
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.GroundingStatus != GroundingPending {
return groundResult{DinnerID: row.ID, GroundingStatus: row.GroundingStatus}, nil
}
var gr recipes.Grounded
if row.RecipeUrl.Valid {
gr, err = s.grounder.GroundURL(ctx, row.RecipeUrl.String)
} else {
gr, err = s.grounder.Ground(ctx, row.Name, row.Name)
}
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,
}); 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,
})
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: record %s: %w", row.ID, err)
}
if n == 0 {
cur, err := db.New(s.st.Reader).GetDinner(ctx, row.ID)
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", row.ID, err)
}
return groundResult{DinnerID: cur.ID, GroundingStatus: cur.GroundingStatus}, nil
}
return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil
}
// FailInterruptedGroundings fails, with ReasonInterrupted, every 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. 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. 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; a page that cannot ground the dinner is a
// *recipes.ErrUngroundable and changes nothing.
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)
}
if _, err := s.get(ctx, id); err != nil {
return Dinner{}, err
}
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("dinners: ground %s: %w", 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: id,
})
if errors.Is(err, sql.ErrNoRows) {
return Dinner{}, ErrNotFound
}
if err != nil {
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err)
}
return fromRow(row), nil
}
// groundManual stores m as dinner id's recipe, with its ingredients and
// steps, and records it on the dinner, all in one transaction.
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)
}
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,
})
if err != nil {
return fmt.Errorf("dinners: ground %s: %w", id, err)
}
return nil
})
if err != nil {
return Dinner{}, 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}
}
+595
View File
@@ -0,0 +1,595 @@
package dinners_test
import (
"context"
"errors"
"io"
"log/slog"
"slices"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"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"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const member = "member-1"
// Fixture pages: two curry recipes from search and a stew at a pasted link.
const (
curryA = "https://a.example/chicken-curry"
curryB = "https://b.example/curry"
stew = "https://c.example/recipes/beef-stew"
)
type env struct {
svc *dinners.Service
st *store.Store
runner *jobs.Runner
search *recipestest.Search
pages *recipestest.Pages
model *llm.Fake
}
// newEnv returns a dinners service over a started job runner, grounding
// with fake search, pages, images and model. Searching "Chicken curry"
// finds curryA, curryB and a missing page.
func newEnv(t *testing.T) *env {
t.Helper()
e := newUnstartedEnv(t)
e.start(t)
return e
}
// newUnstartedEnv is newEnv with its job runner not yet started, so jobs
// enqueued stay queued until start.
func newUnstartedEnv(t *testing.T) *env {
t.Helper()
dataDir := t.TempDir()
st, err := store.Open(t.Context(), dataDir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
search := &recipestest.Search{Results: map[string][]recipes.Candidate{
"Chicken curry": {{URL: curryA}, {URL: "https://missing.example/x"}, {URL: curryB}},
}}
pages := &recipestest.Pages{Bodies: map[string][]byte{
curryA: recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40),
curryB: recipestest.RecipePage("Easy Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25),
stew: recipestest.RecipePage("Beef Stew", "Stew Co", "https://c.example/stew.jpg", 150),
}}
images := &recipestest.Images{JPEGs: map[string][]byte{
"https://a.example/curry.jpg": recipestest.JPEG(t, 400, 320, 1),
"https://b.example/curry.jpg": recipestest.JPEG(t, 400, 320, 2),
"https://c.example/stew.jpg": recipestest.JPEG(t, 400, 320, 3),
}}
model := &llm.Fake{}
g := recipestest.NewGrounder(t, st, dataDir, search, pages, images, model)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
svc, err := dinners.NewService(st, g, runner, logger)
if err != nil {
t.Fatal(err)
}
return &env{svc: svc, st: st, runner: runner, search: search, pages: pages, model: model}
}
// start starts the env's job runner, stopping it when the test ends.
func (e *env) 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()
})
}
func ptr[T any](v T) *T { return &v }
// waitJob waits for job id to finish and returns it.
func (e *env) waitJob(t *testing.T, id string) jobs.Job {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
job, err := e.runner.Get(t.Context(), id)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
return job
}
if time.Now().After(deadline) {
t.Fatalf("job %s never finished; last %+v", id, job)
}
time.Sleep(5 * time.Millisecond)
}
}
func (e *env) dinner(t *testing.T, id string) db.Dinner {
t.Helper()
row, err := db.New(e.st.Reader).GetDinner(t.Context(), id)
if err != nil {
t.Fatal(err)
}
return row
}
func TestCreateGroundsInBackground(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
if c.GroundingStatus != dinners.GroundingPending || c.GroundingJobID == nil {
t.Fatalf("created = %+v, want pending with a job id", c)
}
job := e.waitJob(t, *c.GroundingJobID)
if job.Kind != dinners.KindGroundDinner || job.Status != jobs.StatusSucceeded ||
string(job.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"grounded"}` {
t.Fatalf("job = %+v (result %s)", job, job.Result)
}
row := e.dinner(t, c.ID)
if row.GroundingStatus != "grounded" || !row.RecipeID.Valid || !row.PhotoID.Valid ||
row.Method.String != "stovetop" || row.MinutesTotal.Int64 != 40 || row.Difficulty.String != "easy" || row.GroundingError.Valid {
t.Fatalf("dinner = %+v, want grounded with recipe, photo, stovetop, 40 minutes, easy", row)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != curryA || rec.PhotoID != row.PhotoID {
t.Errorf("recipe = %s photo %s, want %s photo %s", rec.SourceUrl, rec.PhotoID.String, curryA, row.PhotoID.String)
}
if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) {
t.Errorf("searched %q, want the dinner's name", got)
}
}
func TestCreateByRecipeURLGroundsThatURL(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(stew)}, member)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v", job)
}
row := e.dinner(t, c.ID)
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
if err != nil {
t.Fatal(err)
}
if row.GroundingStatus != "grounded" || rec.SourceUrl != stew || row.Method.String != "oven" || row.MinutesTotal.Int64 != 150 {
t.Fatalf("dinner = %+v recipe %s, want grounded in %s", row, rec.SourceUrl, stew)
}
if q := e.search.Queries(); len(q) != 0 {
t.Errorf("searched %q; a recipe_url is grounded directly", q)
}
}
func TestCreateGroundingFailureRecordsReason(t *testing.T) {
t.Parallel()
tests := []struct {
name string
create dinners.NewRegular
setup func(*env)
reason string
}{
{"no candidates", dinners.NewRegular{Name: ptr("Unknown dish")}, nil, dinners.ReasonNoRecipe},
{"unusable link", dinners.NewRegular{RecipeURL: ptr("https://gone.example/recipes/lost-soup")}, nil, dinners.ReasonRecipeUnusable},
{"search fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) { e.search.Err = recipes.ErrSearchRateLimited }, dinners.ReasonSearchFailed},
{"model fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) {
// One provider failure per candidate tried.
e.model.QueueGenerate(llm.GenerateResult{Err: llm.ErrAuth})
}, dinners.ReasonAIFailed},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
if tt.setup != nil {
tt.setup(e)
}
c, err := e.svc.CreateRegular(t.Context(), tt.create, member)
if err != nil {
t.Fatal(err)
}
job := e.waitJob(t, *c.GroundingJobID)
if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != tt.reason {
t.Errorf("job = %+v, want failed with %s", job, tt.reason)
}
row := e.dinner(t, c.ID)
if row.GroundingStatus != "failed" || row.GroundingError.String != tt.reason || row.RecipeID.Valid {
t.Errorf("dinner = %+v, want failed with %s", row, tt.reason)
}
})
}
}
func TestCreateReturnsBeforeGroundingCompletes(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.search.Gate = make(chan struct{})
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
// The search is held, so the job cannot have finished.
if row := e.dinner(t, c.ID); row.GroundingStatus != "pending" {
t.Fatalf("grounding_status = %s right after create, want pending", row.GroundingStatus)
}
if job, err := e.runner.Get(t.Context(), *c.GroundingJobID); err != nil || (job.Status != jobs.StatusQueued && job.Status != jobs.StatusRunning) {
t.Fatalf("job = %+v, %v; want queued or running", job, err)
}
close(e.search.Gate)
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v", job)
}
if row := e.dinner(t, c.ID); row.GroundingStatus != "grounded" {
t.Errorf("grounding_status = %s, want grounded", row.GroundingStatus)
}
}
func TestManualFixWhilePendingIsKept(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.search.Gate = make(chan struct{})
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
if _, err := e.svc.Ground(t.Context(), c.ID, dinners.Fix{Manual: &dinners.ManualRecipe{
Ingredients: []string{"1 chicken"}, Minutes: ptr(30), Method: ptr("oven"),
}}, member); err != nil {
t.Fatal(err)
}
close(e.search.Gate)
job := e.waitJob(t, *c.GroundingJobID)
if job.Status != jobs.StatusSucceeded || string(job.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"manual"}` {
t.Errorf("job = %+v (result %s), want succeeded reporting manual", job, job.Result)
}
if row := e.dinner(t, c.ID); row.GroundingStatus != "manual" || row.Method.String != "oven" {
t.Errorf("dinner = %+v, want the manual grounding kept", row)
}
}
// failedDinner creates a Regular whose background grounding fails.
func failedDinner(t *testing.T, e *env, name string) string {
t.Helper()
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr(name)}, member)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusFailed {
t.Fatalf("job = %+v, want failed", job)
}
return c.ID
}
func TestCandidates(t *testing.T) {
t.Parallel()
e := newEnv(t)
// The background grounding stores curryA with its cached photo.
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v", job)
}
got, err := e.svc.Candidates(t.Context(), c.ID)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("candidates = %+v, want the two usable pages", got)
}
first, second := got[0], got[1]
wantPhoto := dinners.PhotoURL(e.dinner(t, c.ID).PhotoID.String)
if first.CandidateID != curryA || first.Title != "Chicken Curry" || first.SiteName != "Curry House" ||
first.PhotoURL == nil || *first.PhotoURL != wantPhoto || first.Minutes == nil || *first.Minutes != 40 {
t.Errorf("first = %+v, want the stored curryA with photo %s", first, wantPhoto)
}
if second.CandidateID != curryB || second.Title != "Easy Weeknight Curry" || second.SiteName != "B Kitchen" ||
second.PhotoURL != nil || second.Minutes == nil || *second.Minutes != 25 {
t.Errorf("second = %+v, want curryB without a photo", second)
}
if _, err := e.svc.Candidates(t.Context(), "nope"); !errors.Is(err, dinners.ErrNotFound) {
t.Errorf("unknown dinner: err = %v, want ErrNotFound", err)
}
}
func TestCandidatesSearchFails(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.search.Err = recipes.ErrSearchRefused
id := failedDinner(t, e, "Chicken curry")
if _, err := e.svc.Candidates(t.Context(), id); !errors.Is(err, recipes.ErrSearchFailed) {
t.Errorf("err = %v, want ErrSearchFailed", err)
}
}
func TestCandidatesNone(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Fish tacos")
got, err := e.svc.Candidates(t.Context(), id)
if err != nil || len(got) != 0 {
t.Fatalf("no search results: candidates = %+v, %v; want none", got, err)
}
}
func TestFixViaCandidate(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(curryB)}, "member-2")
if err != nil {
t.Fatal(err)
}
if d.GroundingStatus != "grounded" || d.GroundingError != nil || d.RecipeID == nil || d.PhotoURL == nil ||
*d.Method != "stovetop" || *d.MinutesTotal != 25 || *d.Difficulty != "easy" || *d.UpdatedBy != "member-2" {
t.Fatalf("dinner = %+v", d)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), *d.RecipeID)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != curryB {
t.Errorf("recipe source = %s, want %s", rec.SourceUrl, curryB)
}
}
func TestFixViaRecipeURL(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Grandma's stew")
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr(" " + stew + " ")}, member)
if err != nil {
t.Fatal(err)
}
if d.GroundingStatus != "grounded" || d.PhotoURL == nil || *d.Method != "oven" || *d.MinutesTotal != 150 {
t.Fatalf("dinner = %+v", d)
}
// A link holding no recipe changes nothing.
before := e.dinner(t, id)
_, err = e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr("https://gone.example/x")}, member)
var ungroundable *recipes.ErrUngroundable
if !errors.As(err, &ungroundable) {
t.Fatalf("err = %v, want *recipes.ErrUngroundable", err)
}
if after := e.dinner(t, id); after != before {
t.Errorf("dinner changed by a failed fix: %+v, was %+v", after, before)
}
}
func TestFixViaManual(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Family chili")
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Difficulty: ptr("medium")}, member); err != nil {
t.Fatal(err)
}
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{Manual: &dinners.ManualRecipe{
Ingredients: []string{" 1 lb beef ", "1 can beans"},
Steps: []string{"Brown the beef.", "Simmer with the beans."},
Minutes: ptr(240),
Method: ptr("crockpot"),
}}, member)
if err != nil {
t.Fatal(err)
}
if d.GroundingStatus != "manual" || d.GroundingError != nil || d.RecipeID == nil || d.PhotoURL != nil ||
*d.Method != "crockpot" || *d.MinutesTotal != 240 || *d.Difficulty != "medium" {
t.Fatalf("dinner = %+v", d)
}
q := db.New(e.st.Reader)
rec, err := q.GetRecipe(t.Context(), *d.RecipeID)
if err != nil {
t.Fatal(err)
}
if rec.Title != "Family chili" || rec.SourceUrl != "mealquest:manual:"+rec.ID || rec.MinutesTotal.Int64 != 240 || rec.PhotoID.Valid {
t.Errorf("recipe = %+v", rec)
}
ings, err := q.ListRecipeIngredients(t.Context(), rec.ID)
if err != nil {
t.Fatal(err)
}
steps, err := q.ListRecipeSteps(t.Context(), rec.ID)
if err != nil {
t.Fatal(err)
}
if len(ings) != 2 || ings[0].RawText != "1 lb beef" || len(steps) != 2 || steps[1].Text != "Simmer with the beans." {
t.Errorf("ingredients = %+v, steps = %+v", ings, steps)
}
}
func TestFixValidation(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Pad thai")
good := func() *dinners.ManualRecipe {
return &dinners.ManualRecipe{Ingredients: []string{"noodles"}, Minutes: ptr(20), Method: ptr("stovetop")}
}
tests := map[string]dinners.Fix{
"nothing": {},
"two": {CandidateID: ptr(curryA), RecipeURL: ptr(stew)},
"candidate not URL": {CandidateID: ptr("cand-1")},
"http link": {RecipeURL: ptr("http://a.example/x")},
"no ingredients": {Manual: &dinners.ManualRecipe{Minutes: ptr(20), Method: ptr("oven")}},
"blank ingredient": {Manual: &dinners.ManualRecipe{Ingredients: []string{" "}, Minutes: ptr(20), Method: ptr("oven")}},
"blank step": {Manual: func() *dinners.ManualRecipe { m := good(); m.Steps = []string{""}; return m }()},
"no minutes": {Manual: func() *dinners.ManualRecipe { m := good(); m.Minutes = nil; return m }()},
"zero minutes": {Manual: func() *dinners.ManualRecipe { m := good(); m.Minutes = ptr(0); return m }()},
"unknown method": {Manual: func() *dinners.ManualRecipe { m := good(); m.Method = ptr("microwave"); return m }()},
"no method": {Manual: func() *dinners.ManualRecipe { m := good(); m.Method = nil; return m }()},
"too many lines": {Manual: func() *dinners.ManualRecipe { m := good(); m.Ingredients = make([]string, 101); return m }()},
"manual and a link": {RecipeURL: ptr(stew), Manual: good()},
"over-long ing line": {Manual: func() *dinners.ManualRecipe {
m := good()
m.Ingredients = []string{string(make([]byte, 501))}
return m
}()},
}
before := e.dinner(t, id)
for name, fix := range tests {
_, err := e.svc.Ground(t.Context(), id, fix, member)
var invalid *dinners.ValidationError
if !errors.As(err, &invalid) {
t.Errorf("%s: err = %v, want *ValidationError", name, err)
}
}
if after := e.dinner(t, id); after != before {
t.Errorf("dinner changed by invalid fixes: %+v", after)
}
if _, err := e.svc.Ground(t.Context(), "nope", dinners.Fix{Manual: good()}, member); !errors.Is(err, dinners.ErrNotFound) {
t.Errorf("unknown dinner, manual: err = %v, want ErrNotFound", err)
}
if _, err := e.svc.Ground(t.Context(), "nope", dinners.Fix{RecipeURL: ptr(stew)}, member); !errors.Is(err, dinners.ErrNotFound) {
t.Errorf("unknown dinner, link: err = %v, want ErrNotFound", err)
}
}
// TestRestartFailsInterruptedGrounding simulates a crash: one dinner's job
// was left running and another dinner was saved before its job was
// enqueued. At the next boot both end failed as interrupted, while a
// dinner whose job is still queued stays pending and is grounded.
func TestRestartFailsInterruptedGrounding(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.search.Gate = make(chan struct{})
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
running, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Fish pie")}, member)
if err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.ExecContext(t.Context(),
`UPDATE jobs SET status = 'running', started_at = updated_at WHERE id = ?`, *running.GroundingJobID); err != nil {
t.Fatal(err)
}
unqueued, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Lentil soup")}, member)
if err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM jobs WHERE id = ?`, *unqueued.GroundingJobID); err != nil {
t.Fatal(err)
}
queued, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
// Boot: the runner fails the running job, then the service fails the
// dinners nothing is left to ground. The search is held, so the queued
// dinner's job cannot finish meanwhile.
e.start(t)
n, err := e.svc.FailInterruptedGroundings(t.Context())
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Errorf("failed %d dinners, want 2", n)
}
if job, err := e.runner.Get(t.Context(), *running.GroundingJobID); err != nil || job.Status != jobs.StatusFailed {
t.Errorf("running job = %+v, %v; want failed", job, err)
}
for _, id := range []string{running.ID, unqueued.ID} {
if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed || row.GroundingError.String != dinners.ReasonInterrupted {
t.Errorf("dinner %s = %s (%s), want failed (interrupted)", id, row.GroundingStatus, row.GroundingError.String)
}
}
if row := e.dinner(t, queued.ID); row.GroundingStatus != dinners.GroundingPending {
t.Errorf("queued dinner = %s, want pending", row.GroundingStatus)
}
close(e.search.Gate)
if job := e.waitJob(t, *queued.GroundingJobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("queued job = %+v", job)
}
if row := e.dinner(t, queued.ID); row.GroundingStatus != dinners.GroundingGrounded {
t.Errorf("queued dinner = %s, want grounded", row.GroundingStatus)
}
}
// TestCreateEnqueueFailureReturnsFailedDinner: a dinner saved when its job
// cannot be enqueued is returned, with its id, failed and no job id.
func TestCreateEnqueueFailureReturnsFailedDinner(t *testing.T) {
t.Parallel()
e := newEnv(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)
}
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
if c.ID == "" || c.GroundingJobID != nil || c.GroundingStatus != dinners.GroundingFailed ||
c.GroundingError == nil || *c.GroundingError != dinners.ReasonGroundingError {
t.Fatalf("created = %+v, want its id, failed (grounding_error) and no job", c)
}
if row := e.dinner(t, c.ID); row.GroundingStatus != dinners.GroundingFailed {
t.Errorf("stored grounding_status = %s, want failed", row.GroundingStatus)
}
}
// TestGroundTimesOut: grounding a link by hand that runs past its deadline
// is ErrGroundTimeout and leaves the dinner as it was. It shortens the
// package deadline, so it does not run in parallel.
func TestGroundTimesOut(t *testing.T) {
dinners.SetGroundTimeout(t, 50*time.Millisecond)
e := newEnv(t)
id := failedDinner(t, e, "Beef stew")
e.pages.Gate = make(chan struct{})
defer close(e.pages.Gate)
_, err := e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr(stew)}, member)
if !errors.Is(err, dinners.ErrGroundTimeout) {
t.Fatalf("err = %v, want ErrGroundTimeout", err)
}
if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed {
t.Errorf("grounding_status = %s, want failed as before", row.GroundingStatus)
}
}
+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.
+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)
}
+103 -33
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
@@ -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
}
+170
View File
@@ -0,0 +1,170 @@
// 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{}
}
func (p *Pages) Fetch(ctx context.Context, rawURL string) (recipes.Page, error) {
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
}
// 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)
}
}
+236
View File
@@ -0,0 +1,236 @@
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.
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.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 }
+303
View File
@@ -0,0 +1,303 @@
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)
}
}
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")
}
}
+16 -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 {
@@ -103,7 +110,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 +131,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)
}
}
+1 -1
View File
@@ -216,7 +216,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" {
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", 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" {
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", 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;
+74 -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,12 @@ SELECT * FROM dinners
WHERE status = ?
ORDER BY normalized_name, id;
-- name: ListUnarchivedDinners :many
-- Every dinner a new or renamed dinner must not repeat.
SELECT * FROM dinners
WHERE status <> 'archived'
ORDER BY id;
-- name: UpdateDinner :one
-- An unknown id returns sql.ErrNoRows.
UPDATE dinners
@@ -55,3 +61,68 @@ 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 (grounded or manual) and clears any
-- grounding_error. An unknown id returns sql.ErrNoRows.
UPDATE dinners
SET recipe_id = ?,
photo_id = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
grounding_status = ?,
grounding_error = NULL,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING *;
-- name: GroundPendingDinner :execrows
-- Records the background grounding of a dinner still pending; a dinner
-- grounded some other way meanwhile is left as it is (0 rows).
UPDATE dinners
SET recipe_id = ?,
photo_id = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
grounding_status = 'grounded',
grounding_error = NULL,
updated_at = ?
WHERE id = ? AND grounding_status = 'pending';
-- name: FailPendingDinnerGrounding :execrows
-- Records why the background grounding of a dinner still pending failed; a
-- dinner grounded some other way meanwhile is left as it is (0 rows).
UPDATE dinners
SET grounding_status = 'failed',
grounding_error = ?,
updated_at = ?
WHERE id = ? AND grounding_status = 'pending';
-- name: FailOrphanedPendingDinnerGroundings :execrows
-- Fails every dinner still pending whose grounding job is no longer queued
-- or running: the process stopped before the job, or before its handler
-- recorded a result. 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 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
);
+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 *;
+49 -7
View File
@@ -1,11 +1,12 @@
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'
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
@@ -19,12 +20,15 @@ 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 = { '/settings': true, '/settings/ai': true, '/settings/household': true }
describe('App routes', () => {
it('registers every handoff route plus the extra settings pages', () => {
expect(routes.map((route) => route.path)).toEqual([
@@ -46,7 +50,7 @@ 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)
@@ -57,6 +61,44 @@ describe('App routes', () => {
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({
'/api/v1/me': () => jsonResponse(200, ME),
'/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({
'/api/v1/me': () => jsonResponse(200, ME),
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
})
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')
})
})
describe('App bootstrap', () => {
@@ -177,9 +219,9 @@ 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/)
+15 -4
View File
@@ -1,4 +1,4 @@
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'
@@ -6,6 +6,9 @@ import { AuthBootstrap } from './AuthBootstrap.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 { SettingsPage } from './settings/SettingsPage.tsx'
import { AppShell } from './shell/AppShell.tsx'
// Every route the app serves, with the heading its placeholder page shows.
@@ -27,13 +30,20 @@ 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 />,
}
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
export function AppRoutes() {
return (
<Routes>
<Route element={<AppShell />}>
{routes.map(({ path, title }) => (
<Route key={path} path={path} element={<PlaceholderPage title={title} />} />
<Route key={path} path={path} element={screens[path] ?? <PlaceholderPage title={title} />} />
))}
</Route>
</Routes>
@@ -50,8 +60,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 (
+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>,
)
+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}`
}
+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>
)
}
+546
View File
@@ -0,0 +1,546 @@
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
}
// HouseholdSettingsPatch is the PATCH /api/v1/settings/household body: only
// the fields that change.
export type HouseholdSettingsPatch = Partial<HouseholdSettings>
export type HouseholdSettingsField = keyof HouseholdSettings
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
}
return { timezone: body.timezone, ...times, ...days }
}
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),
})
}
// 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
}
// 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))
}
+11 -5
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,14 +18,18 @@ const DESKTOP = 1440
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
vi.unstubAllGlobals()
})
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>,
)
return resize
}
+10 -4
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,14 +67,18 @@ afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(Element.prototype, 'animate')
Reflect.deleteProperty(window, 'matchMedia')
vi.unstubAllGlobals()
})
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>,
)
}
+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.
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: null,
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} />
}
+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')
})
})
+27
View File
@@ -0,0 +1,27 @@
import { ChevronDown } from 'lucide-react'
import { useId, type ComponentPropsWithRef } from 'react'
import { Icon } from './Icon.tsx'
import styles from './Select.module.css'
export type SelectProps = Omit<ComponentPropsWithRef<'select'>, 'id' | 'multiple'> & {
label: string
}
// Pill dropdown: a native select on the surface fill with a chevron, always
// associated with its label. Pass the options as children.
export function Select({ label, className, children, ...rest }: SelectProps) {
const id = useId()
return (
<div className={styles.field}>
<label htmlFor={id} className={styles.label}>
{label}
</label>
<span className={styles.control}>
<select id={id} className={[styles.select, className].filter(Boolean).join(' ')} {...rest}>
{children}
</select>
<Icon icon={ChevronDown} className={styles.chevron} />
</span>
</div>
)
}
+1
View File
@@ -10,6 +10,7 @@ export { ListRow, type ListRowProps } from './ListRow.tsx'
export { Photo, type PhotoProps } from './Photo.tsx'
export { Segmented, type SegmentedOption, type SegmentedProps } from './Segmented.tsx'
export { Sheet, type SheetProps } from './Sheet.tsx'
export { Select, type SelectProps } from './Select.tsx'
export { Skeleton, type SkeletonProps } from './Skeleton.tsx'
export { Slider, type SliderProps } from './Slider.tsx'
export { Tag, type TagProps, type TagTone } from './Tag.tsx'