Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4bed5d82d | ||
|
|
d6d82e8368 | ||
|
|
729c5bd0fe | ||
|
|
0c89eef577 | ||
|
|
fcbdebd6e8 |
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ type Dinner struct {
|
||||
UpdatedBy sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
RecipeUrl sql.NullString
|
||||
GroundingError sql.NullString
|
||||
}
|
||||
|
||||
type HouseholdSetting struct {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router'
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router'
|
||||
import './styles/tokens.css'
|
||||
import './styles/base.css'
|
||||
import App from './App.tsx'
|
||||
@@ -14,10 +14,17 @@ if (!root) {
|
||||
|
||||
const api = createApiClient(window.location, createNetworkStatus(window), createVerifiedSession(window.localStorage))
|
||||
|
||||
// A data router, so pages can block navigation (useBlocker); App's own
|
||||
// <Routes> match every path under its splat route.
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '*',
|
||||
element: <App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />,
|
||||
},
|
||||
])
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />
|
||||
</BrowserRouter>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import { ApiError } from '../api/index.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Card } from '../ui/Card.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Segmented } from '../ui/Segmented.tsx'
|
||||
import { Sheet } from '../ui/Sheet.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { TextInput } from '../ui/TextInput.tsx'
|
||||
import {
|
||||
maskedKey,
|
||||
recipeSearchProviderLabels,
|
||||
useRecipeSearchSettings,
|
||||
useSaveRecipeSearchSettings,
|
||||
useTestRecipeSearch,
|
||||
type RecipeSearchProvider,
|
||||
type RecipeSearchSettings,
|
||||
type RecipeSearchSettingsPatch,
|
||||
} from './api.ts'
|
||||
import styles from './AISettingsPage.module.css'
|
||||
|
||||
const providerOptions = Object.entries(recipeSearchProviderLabels).map(([value, label]) => ({
|
||||
value: value as RecipeSearchProvider,
|
||||
label,
|
||||
}))
|
||||
|
||||
type RecipeSearchCardProps = {
|
||||
// Called with whether the card holds unsaved changes.
|
||||
onDirtyChange: (dirty: boolean) => void
|
||||
}
|
||||
|
||||
// RecipeSearchCard is where recipes are searched for: a SearXNG instance or
|
||||
// Brave. The provider, SearXNG URL and a new Brave key are edited as a draft
|
||||
// and saved together; the client only ever sees the stored key's last four
|
||||
// characters. Test searches with the saved settings.
|
||||
export function RecipeSearchCard({ onDirtyChange }: RecipeSearchCardProps) {
|
||||
const settings = useRecipeSearchSettings()
|
||||
const titleId = useId()
|
||||
return (
|
||||
<Card className={styles.card} aria-labelledby={titleId} role="region">
|
||||
<h2 id={titleId} className={styles.cardTitle}>
|
||||
Recipe search
|
||||
</h2>
|
||||
<p className={styles.hint}>Where MealQuest looks for real recipes behind each Side quest.</p>
|
||||
{settings.isPending && <Skeleton className={styles.cardLoading} />}
|
||||
{settings.isError && (
|
||||
<ErrorBanner onRetry={() => void settings.refetch()}>
|
||||
Recipe search settings could not load: {settings.error.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
{settings.isSuccess && <RecipeSearchForm saved={settings.data} onDirtyChange={onDirtyChange} />}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
type Draft = {
|
||||
provider: RecipeSearchProvider
|
||||
base_url: string
|
||||
// A new Brave key to store, null when the stored one stays.
|
||||
api_key: string | null
|
||||
}
|
||||
|
||||
// draftOf starts a household that has not set up recipe search on SearXNG,
|
||||
// the sidecar every deployment runs. With no URL saved the field holds the
|
||||
// deployment's SearXNG URL, which saving stores.
|
||||
function draftOf(saved: RecipeSearchSettings): Draft {
|
||||
return {
|
||||
provider: saved.provider ?? 'searxng',
|
||||
base_url: saved.base_url ?? saved.default_base_url ?? '',
|
||||
api_key: null,
|
||||
}
|
||||
}
|
||||
|
||||
// patchOf is the PATCH body for what draft changes. Only the chosen
|
||||
// provider's field is sent: the other provider's is left as it is.
|
||||
function patchOf(saved: RecipeSearchSettings, draft: Draft): RecipeSearchSettingsPatch {
|
||||
const patch: RecipeSearchSettingsPatch = {}
|
||||
if (draft.provider !== saved.provider) patch.provider = draft.provider
|
||||
if (draft.provider === 'searxng' && draft.base_url !== (saved.base_url ?? '')) patch.base_url = draft.base_url
|
||||
if (draft.provider === 'brave' && draft.api_key !== null) patch.api_key = draft.api_key
|
||||
return patch
|
||||
}
|
||||
|
||||
function sameDraft(a: Draft, b: Draft): boolean {
|
||||
return a.provider === b.provider && a.base_url === b.base_url && a.api_key === b.api_key
|
||||
}
|
||||
|
||||
function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSettings } & RecipeSearchCardProps) {
|
||||
const [draft, setDraft] = useState(() => draftOf(saved))
|
||||
const save = useSaveRecipeSearchSettings()
|
||||
const test = useTestRecipeSearch()
|
||||
const patch = patchOf(saved, draft)
|
||||
const dirty = Object.keys(patch).length > 0
|
||||
// Only the household's own edits are unsaved changes. Before recipe search
|
||||
// is set up the untouched draft already differs from what is saved — it
|
||||
// suggests SearXNG and the deployment's URL — but leaving it loses nothing.
|
||||
const edited = dirty && !sameDraft(draft, draftOf(saved))
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange(edited)
|
||||
}, [edited, onDirtyChange])
|
||||
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
|
||||
|
||||
// An edit clears the last save and test results: they describe the saved
|
||||
// settings, not the draft.
|
||||
function edit(change: Partial<Draft>) {
|
||||
save.reset()
|
||||
test.reset()
|
||||
setDraft((current) => ({ ...current, ...change }))
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
save.mutate(patch, {
|
||||
onSuccess: (settings) => {
|
||||
setDraft(draftOf(settings))
|
||||
test.reset()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset className={styles.lock} disabled={save.isPending}>
|
||||
<Segmented
|
||||
label="Recipe search provider"
|
||||
options={providerOptions}
|
||||
value={draft.provider}
|
||||
onValueChange={(provider) => edit({ provider })}
|
||||
/>
|
||||
{draft.provider === 'searxng' ? (
|
||||
<TextInput
|
||||
label="SearXNG URL"
|
||||
type="url"
|
||||
className={styles.mono}
|
||||
value={draft.base_url}
|
||||
placeholder="https://"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => edit({ base_url: event.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<BraveKey
|
||||
last4={saved.api_key_last4}
|
||||
newKey={draft.api_key}
|
||||
onKey={(api_key) => edit({ api_key })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.status} role="status">
|
||||
{save.isSuccess && !dirty && 'Recipe search saved.'}
|
||||
{test.isSuccess && `Found ${test.data.count} ${test.data.count === 1 ? 'recipe' : 'recipes'} for '${test.data.query}'`}
|
||||
</div>
|
||||
{save.isError && (
|
||||
<p className={styles.error} role="alert">
|
||||
{saveErrorMessage(save.error)}
|
||||
</p>
|
||||
)}
|
||||
{test.isError && (
|
||||
<p className={styles.error} role="alert">
|
||||
{testErrorMessage(test.error)}
|
||||
</p>
|
||||
)}
|
||||
{dirty && saved.provider !== null && <p className={styles.note}>Save your changes to test them.</p>}
|
||||
|
||||
<div className={styles.cardActions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={dirty || saved.provider === null || test.isPending}
|
||||
onClick={() => test.mutate()}
|
||||
>
|
||||
{test.isPending ? 'Testing…' : 'Test'}
|
||||
</Button>
|
||||
<Button disabled={!dirty || save.isPending} onClick={onSave}>
|
||||
{save.isPending ? 'Saving…' : 'Save recipe search'}
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
type BraveKeyProps = {
|
||||
last4: string | null
|
||||
newKey: string | null
|
||||
onKey: (key: string | null) => void
|
||||
}
|
||||
|
||||
// BraveKey shows the stored key masked, or the new one waiting to be saved,
|
||||
// and takes a new key in a sheet. The key is sent only when the card is
|
||||
// saved.
|
||||
function BraveKey({ last4, newKey, onKey }: BraveKeyProps) {
|
||||
const [replacing, setReplacing] = useState(false)
|
||||
const [key, setKey] = useState('')
|
||||
const labelId = useId()
|
||||
const sheetTitleId = useId()
|
||||
|
||||
function close() {
|
||||
setReplacing(false)
|
||||
setKey('')
|
||||
}
|
||||
|
||||
let shown = 'No key stored'
|
||||
if (newKey !== null) shown = maskedKey(newKey.slice(-4))
|
||||
else if (last4 !== null) shown = maskedKey(last4)
|
||||
|
||||
return (
|
||||
<div className={styles.key}>
|
||||
<span id={labelId} className={styles.label}>
|
||||
Brave API key
|
||||
</span>
|
||||
<div className={styles.keyRow} role="group" aria-labelledby={labelId}>
|
||||
<span className={styles.keyValue}>{shown}</span>
|
||||
<button type="button" className={styles.replace} onClick={() => setReplacing(true)}>
|
||||
{last4 === null && newKey === null ? 'Add key' : 'Replace'}
|
||||
</button>
|
||||
</div>
|
||||
{newKey !== null && (
|
||||
<div className={styles.keyStatus}>
|
||||
<p className={styles.note}>New key not saved yet</p>
|
||||
<button type="button" className={styles.link} onClick={() => onKey(null)}>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Sheet open={replacing} onClose={close} aria-labelledby={sheetTitleId}>
|
||||
<form
|
||||
className={styles.sheet}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
onKey(key)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<h2 id={sheetTitleId} className={styles.sheetTitle}>
|
||||
{last4 === null ? 'Add Brave API key' : 'Replace Brave API key'}
|
||||
</h2>
|
||||
<p className={styles.sheetText}>
|
||||
The key is stored on the server when you save recipe search. This device only ever sees its last four
|
||||
characters.
|
||||
</p>
|
||||
<TextInput
|
||||
label="Brave API key"
|
||||
type="password"
|
||||
className={styles.mono}
|
||||
value={key}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => setKey(event.target.value)}
|
||||
/>
|
||||
<div className={styles.sheetActions}>
|
||||
<Button variant="outline" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={key === ''}>
|
||||
Use this key
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function saveErrorMessage(error: Error): string {
|
||||
if (error instanceof ApiError && error.code === 'invalid_settings') return `Not saved: ${error.message}.`
|
||||
return `Not saved: ${error.message}`
|
||||
}
|
||||
|
||||
// testErrorMessage says why POST /settings/recipe-search/test failed, by
|
||||
// the error code the server names (internal/server/recipesearch.go).
|
||||
function testErrorMessage(error: Error): string {
|
||||
if (error instanceof ApiError && error.code === 'not_configured') {
|
||||
return 'Test failed: recipe search is not set up yet.'
|
||||
}
|
||||
return `Test failed: ${error.message}`
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user