Feature 11: Settings home and Admin AI assistant (#86)
CI / ci (push) Successful in 2m0s

Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #86.
This commit is contained in:
2026-10-06 12:45:05 -04:00
committed by approver_agent
parent f25b28b39b
commit fcbdebd6e8
54 changed files with 6712 additions and 89 deletions
+6 -2
View File
@@ -44,6 +44,9 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
# trusts no peer, which is right for local development.
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
# Optional: a SearXNG instance the Settings screen suggests as the recipe
# search URL until one is saved.
# export MEALQUEST_SEARXNG_URL=http://localhost:8888
export MEALQUEST_DEFAULT_TZ=America/New_York
make dev
```
@@ -53,8 +56,9 @@ make dev
http://localhost:5173. Configuration is environment-only and fails fast:
every variable above is required except the commented-out session
lifetimes (when set they must be positive, with MAX at least IDLE),
trusted proxy list (every entry must be an IP or CIDR) and
`MEALQUEST_DEFAULT_TZ`, and
trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL
(an absolute `http://` or `https://` URL without credentials, a query or a
fragment) and `MEALQUEST_DEFAULT_TZ`, and
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
endpoints the issuer advertises must be `https://`; plain `http://` is
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
+8 -2
View File
@@ -13,6 +13,7 @@ import (
"syscall"
"time"
"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/config"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
@@ -78,7 +79,12 @@ func run(cfg config.Config, logger *slog.Logger) error {
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
}
@@ -99,7 +105,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, runner, photos, frontend.Assets())
if err != nil {
return err
}
+4 -4
View File
@@ -68,10 +68,10 @@ MEALQUEST_SESSION_MAX=4320h
# MEALQUEST_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
# The Authentik provider's client secret.
# MEALQUEST_SEARXNG_URL=http://searxng:8080
# The SearXNG recipe-search sidecar on the compose network. Planned; not
# read by the service yet. The app takes its recipe-search provider and
# base URL from PATCH /api/v1/settings/recipe-search; see
# deploy/RUNBOOK.md "Recipe search (SearXNG)".
# The SearXNG recipe-search sidecar on the compose network. The Settings
# screen pre-fills it as the SearXNG URL until one is saved; the saved
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
# See deploy/RUNBOOK.md "Recipe search (SearXNG)".
# --- Planned; not read by the service yet -------------------------------------
#
+16 -8
View File
@@ -417,15 +417,23 @@ The `searxng` service in `deploy/compose.yaml` is a private SearXNG
instance for recipe search. On the compose network it is reachable at
`http://searxng:8080`; that is the address to configure in the app.
Starting the sidecar does not connect the app to it. The service does not
read `MEALQUEST_SEARXNG_URL`: `deploy/compose.yaml` sets it for a future
wiring, and today it does nothing. The recipe-search provider and base URL
live in the `recipe_search_settings` row and are set through
`GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the Settings
screen's recipe search; the screen itself is not built yet). Until that row
exists, recipe search answers 409 `not_configured`.
Starting the sidecar does not connect the app to it. The recipe-search
provider and base URL live in the `recipe_search_settings` row and are set
through `GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the
recipe search card on Settings → AI assistant). Until that row exists,
recipe search answers 409 `not_configured`.
After the first deploy, from a signed-in session (every non-`GET` request
`deploy/compose.yaml` sets `MEALQUEST_SEARXNG_URL=http://searxng:8080`.
The service only suggests it: `GET` returns it as `default_base_url`, and
the recipe search card pre-fills the SearXNG URL with it while no URL is
saved. Nothing is stored until the card is saved, and a saved `base_url`
always wins. A set value must be an absolute `http://` or `https://` URL
without credentials, a query or a fragment, or the service refuses to
start; unset, `default_base_url` is `null`.
To set it up, open Settings → AI assistant, check that the recipe search
card shows SearXNG with `http://searxng:8080`, select **Save recipe search**
and then **Test**. Or, from a signed-in session (every non-`GET` request
needs the `X-MealQuest-CSRF` header set to `csrfToken` from
`GET /api/v1/me`):
+26
View File
@@ -149,9 +149,35 @@ 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.
- `/health` — liveness.
- Everything else serves the embedded PWA with SPA fallback.
+478
View File
@@ -0,0 +1,478 @@
// Package aisettings reads and changes the household's AI settings — the
// ai_settings row seeded by household.SeedAISettings — and builds the
// provider and harness.Config they describe. The API key is sealed with the
// secret box and never leaves the package except to the provider.
package aisettings
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"math"
"net/url"
"strings"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/presets"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const (
// MaxSystemPromptChars is the longest system prompt accepted, in
// characters.
MaxSystemPromptChars = 8000
// MinKeyLength is the shortest API key accepted, so its last four
// characters never give away most of it.
MinKeyLength = 8
)
var (
// ErrNotConfigured is returned when the stored AI settings cannot
// build a provider, e.g. there is no API key yet. It is joined with
// the llm configuration error that says why.
ErrNotConfigured = errors.New("aisettings: AI provider is not configured")
// ErrProviderFailed is returned when a call to the provider fails. It
// is joined with the provider's error, so the llm typed error it
// matches says why.
ErrProviderFailed = errors.New("aisettings: AI provider call failed")
// ErrChanged is returned when the AI settings changed while
// their key was being verified, so the result applies to old settings.
ErrChanged = errors.New("aisettings: AI settings changed during verification")
)
// Settings is the household's AI configuration as the API shows it. The
// API key is never part of it: only its last four characters.
type Settings struct {
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
Model string `json:"model"`
Temperature float64 `json:"temperature"`
// SystemPrompt is the admin persona, nil when none is stored.
SystemPrompt *string `json:"system_prompt"`
// APIKeyLast4 is nil when no key is stored.
APIKeyLast4 *string `json:"api_key_last4"`
// KeyVerifiedAt is when the stored key was last verified with these
// provider settings, nil when it has not been.
KeyVerifiedAt *string `json:"key_verified_at"`
UpdatedAt string `json:"updated_at"`
}
// Patch changes the settings. A nil field is left as it is.
type Patch struct {
Provider *string `json:"provider"`
BaseURL *string `json:"base_url"`
Model *string `json:"model"`
Temperature *float64 `json:"temperature"`
SystemPrompt *string `json:"system_prompt"`
}
// Model is one model the provider offers.
type Model struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
}
// Problem is one invalid field, named by its JSON key.
type Problem struct {
Field string
Reason string
}
// ValidationError lists every problem with an Patch or API
// key, or with the settings it would leave. No problem quotes the key.
type ValidationError struct {
Problems []Problem
}
func (e *ValidationError) Error() string {
parts := make([]string, len(e.Problems))
for i, p := range e.Problems {
parts[i] = p.Field + ": " + p.Reason
}
return "invalid AI settings: " + strings.Join(parts, "; ")
}
// Service reads and changes the ai_settings row and builds the provider it
// names. API keys are sealed with its secret box and never leave it except
// to the provider.
type Service struct {
st *store.Store
box *secretbox.Box
publicURL string
// newProvider and newAccount are presets.FromSettings and
// presets.ForAccount; tests substitute a fake provider.
newProvider harness.NewProvider
newAccount func(llm.Settings, string) (presets.Account, error)
}
// New returns the service over st, sealing keys with box.
// publicURL is MEALQUEST_PUBLIC_URL, which some presets send.
func New(st *store.Store, box *secretbox.Box, publicURL string) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("aisettings: nil store")
case box == nil:
return nil, errors.New("aisettings: nil secret box")
case publicURL == "":
return nil, errors.New("aisettings: empty public URL")
}
return &Service{st: st, box: box, publicURL: publicURL, newProvider: presets.FromSettings, newAccount: presets.ForAccount}, nil
}
// Get returns the settings.
func (s *Service) Get(ctx context.Context) (Settings, error) {
row, err := s.read(ctx, db.New(s.st.Reader))
if err != nil {
return Settings{}, err
}
return settingsFrom(row), nil
}
// Update applies patch and returns the settings it leaves. The provider
// and base URL cannot change while an API key is stored, so the key never
// reaches an endpoint it was not stored for. Every problem is reported in
// a *ValidationError and nothing changes.
func (s *Service) Update(ctx context.Context, patch Patch) (Settings, error) {
return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) {
return applyPatch(row, patch)
})
}
// SetAPIKey seals key and records its last four characters, clearing
// key_verified_at. An empty key removes the stored one. A preset without
// a fixed base URL takes a key only when its base URL is secure (see
// secureurl.Check).
func (s *Service) SetAPIKey(ctx context.Context, key string) (Settings, error) {
return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) {
row.ApiKeyCt, row.ApiKeyLast4, row.KeyVerifiedAt = nil, sql.NullString{}, sql.NullString{}
if key == "" {
return row, nil
}
p, _ := presets.Lookup(row.Provider)
var reason string
switch {
case p.Key == presets.KeyNone:
reason = row.Provider + " takes no API key"
case p.BaseURL == "" && !secureBaseURL(row.BaseUrl):
reason = "is sent only over https://, or http:// to localhost: set such a base_url first"
case !validHeaderValue(key):
reason = "has a character not allowed in a header"
case len(key) < MinKeyLength:
reason = fmt.Sprintf("must be at least %d characters", MinKeyLength)
}
if reason != "" {
return db.AiSetting{}, &ValidationError{Problems: []Problem{{Field: "api_key", Reason: reason}}}
}
ct, err := s.box.Encrypt([]byte(key))
if err != nil {
return db.AiSetting{}, fmt.Errorf("aisettings: seal AI API key: %w", err)
}
row.ApiKeyCt = ct
row.ApiKeyLast4 = sql.NullString{String: key[len(key)-4:], Valid: true}
return row, nil
})
}
// ResetPrompt restores household.DefaultPersonaPrompt as the system prompt.
func (s *Service) ResetPrompt(ctx context.Context) (Settings, error) {
return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) {
row.SystemPrompt = sql.NullString{String: household.DefaultPersonaPrompt, Valid: true}
return row, nil
})
}
// Verify checks the stored key with the provider and, when it is
// accepted, records key_verified_at. Settings that cannot build a
// provider fail with ErrNotConfigured; a provider failure matches
// ErrProviderFailed and the llm typed error the provider returned. If the
// settings change while the provider is asked, nothing is recorded and the
// error is ErrChanged.
func (s *Service) Verify(ctx context.Context) (Settings, error) {
row, err := s.read(ctx, db.New(s.st.Reader))
if err != nil {
return Settings{}, err
}
acct, key, err := s.account(row)
if err != nil {
return Settings{}, err
}
if err := acct.Verify(ctx); err != nil {
return Settings{}, redact(fmt.Errorf("%w: verify: %w", ErrProviderFailed, err), key)
}
verified := store.Now().String()
return s.change(ctx, func(now db.AiSetting) (db.AiSetting, error) {
if now.Provider != row.Provider || now.BaseUrl != row.BaseUrl || !bytes.Equal(now.ApiKeyCt, row.ApiKeyCt) {
return db.AiSetting{}, ErrChanged
}
now.KeyVerifiedAt = sql.NullString{String: verified, Valid: true}
return now, nil
})
}
// Models returns the provider's live model list with the API key removed
// from every model's ID and display name. Its errors are Verify's.
func (s *Service) Models(ctx context.Context) ([]Model, error) {
row, err := s.read(ctx, db.New(s.st.Reader))
if err != nil {
return nil, err
}
acct, key, err := s.account(row)
if err != nil {
return nil, err
}
models, err := acct.ListModels(ctx)
if err != nil {
return nil, redact(fmt.Errorf("%w: list models: %w", ErrProviderFailed, err), key)
}
out := make([]Model, len(models))
for i, m := range models {
out[i] = Model{ID: llm.Redact(m.ID, key), DisplayName: llm.Redact(m.DisplayName, key)}
}
return out, nil
}
// HarnessConfig returns the harness.Config for a skill run with the
// current settings, sharing guard. Read it for each run, so a run always
// uses the latest saved provider, model, temperature and persona.
func (s *Service) HarnessConfig(ctx context.Context, guard *harness.Guard) (harness.Config, error) {
row, err := s.read(ctx, db.New(s.st.Reader))
if err != nil {
return harness.Config{}, err
}
key, err := s.key(row)
if err != nil {
return harness.Config{}, err
}
return harness.Config{
Settings: s.llmSettings(row),
APIKey: key,
NewProvider: s.newProvider,
Persona: row.SystemPrompt.String,
Store: s.st,
Guard: guard,
}, nil
}
// account builds the provider's Account from row, returning the key too
// so the caller can redact it.
func (s *Service) account(row db.AiSetting) (presets.Account, string, error) {
key, err := s.key(row)
if err != nil {
return nil, "", err
}
acct, err := s.newAccount(s.llmSettings(row), key)
if err != nil {
return nil, "", redact(fmt.Errorf("%w: %w", ErrNotConfigured, err), key)
}
return acct, key, nil
}
func (s *Service) llmSettings(row db.AiSetting) llm.Settings {
return llm.Settings{
Provider: row.Provider,
BaseURL: row.BaseUrl,
Model: row.Model,
Temperature: row.Temperature,
PublicURL: s.publicURL,
}
}
// key opens row's API key; "" when none is stored.
func (s *Service) key(row db.AiSetting) (string, error) {
if len(row.ApiKeyCt) == 0 {
return "", nil
}
key, err := s.box.Decrypt(row.ApiKeyCt)
if err != nil {
return "", fmt.Errorf("aisettings: open AI API key: %w", err)
}
return string(key), nil
}
func (s *Service) read(ctx context.Context, q *db.Queries) (db.AiSetting, error) {
row, err := q.GetAISettings(ctx)
if errors.Is(err, sql.ErrNoRows) {
return db.AiSetting{}, errors.New("aisettings: AI settings are not seeded")
}
if err != nil {
return db.AiSetting{}, fmt.Errorf("aisettings: read AI settings: %w", err)
}
return row, nil
}
// change saves edit's result for the current row in one transaction.
func (s *Service) change(ctx context.Context, edit func(db.AiSetting) (db.AiSetting, error)) (Settings, error) {
var saved db.AiSetting
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
row, err := s.read(ctx, q)
if err != nil {
return err
}
next, err := edit(row)
if err != nil {
return err
}
saved, err = q.UpsertAISettings(ctx, db.UpsertAISettingsParams{
Provider: next.Provider,
BaseUrl: next.BaseUrl,
Model: next.Model,
Temperature: next.Temperature,
SystemPrompt: next.SystemPrompt,
ApiKeyCt: next.ApiKeyCt,
ApiKeyLast4: next.ApiKeyLast4,
KeyVerifiedAt: next.KeyVerifiedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: store.Now().String(),
})
if err != nil {
return fmt.Errorf("aisettings: save AI settings: %w", err)
}
return nil
})
if err != nil {
return Settings{}, err
}
return settingsFrom(saved), nil
}
// applyPatch returns row with patch applied, validated.
func applyPatch(row db.AiSetting, patch Patch) (db.AiSetting, error) {
var problems []Problem
fail := func(field, reason string) {
problems = append(problems, Problem{Field: field, Reason: reason})
}
if patch == (Patch{}) {
fail("settings", "no settings to change")
}
before := row
if patch.Provider != nil {
if _, ok := presets.Lookup(*patch.Provider); ok {
row.Provider = *patch.Provider
} else {
fail("provider", "must be one of "+strings.Join(presets.Names(), ", "))
}
}
if patch.BaseURL != nil {
row.BaseUrl = *patch.BaseURL
}
if patch.Model != nil {
switch m := *patch.Model; {
case strings.TrimSpace(m) == "":
fail("model", "must not be empty")
case !validHeaderValue(m):
fail("model", "must not contain control characters")
default:
row.Model = m
}
}
if patch.Temperature != nil {
t := *patch.Temperature
tenths := math.Round(t * 10)
if t < 0 || t > 1 || math.Abs(t*10-tenths) > 1e-9 {
fail("temperature", "must be between 0 and 1 in steps of 0.1")
} else {
row.Temperature = tenths / 10
}
}
if patch.SystemPrompt != nil {
if n := utf8.RuneCountInString(*patch.SystemPrompt); n > MaxSystemPromptChars {
fail("system_prompt", fmt.Sprintf("must be at most %d characters, got %d", MaxSystemPromptChars, n))
} else {
row.SystemPrompt = sql.NullString{String: *patch.SystemPrompt, Valid: true}
}
}
if p, ok := presets.Lookup(row.Provider); ok && len(problems) == 0 {
switch {
case p.BaseURL != "" && row.BaseUrl != "" && row.BaseUrl != p.BaseURL:
fail("base_url", fmt.Sprintf("is fixed for %s: must be empty or %s", p.Name, p.BaseURL))
case p.BaseURL == "" && row.BaseUrl != "":
// The preset's own check of the URL; its errors never quote it.
if _, err := presets.ForAccount(llm.Settings{Provider: p.Name, BaseURL: row.BaseUrl}, ""); err != nil {
fail("base_url", err.Error())
}
}
}
// The stored key is only ever sent where it was stored for: moving it
// to another provider or server would let a changed base URL receive
// it. Remove the key, change the endpoint, then store the new key.
if len(problems) == 0 && len(row.ApiKeyCt) > 0 {
if row.Provider != before.Provider {
fail("provider", "cannot change while an API key is stored: remove the key first")
}
if row.BaseUrl != before.BaseUrl {
fail("base_url", "cannot change while an API key is stored: remove the key first")
}
}
if len(problems) > 0 {
return db.AiSetting{}, &ValidationError{Problems: problems}
}
return row, nil
}
func settingsFrom(row db.AiSetting) Settings {
return Settings{
Provider: row.Provider,
BaseURL: row.BaseUrl,
Model: row.Model,
Temperature: row.Temperature,
SystemPrompt: nullString(row.SystemPrompt),
APIKeyLast4: nullString(row.ApiKeyLast4),
KeyVerifiedAt: nullString(row.KeyVerifiedAt),
UpdatedAt: row.UpdatedAt,
}
}
func nullString(s sql.NullString) *string {
if !s.Valid {
return nil
}
return &s.String
}
// secureBaseURL reports whether base is a URL a key may be sent to.
func secureBaseURL(base string) bool {
u, err := url.Parse(base)
return base != "" && err == nil && secureurl.Check(u) == nil
}
// validHeaderValue reports whether value has no control character other
// than tab.
func validHeaderValue(value string) bool {
for i := 0; i < len(value); i++ {
if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f {
return false
}
}
return true
}
// redactedError is an error with the API key removed from its text. It
// does not unwrap, because the original may hold the key; Is still matches
// the sentinels the original wraps.
type redactedError struct {
text string
err error
}
func (e *redactedError) Error() string { return e.text }
// Is reports whether the original error matches target.
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
// redact returns err with key removed from its text; an empty key returns
// err unchanged.
func redact(err error, key string) error {
if key == "" {
return err
}
return &redactedError{text: llm.Redact(err.Error(), key), err: err}
}
+463
View File
@@ -0,0 +1,463 @@
package aisettings
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"reflect"
"slices"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/presets"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const testKey = "sk-ant-test-secret-key-41ab"
// newService returns a Service over a migrated store with the seed AI
// settings.
func newService(t *testing.T) (*Service, *store.Store) {
t.Helper()
ctx := context.Background()
st, err := store.Open(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if err := household.SeedAISettings(ctx, st); err != nil {
t.Fatal(err)
}
raw := make([]byte, secretbox.KeySize)
rand.Read(raw)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(raw))
if err != nil {
t.Fatal(err)
}
s, err := New(st, box, "https://mealquest.example")
if err != nil {
t.Fatal(err)
}
return s, st
}
// useFake makes s build f for every run, verification and model list, and
// returns a func reporting the settings and key each build was given.
func useFake(s *Service, f *llm.Fake) func() ([]llm.Settings, []string) {
var mu sync.Mutex
var settings []llm.Settings
var keys []string
record := func(set llm.Settings, key string) {
mu.Lock()
defer mu.Unlock()
settings = append(settings, set)
keys = append(keys, key)
}
s.newProvider = func(set llm.Settings, key string) (llm.Provider, error) {
record(set, key)
return f, nil
}
s.newAccount = func(set llm.Settings, key string) (presets.Account, error) {
record(set, key)
return f, nil
}
return func() ([]llm.Settings, []string) {
mu.Lock()
defer mu.Unlock()
return settings, keys
}
}
func ptr[T any](v T) *T { return &v }
// echoSkill asks the model to repeat a word; its prompt is the harness's
// test template prompts/echo.v1.tmpl.
type echoSkill struct{}
type echoInput struct{ Word string }
type echoOutput struct {
Echo string `json:"echo"`
}
func (echoSkill) Name() string { return "echo" }
func (echoSkill) Version() int { return 1 }
func (echoSkill) PromptInput(in echoInput) (harness.PromptInput, error) {
return harness.PromptInput{Data: in}, nil
}
func (echoSkill) Schema() llm.JSONSchema {
return llm.JSONSchema{Name: "echo", Schema: json.RawMessage(
`{"type":"object","properties":{"echo":{"type":"string"}},"required":["echo"],"additionalProperties":false}`)}
}
func (echoSkill) Validate(echoOutput) []string { return nil }
func (echoSkill) PostProcess(out echoOutput) (echoOutput, error) { return out, nil }
func (echoSkill) Timeout() time.Duration { return harness.DefaultTimeout }
// A PATCHed model and temperature are what the next skill run uses: the
// provider is built with them, the request carries the temperature and the
// ai_runs row records both. A second PATCH changes the run after it.
func TestPatchedModelAndTemperatureUsedByNextRun(t *testing.T) {
ctx := context.Background()
s, st := newService(t)
f := &llm.Fake{}
built := useFake(s, f)
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
t.Fatal(err)
}
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
t.Fatal(err)
}
for i, want := range []struct {
model string
temp float64
}{{"claude-patched", 0.3}, {"claude-repatched", 1}} {
if _, err := s.Update(ctx, Patch{Model: ptr(want.model), Temperature: ptr(want.temp)}); err != nil {
t.Fatal(err)
}
cfg, err := s.HarnessConfig(ctx, guard)
if err != nil {
t.Fatal(err)
}
f.QueueGenerate(llm.GenerateResult{Response: llm.Response{JSON: json.RawMessage(`{"echo":"hi"}`)}})
if _, err := harness.Run(ctx, cfg, echoSkill{}, "test", echoInput{Word: "hi"}); err != nil {
t.Fatalf("run %d: %v", i, err)
}
settings, keys := built()
if got := settings[i]; got.Provider != "anthropic" || got.Model != want.model || got.Temperature != want.temp || keys[i] != testKey {
t.Errorf("run %d provider built with %+v, key ok %v; want model %s at %v", i, got, keys[i] == testKey, want.model, want.temp)
}
req := f.Requests()[i]
if req.Temperature == nil || *req.Temperature != want.temp {
t.Errorf("run %d request temperature = %v, want %v", i, req.Temperature, want.temp)
}
if !strings.HasPrefix(req.System, household.DefaultPersonaPrompt[:40]) {
t.Errorf("run %d system prompt does not start with the persona: %.80q", i, req.System)
}
var model string
var temp float64
err = st.Reader.QueryRowContext(ctx, `SELECT model, temperature FROM ai_runs ORDER BY created_at DESC, rowid DESC LIMIT 1`).Scan(&model, &temp)
if err != nil {
t.Fatal(err)
}
if model != want.model || temp != want.temp {
t.Errorf("run %d ai_runs row = %s at %v, want %s at %v", i, model, temp, want.model, want.temp)
}
}
}
func TestUpdateValidates(t *testing.T) {
ctx := context.Background()
for _, c := range []struct {
name string
patch Patch
field string
}{
{"empty", Patch{}, "settings"},
{"unknown provider", Patch{Provider: ptr("gemini")}, "provider"},
{"temperature above 1", Patch{Temperature: ptr(1.1)}, "temperature"},
{"negative temperature", Patch{Temperature: ptr(-0.1)}, "temperature"},
{"temperature between steps", Patch{Temperature: ptr(0.35)}, "temperature"},
{"empty model", Patch{Model: ptr(" ")}, "model"},
{"long system prompt", Patch{SystemPrompt: ptr(strings.Repeat("ü", MaxSystemPromptChars+1))}, "system_prompt"},
{"other base URL for a fixed preset", Patch{BaseURL: ptr("https://api.example/v1")}, "base_url"},
{"bad base URL", Patch{Provider: ptr("ollama"), BaseURL: ptr("ftp://ollama:11434")}, "base_url"},
} {
t.Run(c.name, func(t *testing.T) {
s, _ := newService(t)
before, err := s.Get(ctx)
if err != nil {
t.Fatal(err)
}
_, err = s.Update(ctx, c.patch)
var invalid *ValidationError
if !errors.As(err, &invalid) || len(invalid.Problems) != 1 || invalid.Problems[0].Field != c.field {
t.Fatalf("Update = %v, want one %s problem", err, c.field)
}
if after, err := s.Get(ctx); err != nil || !reflect.DeepEqual(after, before) {
t.Errorf("settings changed by a refused patch: %+v, %v", after, err)
}
})
}
}
func TestUpdateAccepts(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
for _, temp := range []float64{0, 0.1, 0.2, 0.3, 0.6, 0.7, 0.9, 1} {
got, err := s.Update(ctx, Patch{Temperature: ptr(temp)})
if err != nil || got.Temperature != temp {
t.Errorf("temperature %v = %v, %v", temp, got.Temperature, err)
}
}
prompt := strings.Repeat("ü", MaxSystemPromptChars)
if got, err := s.Update(ctx, Patch{SystemPrompt: &prompt}); err != nil || *got.SystemPrompt != prompt {
t.Errorf("system prompt of %d characters: %v", MaxSystemPromptChars, err)
}
for _, p := range presets.Names() {
patch := Patch{Provider: ptr(p), BaseURL: ptr("")}
if pr, _ := presets.Lookup(p); pr.BaseURL == "" {
patch.BaseURL = ptr("http://llm.local:11434")
}
if got, err := s.Update(ctx, patch); err != nil || got.Provider != p {
t.Errorf("provider %s: %+v, %v", p, got, err)
}
}
}
// While a key is stored the provider and base URL cannot change, so the
// key never reaches an endpoint it was not stored for; the model,
// temperature and system prompt can, keeping the verification.
func TestEndpointLockedWhileKeyStored(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
f := &llm.Fake{}
useFake(s, f)
if _, err := s.Update(ctx, Patch{Provider: ptr("openai_compatible"), BaseURL: ptr("https://llm.example/v1")}); err != nil {
t.Fatal(err)
}
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
t.Fatal(err)
}
f.QueueVerify(nil)
if got, err := s.Verify(ctx); err != nil || got.KeyVerifiedAt == nil {
t.Fatalf("Verify = %+v, %v", got, err)
}
got, err := s.Update(ctx, Patch{Model: ptr("m"), Temperature: ptr(0.5), SystemPrompt: ptr("p")})
if err != nil || got.KeyVerifiedAt == nil {
t.Errorf("model change = %+v, %v; want verification kept", got, err)
}
before, err := s.Get(ctx)
if err != nil {
t.Fatal(err)
}
for _, c := range []struct {
patch Patch
field string
}{
{Patch{BaseURL: ptr("https://attacker.example/v1")}, "base_url"},
{Patch{Provider: ptr("openai"), BaseURL: ptr("")}, "provider"},
{Patch{Provider: ptr("ollama"), BaseURL: ptr("http://localhost:11434")}, "provider"},
} {
var invalid *ValidationError
_, err := s.Update(ctx, c.patch)
if !errors.As(err, &invalid) || !slices.ContainsFunc(invalid.Problems, func(p Problem) bool { return p.Field == c.field }) {
t.Errorf("Update(%+v) with a key stored = %v, want a %s problem", c.patch, err, c.field)
}
}
if after, err := s.Get(ctx); err != nil || !reflect.DeepEqual(after, before) {
t.Errorf("refused endpoint change altered the settings: %+v, %v", after, err)
}
// Removing the key frees the endpoint; a new key is unverified.
if _, err := s.SetAPIKey(ctx, ""); err != nil {
t.Fatal(err)
}
if _, err := s.Update(ctx, Patch{Provider: ptr("openai"), BaseURL: ptr("")}); err != nil {
t.Fatal(err)
}
got, err = s.SetAPIKey(ctx, "sk-another-key-9999")
if err != nil || got.KeyVerifiedAt != nil || *got.APIKeyLast4 != "9999" {
t.Errorf("new key = %+v, %v; want it unverified", got, err)
}
}
// A key is never stored for a base URL it would cross the network to in
// plain text.
func TestSetAPIKeyNeedsSecureBaseURL(t *testing.T) {
ctx := context.Background()
for _, c := range []struct {
base string
ok bool
}{
{"", false},
{"http://llm.lan:8080/v1", false},
{"http://127.0.0.1:8080/v1", true},
{"https://llm.example/v1", true},
} {
s, _ := newService(t)
patch := Patch{Provider: ptr("openai_compatible")}
if c.base != "" {
patch.BaseURL = ptr(c.base)
}
if _, err := s.Update(ctx, patch); err != nil {
t.Fatal(err)
}
_, err := s.SetAPIKey(ctx, testKey)
var invalid *ValidationError
if c.ok != (err == nil) || (!c.ok && !errors.As(err, &invalid)) {
t.Errorf("SetAPIKey with base URL %q = %v, want ok %v", c.base, err, c.ok)
}
}
}
func TestVerifyReturnsProviderTypedError(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
f := &llm.Fake{}
useFake(s, f)
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
t.Fatal(err)
}
for _, want := range []error{llm.ErrAuth, llm.NewRateLimitedError(time.Second, testKey), llm.NewProviderError(500, []byte("bad "+testKey), testKey)} {
f.QueueVerify(want)
_, err := s.Verify(ctx)
if !errors.Is(err, ErrProviderFailed) || !errors.Is(err, want) {
t.Errorf("Verify = %v, want ErrProviderFailed and %v", err, want)
}
if err != nil && strings.Contains(err.Error(), testKey) {
t.Errorf("Verify error holds the key: %v", err)
}
}
if got, err := s.Get(ctx); err != nil || got.KeyVerifiedAt != nil {
t.Errorf("failed verification recorded: %+v, %v", got, err)
}
}
// A key error that quotes the key, as a misbehaving adapter might, is
// redacted by the service.
func TestVerifyRedactsKey(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
f := &llm.Fake{}
useFake(s, f)
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
t.Fatal(err)
}
f.QueueVerify(errors.Join(llm.ErrAuth, errors.New("rejected "+testKey)))
_, err := s.Verify(ctx)
if !errors.Is(err, llm.ErrAuth) || strings.Contains(err.Error(), testKey) {
t.Errorf("Verify = %v, want ErrAuth without the key", err)
}
f.QueueListModels(llm.ListModelsResult{Err: errors.New("rejected " + testKey)})
if _, err := s.Models(ctx); !errors.Is(err, ErrProviderFailed) || strings.Contains(err.Error(), testKey) {
t.Errorf("Models = %v, want ErrProviderFailed without the key", err)
}
f.QueueListModels(llm.ListModelsResult{Models: []llm.Model{{ID: "m-" + testKey, DisplayName: "Model " + testKey}}})
models, err := s.Models(ctx)
if err != nil || len(models) != 1 || models[0] != (Model{ID: "m-[REDACTED]", DisplayName: "Model [REDACTED]"}) {
t.Errorf("Models = %+v, %v, want the key redacted", models, err)
}
}
// Settings changed while the provider verifies the key are not marked
// verified.
func TestVerifyRefusesChangedSettings(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
t.Fatal(err)
}
s.newAccount = func(llm.Settings, string) (presets.Account, error) {
return changingAccount{s: s}, nil
}
if _, err := s.Verify(ctx); !errors.Is(err, ErrChanged) {
t.Fatalf("Verify = %v, want ErrChanged", err)
}
if got, err := s.Get(ctx); err != nil || got.KeyVerifiedAt != nil || *got.APIKeyLast4 != "5678" {
t.Errorf("settings after a changed verification = %+v, %v", got, err)
}
}
// changingAccount replaces the stored key while it verifies.
type changingAccount struct{ s *Service }
func (a changingAccount) Verify(ctx context.Context) error {
_, err := a.s.SetAPIKey(ctx, "sk-replaced-5678")
return err
}
func (changingAccount) ListModels(context.Context) ([]llm.Model, error) { return nil, nil }
func TestNotConfigured(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
// The seed is anthropic with no key; the real presets refuse it.
if _, err := s.Verify(ctx); !errors.Is(err, ErrNotConfigured) || !errors.Is(err, llm.ErrMissingKey) {
t.Errorf("Verify = %v, want ErrNotConfigured and ErrMissingKey", err)
}
if _, err := s.Models(ctx); !errors.Is(err, ErrNotConfigured) {
t.Errorf("Models = %v, want ErrNotConfigured", err)
}
}
func TestSetAPIKey(t *testing.T) {
ctx := context.Background()
s, st := newService(t)
got, err := s.SetAPIKey(ctx, testKey)
if err != nil || got.APIKeyLast4 == nil || *got.APIKeyLast4 != "41ab" {
t.Fatalf("SetAPIKey = %+v, %v", got, err)
}
var ct []byte
if err := st.Reader.QueryRowContext(ctx, `SELECT api_key_ct FROM ai_settings`).Scan(&ct); err != nil {
t.Fatal(err)
}
if strings.Contains(string(ct), testKey) {
t.Error("the key is stored in plain text")
}
if key, err := s.key(mustRow(t, s)); err != nil || key != testKey {
t.Errorf("stored key opens to %q, %v", key, err)
}
for _, bad := range []string{"short", "sk-bad\r\nkey"} {
var invalid *ValidationError
if _, err := s.SetAPIKey(ctx, bad); !errors.As(err, &invalid) {
t.Errorf("SetAPIKey(%q) = %v, want a ValidationError", bad, err)
}
}
// Ollama takes no key: neither storing one nor switching to it with one.
var invalid *ValidationError
if _, err := s.Update(ctx, Patch{Provider: ptr("ollama"), BaseURL: ptr("http://ollama:11434")}); !errors.As(err, &invalid) {
t.Errorf("switch to ollama with a key = %v, want a ValidationError", err)
}
if got, err := s.SetAPIKey(ctx, ""); err != nil || got.APIKeyLast4 != nil {
t.Fatalf("remove key = %+v, %v", got, err)
}
if _, err := s.Update(ctx, Patch{Provider: ptr("ollama"), BaseURL: ptr("http://ollama:11434")}); err != nil {
t.Fatal(err)
}
if _, err := s.SetAPIKey(ctx, testKey); !errors.As(err, &invalid) {
t.Errorf("SetAPIKey for ollama = %v, want a ValidationError", err)
}
}
func TestResetPrompt(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
if _, err := s.Update(ctx, Patch{SystemPrompt: ptr("")}); err != nil {
t.Fatal(err)
}
got, err := s.ResetPrompt(ctx)
if err != nil || got.SystemPrompt == nil || *got.SystemPrompt != household.DefaultPersonaPrompt {
t.Errorf("ResetPrompt = %+v, %v", got, err)
}
}
func mustRow(t *testing.T, s *Service) db.AiSetting {
t.Helper()
row, err := s.read(context.Background(), db.New(s.st.Reader))
if err != nil {
t.Fatal(err)
}
return row
}
+34 -6
View File
@@ -15,6 +15,7 @@ import (
"context"
"errors"
"fmt"
"maps"
"math"
"regexp"
"slices"
@@ -115,6 +116,11 @@ func Lookup(name string) (Preset, bool) {
return p, ok
}
// Names returns every preset's name, sorted.
func Names() []string {
return slices.Sorted(maps.Keys(all))
}
// all is every preset, by Settings.Provider name.
var all = map[string]Preset{
"anthropic": {
@@ -181,19 +187,40 @@ var all = map[string]Preset{
// preset's OmitTemperature matches. It is wrapped in llm.NewRetrying, so
// Generate retries transient vendor failures and has the default timeout.
func FromSettings(s llm.Settings, key string) (llm.Provider, error) {
cp, err := fromSettings(s, key)
cp, err := fromSettings(s, key, true)
if err != nil {
return nil, redact(err, key)
}
return llm.NewRetrying(cp), nil
}
func fromSettings(s llm.Settings, key string) (*configured, error) {
// Account is what a provider offers before a model is chosen: the live
// model list and a check of the credentials.
type Account interface {
ListModels(ctx context.Context) ([]llm.Model, error)
Verify(ctx context.Context) error
}
// ForAccount returns the Account s describes, using key as its API key. It
// checks s and key as FromSettings does except that s.Model may be empty,
// so the household can verify its key and pick from the live model list
// before it has chosen a model. Neither call is retried.
func ForAccount(s llm.Settings, key string) (Account, error) {
cp, err := fromSettings(s, key, false)
if err != nil {
return nil, redact(err, key)
}
return cp, nil
}
// fromSettings builds the provider s describes; needModel is whether
// s.Model is required.
func fromSettings(s llm.Settings, key string, needModel bool) (*configured, error) {
p, ok := Lookup(s.Provider)
if !ok {
return nil, llm.ErrUnknownProvider
}
if err := check(p, s, key); err != nil {
if err := check(p, s, key, needModel); err != nil {
return nil, fmt.Errorf("%s: %w", p.Name, err)
}
base := p.BaseURL
@@ -233,8 +260,9 @@ func fromSettings(s llm.Settings, key string) (*configured, error) {
return cp, nil
}
// check validates s and key against p. Its errors never quote a value.
func check(p Preset, s llm.Settings, key string) error {
// check validates s and key against p, and that s names a model when
// needModel is set. Its errors never quote a value.
func check(p Preset, s llm.Settings, key string, needModel bool) error {
switch {
case p.Key == KeyRequired && key == "":
return llm.ErrMissingKey
@@ -249,7 +277,7 @@ func check(p Preset, s llm.Settings, key string) error {
case p.BaseURL != "" && s.BaseURL != "" && s.BaseURL != p.BaseURL:
return fmt.Errorf("%w: base URL is fixed by the preset", llm.ErrInvalidSetting)
}
if s.Model == "" {
if needModel && s.Model == "" {
return llm.ErrMissingModel
}
if p.RequiresPublicURL && s.PublicURL == "" {
+33
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"slices"
"strings"
"testing"
@@ -660,3 +661,35 @@ func TestFromSettingsRetriesTransportTimeout(t *testing.T) {
t.Errorf("sent %d requests, want 2", st.sent)
}
}
// ForAccount builds without a model, so a key can be verified and the
// model list read before a model is chosen, but keeps every other check.
func TestForAccount(t *testing.T) {
if _, err := ForAccount(llm.Settings{Provider: "openai"}, testKey); err != nil {
t.Errorf("ForAccount without a model = %v", err)
}
for _, c := range []struct {
s llm.Settings
key string
want error
}{
{llm.Settings{Provider: "gemini"}, testKey, llm.ErrUnknownProvider},
{llm.Settings{Provider: "openai"}, "", llm.ErrMissingKey},
{llm.Settings{Provider: "ollama"}, "", llm.ErrMissingBaseURL},
{llm.Settings{Provider: "openrouter"}, testKey, llm.ErrMissingPublicURL},
} {
if _, err := ForAccount(c.s, c.key); !errors.Is(err, c.want) {
t.Errorf("ForAccount(%s) = %v, want %v", c.s.Provider, err, c.want)
}
}
if _, err := FromSettings(llm.Settings{Provider: "openai"}, testKey); !errors.Is(err, llm.ErrMissingModel) {
t.Errorf("FromSettings without a model = %v, want ErrMissingModel", err)
}
}
func TestNames(t *testing.T) {
want := []string{"anthropic", "ollama", "openai", "openai_compatible", "openrouter", "xai"}
if got := Names(); !slices.Equal(got, want) {
t.Errorf("Names = %v, want %v", got, want)
}
}
+11
View File
@@ -35,6 +35,8 @@ const (
EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY"
EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ"
EnvSearXNGURL = "MEALQUEST_SEARXNG_URL"
)
// Session lifetime defaults (D-004): long enough that the kitchen tablet
@@ -73,6 +75,13 @@ type Config struct {
// validates it only while the settings do not exist yet, and ignores it
// once they do. Empty when unset.
DefaultTZ string
// SearXNGURL is the deployment's SearXNG instance, which the Settings
// screen suggests while no recipe-search URL is saved. It is optional
// and validated by recipes.NewSearchSettingsService: the compose
// network's http://searxng:8080 is neither https nor loopback, so the
// secure-URL rule does not apply. Empty when unset.
SearXNGURL string
}
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
@@ -107,6 +116,8 @@ func load(lookup func(string) (string, bool)) (Config, error) {
TrustedProxy: r.trustedProxy(EnvTrustedProxy),
DefaultTZ: r.optional(EnvDefaultTZ),
SearXNGURL: r.optional(EnvSearXNGURL),
}
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
+5
View File
@@ -422,6 +422,8 @@ func TestLoadValues(t *testing.T) {
EnvOIDCAllowedGroups: " mealquest-admin ,family",
EnvDefaultTZ: "Europe/London",
EnvSearXNGURL: "http://searxng:8080",
}
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
if err != nil {
@@ -480,6 +482,9 @@ func TestLoadValues(t *testing.T) {
if cfg.DefaultTZ != "Europe/London" {
t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ)
}
if cfg.SearXNGURL != "http://searxng:8080" {
t.Errorf("SearXNGURL = %q, want http://searxng:8080", cfg.SearXNGURL)
}
}
func TestSecretRedacted(t *testing.T) {
+2 -1
View File
@@ -11,7 +11,8 @@ import (
)
// DefaultPersonaPrompt is the default AI system prompt (CTXD PROMPTS.MD §4).
// SeedAISettings stores it on first boot and the Admin reset restores it.
// SeedAISettings stores it on first boot and aisettings.Service.ResetPrompt
// restores it.
const DefaultPersonaPrompt = `You are MealQuest, a dinner planning assistant for one household. Suggest dinners
similar to their regular rotation, nudging them to try something a little new each
week. Prefer weeknight-friendly recipes with common grocery-store ingredients.
+40 -17
View File
@@ -30,10 +30,15 @@ var ErrSearchNotConfigured = errors.New("recipes: recipe search is not configure
type SearchSettings struct {
// Provider is "searxng" or "brave", nil until first chosen.
Provider *string `json:"provider"`
// BaseURL is the SearXNG instance's URL, nil when not set.
BaseURL *string `json:"base_url"`
APIKeySet bool `json:"api_key_set"`
APIKeyLast4 *string `json:"api_key_last4"`
// BaseURL is the saved SearXNG instance's URL, nil when not set.
BaseURL *string `json:"base_url"`
// DefaultBaseURL is the deployment's SearXNG URL
// (MEALQUEST_SEARXNG_URL), for the client to suggest while BaseURL is
// not set. It is never saved by itself: only a PATCH of base_url saves
// a URL. Nil when the deployment names none.
DefaultBaseURL *string `json:"default_base_url"`
APIKeySet bool `json:"api_key_set"`
APIKeyLast4 *string `json:"api_key_last4"`
// UpdatedAt is nil until the settings are first saved.
UpdatedAt *string `json:"updated_at"`
}
@@ -75,32 +80,49 @@ func (e *SearchSettingsError) Error() string {
type SearchSettingsService struct {
st *store.Store
box *secretbox.Box
// defaultBaseURL is SearchSettings.DefaultBaseURL, "" for none.
defaultBaseURL string
// newProvider is NewSearchProvider; tests point Brave elsewhere.
newProvider func(db.RecipeSearchSetting, *secretbox.Box) (SearchProvider, error)
}
// NewSearchSettingsService returns the service over st, sealing keys with
// box.
func NewSearchSettingsService(st *store.Store, box *secretbox.Box) (*SearchSettingsService, error) {
// box. defaultBaseURL is the deployment's SearXNG URL, "" for none; a set
// one must be a valid SearXNG URL.
func NewSearchSettingsService(st *store.Store, box *secretbox.Box, defaultBaseURL string) (*SearchSettingsService, error) {
if st == nil {
return nil, errors.New("recipes: nil store")
}
if box == nil {
return nil, errors.New("recipes: nil secret box")
}
return &SearchSettingsService{st: st, box: box, newProvider: NewSearchProvider}, nil
if defaultBaseURL != "" {
if _, err := NewSearXNG(defaultBaseURL); err != nil {
return nil, fmt.Errorf("recipes: default SearXNG URL: %s", strings.TrimPrefix(err.Error(), "recipes: "))
}
}
return &SearchSettingsService{st: st, box: box, defaultBaseURL: defaultBaseURL, newProvider: NewSearchProvider}, nil
}
// Get returns the settings; before any are saved every field is unset.
// Get returns the settings; before any are saved every field but
// DefaultBaseURL is unset.
func (s *SearchSettingsService) Get(ctx context.Context) (SearchSettings, error) {
row, err := db.New(s.st.Reader).GetRecipeSearchSettings(ctx)
if errors.Is(err, sql.ErrNoRows) {
return SearchSettings{}, nil
return SearchSettings{DefaultBaseURL: s.defaultBase()}, nil
}
if err != nil {
return SearchSettings{}, fmt.Errorf("recipes: read recipe search settings: %w", err)
}
return searchSettingsFrom(row), nil
return s.settingsFrom(row), nil
}
func (s *SearchSettingsService) defaultBase() *string {
if s.defaultBaseURL == "" {
return nil
}
u := s.defaultBaseURL
return &u
}
// Update applies patch and returns the settings it leaves. The first update
@@ -141,7 +163,7 @@ func (s *SearchSettingsService) Update(ctx context.Context, patch SearchSettings
if err != nil {
return SearchSettings{}, err
}
return searchSettingsFrom(saved), nil
return s.settingsFrom(saved), nil
}
// apply returns row with patch applied, validated.
@@ -226,14 +248,15 @@ func (s *SearchSettingsService) TestSearch(ctx context.Context) (int, error) {
return len(cands), nil
}
func searchSettingsFrom(row db.RecipeSearchSetting) SearchSettings {
func (s *SearchSettingsService) settingsFrom(row db.RecipeSearchSetting) SearchSettings {
provider, updated := row.Provider, row.UpdatedAt
return SearchSettings{
Provider: &provider,
BaseURL: nullString(row.BaseUrl),
APIKeySet: len(row.ApiKeyCt) > 0,
APIKeyLast4: nullString(row.ApiKeyLast4),
UpdatedAt: &updated,
Provider: &provider,
BaseURL: nullString(row.BaseUrl),
DefaultBaseURL: s.defaultBase(),
APIKeySet: len(row.ApiKeyCt) > 0,
APIKeyLast4: nullString(row.ApiKeyLast4),
UpdatedAt: &updated,
}
}
+76 -1
View File
@@ -19,6 +19,13 @@ import (
const testBraveKey = "BSA-secret-brave-key-9f3c"
func newTestSearchSettings(t *testing.T) *SearchSettingsService {
t.Helper()
return newTestSearchSettingsWithDefault(t, "")
}
// newTestSearchSettingsWithDefault is newTestSearchSettings with
// defaultBaseURL as the deployment's SearXNG URL.
func newTestSearchSettingsWithDefault(t *testing.T, defaultBaseURL string) *SearchSettingsService {
t.Helper()
st, err := store.Open(t.Context(), t.TempDir())
if err != nil {
@@ -38,7 +45,7 @@ func newTestSearchSettings(t *testing.T) *SearchSettingsService {
if err != nil {
t.Fatal(err)
}
svc, err := NewSearchSettingsService(st, box)
svc, err := NewSearchSettingsService(st, box, defaultBaseURL)
if err != nil {
t.Fatal(err)
}
@@ -124,6 +131,74 @@ func TestSearchSettingsUpdate(t *testing.T) {
}
}
func TestSearchSettingsDefaultBaseURL(t *testing.T) {
t.Parallel()
svc := newTestSearchSettingsWithDefault(t, "http://searxng:8080")
got, err := svc.Get(t.Context())
if err != nil {
t.Fatal(err)
}
if got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" || got.BaseURL != nil || got.Provider != nil {
t.Errorf("Get before any update = %+v, want only default_base_url set", got)
}
if _, err := svc.Provider(t.Context()); !errors.Is(err, ErrSearchNotConfigured) {
t.Errorf("Provider with only a default error = %v, want ErrSearchNotConfigured", err)
}
// The default is a suggestion: SearXNG still needs a saved URL.
if _, err := svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng")}); err == nil {
t.Error("choosing SearXNG without a URL succeeded with a default set")
}
got, err = svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng"), BaseURL: ptr("https://search.example")})
if err != nil {
t.Fatal(err)
}
if *got.BaseURL != "https://search.example" || got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" {
t.Errorf("Update result = %+v, want the saved URL beside the default", got)
}
got, err = svc.Get(t.Context())
if err != nil {
t.Fatal(err)
}
if *got.BaseURL != "https://search.example" {
t.Errorf("Get base_url = %q, want the saved URL, not the default", *got.BaseURL)
}
row, err := db.New(svc.st.Reader).GetRecipeSearchSettings(t.Context())
if err != nil {
t.Fatal(err)
}
if row.BaseUrl.String != "https://search.example" {
t.Errorf("stored base_url = %q", row.BaseUrl.String)
}
}
func TestNewSearchSettingsServiceRejectsBadDefault(t *testing.T) {
t.Parallel()
st, err := store.Open(t.Context(), t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatal(err)
}
for _, u := range []string{"searxng:8080", "ftp://searxng", "http://user:pw@searxng:8080"} {
if _, err := NewSearchSettingsService(st, box, u); err == nil {
t.Errorf("NewSearchSettingsService(%q) succeeded", u)
} else if strings.Contains(err.Error(), "pw") {
t.Errorf("error for %q echoes the URL: %v", u, err)
}
}
}
func TestSearchSettingsTestSearch(t *testing.T) {
t.Parallel()
searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+145
View File
@@ -0,0 +1,145 @@
package server
import (
"errors"
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// apiKeyBody is the PUT /settings/ai/api-key body. An empty api_key removes
// the stored key.
type apiKeyBody struct {
APIKey *string `json:"api_key"`
}
// modelsResponse is the GET /settings/ai/models body.
type modelsResponse struct {
Models []aisettings.Model `json:"models"`
}
// No response from these handlers holds the API key: the settings carry
// only its last four characters, and provider errors and model lists are
// redacted by package aisettings.
func getAISettings(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
settings, err := svc.Get(r.Context())
if err != nil {
logger.ErrorContext(r.Context(), "get AI settings", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusOK, settings)
}
}
func patchAISettings(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
patch, err := decodePatch[aisettings.Patch](http.MaxBytesReader(w, r.Body, maxAISettingsBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
writeAISettings(w, r, logger, "update AI settings")(svc.Update(r.Context(), patch))
}
}
// putAIKey takes the API key in api_key; the response, like GET's, holds
// only its last four characters.
func putAIKey(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := decodePatch[apiKeyBody](http.MaxBytesReader(w, r.Body, maxSettingsBody))
if err == nil && body.APIKey == nil {
err = errors.New("api_key: is required")
}
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
writeAISettings(w, r, logger, "store AI API key")(svc.SetAPIKey(r.Context(), *body.APIKey))
}
}
func postAIResetPrompt(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
writeAISettings(w, r, logger, "reset AI system prompt")(svc.ResetPrompt(r.Context()))
}
}
// postAIVerify checks the stored key with the provider and returns the
// settings with key_verified_at, or the failure (see writeAIFailure).
func postAIVerify(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
settings, err := svc.Verify(r.Context())
if err != nil {
writeAIFailure(w, r, logger, "verify AI key", err)
return
}
writeJSON(w, r, logger, http.StatusOK, settings)
}
}
// getAIModels returns the provider's live model list, or the failure (see
// writeAIFailure).
func getAIModels(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
models, err := svc.Models(r.Context())
if err != nil {
writeAIFailure(w, r, logger, "list AI models", err)
return
}
writeJSON(w, r, logger, http.StatusOK, modelsResponse{Models: models})
}
}
// writeAISettings returns a func writing a Service result: the settings,
// 400 invalid_settings for a *aisettings.ValidationError, or 500.
func writeAISettings(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string) func(aisettings.Settings, error) {
return func(settings aisettings.Settings, err error) {
var invalid *aisettings.ValidationError
switch {
case errors.As(err, &invalid):
writeError(w, r, logger, http.StatusBadRequest, "invalid_settings", invalid.Error())
case err != nil:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
default:
writeJSON(w, r, logger, http.StatusOK, settings)
}
}
}
// writeAIFailure writes a Verify or Models error: 409 not_configured when
// the settings cannot build a provider, 409 settings_changed when they
// changed during verification, and for a failed provider call a 502 (504
// for a timeout) whose code names the llm typed error: auth_failed,
// rate_limited, timeout, bad_output or provider_error. Rate limits and
// timeouts are retryable. Anything else is 500.
func writeAIFailure(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
switch {
case errors.Is(err, aisettings.ErrNotConfigured):
writeError(w, r, logger, http.StatusConflict, "not_configured", err.Error())
case errors.Is(err, aisettings.ErrChanged):
writeError(w, r, logger, http.StatusConflict, "settings_changed", err.Error())
case errors.Is(err, aisettings.ErrProviderFailed):
logger.WarnContext(r.Context(), action+" failed", "error", err)
status, e := http.StatusBadGateway, apiError{Code: "provider_error", Message: err.Error()}
switch {
case errors.Is(err, llm.ErrAuth):
e.Code = "auth_failed"
case errors.Is(err, llm.ErrRateLimited):
e.Code, e.Retryable = "rate_limited", true
case errors.Is(err, llm.ErrTimeout):
status, e.Code, e.Retryable = http.StatusGatewayTimeout, "timeout", true
case errors.Is(err, llm.ErrBadOutput):
e.Code = "bad_output"
}
writeAPIError(w, r, logger, status, e)
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
+261
View File
@@ -0,0 +1,261 @@
package server
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"testing/fstest"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
)
const (
aiPath = "/api/v1/settings/ai"
aiKeyPath = "/api/v1/settings/ai/api-key"
aiVerifyPath = "/api/v1/settings/ai/verify"
aiModelsPath = "/api/v1/settings/ai/models"
aiResetPromptPath = "/api/v1/settings/ai/reset-prompt"
testAIKey = "sk-test-secret-ai-key-7d21"
testWrongAIKey = "sk-test-wrong-ai-key-0b9e"
)
// newAIRouter returns a router with a fresh, seeded AI settings service.
func newAIRouter(t *testing.T) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
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)
}
}
+1 -1
View File
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
t.Fatal(err)
}
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestRunner(t),
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestRunner(t),
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+1 -1
View File
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
}
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+2 -2
View File
@@ -22,7 +22,7 @@ const (
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestRunner(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), 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, "")
+14 -2
View File
@@ -10,6 +10,7 @@ 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/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
@@ -31,18 +32,23 @@ 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;
// 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, 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 runner == nil {
return nil, errors.New("server: nil job runner")
}
@@ -80,6 +86,12 @@ 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.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
media.Get("/photos/{name}", getPhoto(logger, photos))
+43 -12
View File
@@ -16,6 +16,7 @@ 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/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
@@ -42,7 +43,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), runner, newTestPhotos(t), assets)
if err != nil {
t.Fatal(err)
}
@@ -131,24 +132,54 @@ 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
}
// 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, 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), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil recipe search settings service")
}
}
@@ -157,7 +188,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), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted nil photo files")
}
}
@@ -166,7 +197,7 @@ 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), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
@@ -175,7 +206,7 @@ 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), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner")
}
}
+15 -3
View File
@@ -9,14 +9,21 @@ import (
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
)
// maxSettingsBody bounds a PATCH /settings/household or
// /settings/recipe-search request body.
// /settings/recipe-search, or PUT /settings/ai/api-key, request body.
const maxSettingsBody = 16 << 10
// maxAISettingsBody bounds a PATCH /settings/ai request body. It fits the
// longest valid system prompt at 12 bytes of JSON per character (a
// \u-escaped surrogate pair) on top of maxSettingsBody for the other
// fields, so only aisettings.MaxSystemPromptChars limits the prompt.
const maxAISettingsBody = maxSettingsBody + 12*aisettings.MaxSystemPromptChars
// settingsPatch is the PATCH /settings/household body. Every field is
// optional; unknown fields and null values are refused.
type settingsPatch struct {
@@ -124,13 +131,18 @@ func writeJSON(w http.ResponseWriter, r *http.Request, logger *slog.Logger, stat
// writeError responds with status and a non-retryable API error.
func writeError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, code, message string) {
body, err := json.Marshal(errorBody{Error: apiError{Code: code, Message: message}})
writeAPIError(w, r, logger, status, apiError{Code: code, Message: message})
}
// writeAPIError responds with status and e.
func writeAPIError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, e apiError) {
body, err := json.Marshal(errorBody{Error: e})
if err != nil {
panic(err) // string and bool fields cannot fail to marshal
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if _, err := w.Write(body); err != nil {
logger.WarnContext(r.Context(), "write error response", "code", code, "error", err)
logger.WarnContext(r.Context(), "write error response", "code", e.Code, "error", err)
}
}
+1 -1
View File
@@ -216,7 +216,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+49 -7
View File
@@ -1,11 +1,12 @@
import { act, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApiClient, createVerifiedSession } from './api/index.ts'
import { createNetworkStatus } from './pwa/networkStatus.ts'
import { mockViewport } from './test/matchMedia.ts'
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts'
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
import App, { routes } from './App.tsx'
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
import { InDataRouter } from './test/dataRouter.tsx'
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
@@ -19,12 +20,15 @@ function renderAt(path: string, location = fakeLocation(path)) {
function renderAppAt(path: string, location = fakeLocation(path)) {
return render(
<MemoryRouter initialEntries={[path]}>
<InDataRouter path={path}>
<App api={createApiClient(location, createNetworkStatus(window), createVerifiedSession(localStorage))} serviceWorker={undefined} />
</MemoryRouter>,
</InDataRouter>,
)
}
// The routes that render a built screen rather than a placeholder page.
const SCREENS = { '/settings': true, '/settings/ai': true, '/settings/household': true }
describe('App routes', () => {
it('registers every handoff route plus the extra settings pages', () => {
expect(routes.map((route) => route.path)).toEqual([
@@ -46,7 +50,7 @@ describe('App routes', () => {
])
})
it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
it.each(routes.filter(({ path }) => !Object.hasOwn(SCREENS, path)).map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
'%s renders its placeholder page inside the shell',
async (path, title) => {
mockViewport(390)
@@ -57,6 +61,44 @@ describe('App routes', () => {
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
},
)
it('/settings renders the settings home inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
'/api/v1/me': () => jsonResponse(200, ME),
...SETTINGS_API,
})
renderAt('/settings')
const heading = await screen.findByRole('heading', { level: 1, name: 'Settings' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
})
it('/settings/ai renders the AI assistant screen inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
'/api/v1/me': () => jsonResponse(200, ME),
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }),
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
})
renderAt('/settings/ai')
const heading = await screen.findByRole('heading', { level: 1, name: 'AI assistant' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument()
})
it('/settings/household renders the household settings screen inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
'/api/v1/me': () => jsonResponse(200, ME),
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
})
renderAt('/settings/household')
const heading = await screen.findByRole('heading', { level: 1, name: 'Household' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByLabelText('Dinner time')).toHaveValue('17:30')
})
})
describe('App bootstrap', () => {
@@ -177,9 +219,9 @@ describe('App offline bootstrap', () => {
goOffline()
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<MemoryRouter initialEntries={['/plan']}>
<InDataRouter path="/plan">
<App api={api} serviceWorker={undefined} />
</MemoryRouter>,
</InDataRouter>,
)
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
+15 -4
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, type ReactNode } from 'react'
import { QueryClientProvider } from '@tanstack/react-query'
import { Route, Routes } from 'react-router'
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
@@ -6,6 +6,9 @@ import { AuthBootstrap } from './AuthBootstrap.tsx'
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
import { OfflinePill } from './pwa/OfflinePill.tsx'
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
import { AISettingsPage } from './settings/AISettingsPage.tsx'
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
import { SettingsPage } from './settings/SettingsPage.tsx'
import { AppShell } from './shell/AppShell.tsx'
// Every route the app serves, with the heading its placeholder page shows.
@@ -27,13 +30,20 @@ export const routes = [
{ path: '/settings/calendar', title: 'Calendar' },
] as const
// The screens built so far; every other route shows its placeholder page.
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
'/settings/household': <HouseholdSettingsPage />,
}
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
export function AppRoutes() {
return (
<Routes>
<Route element={<AppShell />}>
{routes.map(({ path, title }) => (
<Route key={path} path={path} element={<PlaceholderPage title={title} />} />
<Route key={path} path={path} element={screens[path] ?? <PlaceholderPage title={title} />} />
))}
</Route>
</Routes>
@@ -50,8 +60,9 @@ function reloadPage() {
window.location.reload()
}
// App expects to be rendered inside a router (BrowserRouter in main.tsx,
// MemoryRouter in tests).
// App expects to be rendered by a data router's splat route
// (createBrowserRouter in main.tsx, createMemoryRouter in tests), so pages can
// block navigation.
export default function App({ api, serviceWorker }: AppProps) {
const [queryClient] = useState(createQueryClient)
return (
+11 -4
View File
@@ -1,6 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import { createBrowserRouter, RouterProvider } from 'react-router'
import './styles/tokens.css'
import './styles/base.css'
import App from './App.tsx'
@@ -14,10 +14,17 @@ if (!root) {
const api = createApiClient(window.location, createNetworkStatus(window), createVerifiedSession(window.localStorage))
// A data router, so pages can block navigation (useBlocker); App's own
// <Routes> match every path under its splat route.
const router = createBrowserRouter([
{
path: '*',
element: <App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />,
},
])
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />
</BrowserRouter>
<RouterProvider router={router} />
</StrictMode>,
)
+313
View File
@@ -0,0 +1,313 @@
/* Admin · AI assistant, handoff mock 1o. */
.page {
box-sizing: border-box;
min-height: 100%;
max-width: 560px;
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 6px;
}
.header {
display: flex;
align-items: center;
gap: 16px;
}
.kicker {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-accent-700);
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 28px;
font-weight: 400;
}
.loading {
height: 320px;
border-radius: 26px;
}
.columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
}
.model {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.label {
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
.mono {
font-family: var(--font-mono);
}
.hint,
.note,
.verified,
.error,
.status {
margin: 0;
font-family: var(--font-body);
font-size: 13px;
line-height: 1.4;
}
.hint,
.note {
color: var(--color-neutral-700);
}
.verified {
font-weight: 700;
color: var(--color-accent-2-800);
}
.error {
font-weight: 700;
color: var(--color-accent-700);
}
.status {
color: var(--color-accent-2-800);
}
.status:empty {
display: none;
}
.key {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.keyRow {
display: flex;
align-items: center;
gap: 10px;
min-height: 56px;
padding: 0 6px 0 20px;
border: 1px solid var(--color-neutral-300);
border-radius: var(--radius-pill);
background: var(--color-surface);
}
.keyValue {
flex: 1;
min-width: 0;
overflow: hidden;
font-family: var(--font-mono);
font-size: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.replace {
flex: none;
min-width: 44px;
min-height: 44px;
padding: 0 18px;
border: 0;
border-radius: var(--radius-pill);
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.replace:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.keyStatus {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0 8px;
}
.link {
min-width: 44px;
min-height: 44px;
padding: 0 4px;
border: 0;
background: transparent;
color: var(--color-accent-700);
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
text-decoration: underline;
cursor: pointer;
}
.temperature {
display: flex;
flex-direction: column;
}
.temperatureHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.value {
font-family: var(--font-body);
font-size: 15px;
font-weight: 700;
}
.ends {
display: flex;
justify-content: space-between;
font-family: var(--font-body);
font-size: 12px;
color: var(--color-neutral-700);
}
.prompt {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.textarea {
box-sizing: border-box;
width: 100%;
min-height: 150px;
padding: 14px 18px;
border: 1px solid var(--color-neutral-300);
border-radius: 22px;
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
resize: vertical;
}
.actions {
/* Mock 1o anchors Reset and Save changes at the bottom of the screen. */
margin-top: auto;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: 10px;
padding-top: 6px;
}
.reset,
.save {
min-height: 52px;
}
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 560px;
margin: 0 auto;
}
.sheetTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.sheetText {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-neutral-700);
}
.sheetActions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* Recipe search card, below the AI assistant's actions. */
.card {
display: flex;
flex-direction: column;
gap: 14px;
border: 1px solid var(--color-neutral-300);
}
.cardTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.cardLoading {
height: 160px;
border-radius: var(--radius-lg);
}
.cardActions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: 10px;
}
.cardActions > button {
min-height: 52px;
}
.cardHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
/* AI assistant card on the desktop settings (mock 2e): the card grows to
fill its column and the system prompt grows to fill the card. */
.fill {
flex: 1;
gap: 18px;
}
.fill .prompt,
.fill .textarea {
flex: 1;
}
.fill .textarea {
resize: none;
}
/* A form locked while its save or prompt reset is pending, so no edit is
made that the response would then replace. It lays out as if it were not
there. */
.lock {
display: contents;
}
+782
View File
@@ -0,0 +1,782 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { AI_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { AISettingsPage, formatVerifiedDate } from './AISettingsPage.tsx'
import { maskedKey } from './api.ts'
const MODELS = {
models: [
{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' },
{ id: 'claude-haiku-4-5', display_name: 'Claude Haiku' },
],
}
const DEFAULT_PROMPT = 'You are MealQuest, the default persona.'
type Responder = (body: unknown) => Response | Promise<Response>
// stubAPI answers "METHOD path" from responders, each called with the
// request's decoded JSON body; GET /settings/ai and /settings/ai/models
// answer AI_SETTINGS and MODELS unless overridden. Any other request fails
// the test.
function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch> {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'GET /api/v1/settings/ai/models': () => jsonResponse(200, MODELS),
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined
return Promise.resolve(respond(body))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// sent lists the requests other than GETs, as "METHOD path" and body.
function sent(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
.map(([input, init]) => ({
request: `${init?.method} ${String(input)}`,
body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined,
}))
}
afterEach(() => {
vi.unstubAllGlobals()
})
function CurrentPath() {
return <p data-testid="path">{useLocation().pathname}</p>
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the page at /settings/ai in a data router, so its leave guard works.
async function renderPage() {
const api = createApiClient(fakeLocation('/settings/ai'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings/ai">
<Routes>
<Route path="/settings/ai" element={<AISettingsPage />} />
<Route path="*" element={<CurrentPath />} />
</Routes>
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
await screen.findByRole('option', { name: 'Claude Sonnet' })
}
const provider = () => screen.getByRole('combobox', { name: 'Provider' })
const model = () => screen.getByRole('combobox', { name: 'Model' })
const prompt = () => screen.getByRole('textbox', { name: 'System prompt' })
const slider = () => screen.getByRole('slider', { name: 'Creativity (temperature)' })
const save = () => screen.getByRole('button', { name: 'Save changes' })
describe('AISettingsPage', () => {
it('loads the settings as mock 1o lays them out', async () => {
stubAPI()
await renderPage()
expect(screen.getByRole('button', { name: 'Back to Settings' })).toBeInTheDocument()
expect(screen.getByText('Settings · Admin')).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: 'AI assistant' })).toBeInTheDocument()
expect(provider()).toHaveValue('anthropic')
expect(within(provider()).getByRole('option', { selected: true })).toHaveTextContent('Anthropic')
expect(model()).toHaveValue('claude-sonnet-4-5')
expect(within(model()).getAllByRole('option').map((o) => o.textContent)).toEqual([
'Claude Sonnet',
'Claude Haiku',
'Type a model id',
])
expect(screen.getByText('••••••••3f9a')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Replace' })).toBeInTheDocument()
expect(screen.getByText('Verified · Oct 1')).toBeInTheDocument()
expect(slider()).toHaveValue('0.7')
expect(slider()).toHaveAttribute('step', '0.1')
expect(screen.getByText('0.7')).toBeInTheDocument()
expect(screen.getByText('Familiar')).toBeInTheDocument()
expect(screen.getByText('Adventurous')).toBeInTheDocument()
expect(prompt()).toHaveValue(AI_SETTINGS.system_prompt)
expect(screen.getByRole('button', { name: 'Reset' })).toBeEnabled()
expect(save()).toBeDisabled()
})
it('reports a load failure with a retry', async () => {
stubAPI({ 'GET /api/v1/settings/ai': () => errorResponse(500, 'internal') })
const api = createApiClient(fakeLocation('/settings/ai'), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings/ai">
<AISettingsPage />
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
expect(await screen.findByRole('alert')).toHaveTextContent('AI settings could not load: internal message')
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
})
it('saves only the edited fields', async () => {
const fetchMock = stubAPI({
'PATCH /api/v1/settings/ai': (body) => jsonResponse(200, { ...AI_SETTINGS, ...(body as object) }),
})
await renderPage()
fireEvent.change(model(), { target: { value: 'claude-haiku-4-5' } })
fireEvent.change(slider(), { target: { value: '0.4' } })
expect(screen.getByText('0.4')).toBeInTheDocument()
fireEvent.change(prompt(), { target: { value: 'Keep it cozy.' } })
expect(save()).toBeEnabled()
fireEvent.click(save())
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{
request: 'PATCH /api/v1/settings/ai',
body: { model: 'claude-haiku-4-5', temperature: 0.4, system_prompt: 'Keep it cozy.' },
},
])
expect(save()).toBeDisabled()
expect(model()).toHaveValue('claude-haiku-4-5')
})
it('saves a typed model id', async () => {
const fetchMock = stubAPI({
'PATCH /api/v1/settings/ai': (body) => jsonResponse(200, { ...AI_SETTINGS, ...(body as object) }),
})
await renderPage()
expect(screen.queryByRole('textbox', { name: 'Model id' })).not.toBeInTheDocument()
fireEvent.change(model(), { target: { value: within(model()).getByRole('option', { name: 'Type a model id' }).getAttribute('value') } })
fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'claude-opus-5-5' } })
fireEvent.click(save())
await screen.findByText('Changes saved.')
expect(sent(fetchMock)).toEqual([{ request: 'PATCH /api/v1/settings/ai', body: { model: 'claude-opus-5-5' } }])
})
it('explains that a stored key must be removed before switching provider', async () => {
stubAPI({
'PATCH /api/v1/settings/ai': () =>
jsonResponse(400, {
error: {
code: 'invalid_settings',
message: 'invalid AI settings: provider: cannot change while an API key is stored: remove the key first',
retryable: false,
},
}),
})
await renderPage()
fireEvent.change(provider(), { target: { value: 'openai' } })
expect(screen.getByText(/An API key is stored\. Remove it \(Replace → Remove key\)/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Replace' })).toBeEnabled()
fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'gpt-5' } })
fireEvent.click(save())
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('Not saved: invalid AI settings: provider: cannot change while an API key is stored')
expect(alert).toHaveTextContent('Remove it (Replace → Remove key) before changing the provider or base URL.')
})
it('gives the same guidance when a stored key locks the base URL', async () => {
stubAPI({
'GET /api/v1/settings/ai': () =>
jsonResponse(200, { ...AI_SETTINGS, provider: 'openai_compatible', base_url: 'https://llm.example.com/v1' }),
'PATCH /api/v1/settings/ai': () =>
jsonResponse(400, {
error: {
code: 'invalid_settings',
message: 'invalid AI settings: base_url: cannot change while an API key is stored: remove the key first',
retryable: false,
},
}),
})
await renderPage()
fireEvent.change(screen.getByRole('textbox', { name: 'Base URL' }), { target: { value: 'https://other.example.com/v1' } })
expect(screen.getByText(/An API key is stored\. Remove it/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Replace' })).toBeEnabled()
fireEvent.click(save())
expect(await screen.findByRole('alert')).toHaveTextContent(
'Remove it (Replace → Remove key) before changing the provider or base URL.',
)
})
it('recovers a provider switch by removing the stored key, without undoing the switch', async () => {
const fetchMock = stubAPI({
'PUT /api/v1/settings/ai/api-key': () =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null }),
'PATCH /api/v1/settings/ai': (body) =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null, ...(body as object) }),
})
await renderPage()
fireEvent.change(provider(), { target: { value: 'ollama' } })
fireEvent.change(screen.getByRole('textbox', { name: 'Base URL' }), { target: { value: 'http://localhost:11434' } })
fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'llama3.2' } })
// The stored key stays in view, and removable, while Ollama is chosen.
expect(screen.getByText('••••••••3f9a')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove key' }))
expect(await screen.findByText('Ollama takes no API key.')).toBeInTheDocument()
expect(screen.queryByText(/An API key is stored/)).not.toBeInTheDocument()
expect(provider()).toHaveValue('ollama')
fireEvent.click(save())
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{ request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: '' } },
{
request: 'PATCH /api/v1/settings/ai',
body: { provider: 'ollama', base_url: 'http://localhost:11434', model: 'llama3.2' },
},
])
})
it('replaces the key in a sheet, then verifies it', async () => {
const verifiedAt = '2026-10-06T14:00:00.000Z'
const fetchMock = stubAPI({
'PUT /api/v1/settings/ai/api-key': () =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null }),
'POST /api/v1/settings/ai/verify': () =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: verifiedAt }),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
const sheet = screen.getByRole('dialog', { name: 'Replace API key' })
const input = within(sheet).getByLabelText('API key')
expect(input).toHaveAttribute('type', 'password')
fireEvent.change(input, { target: { value: 'sk-ant-secret-b7c1' } })
fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' }))
expect(await screen.findByText(`Verified · ${formatVerifiedDate(verifiedAt)}`)).toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getByText('••••••••b7c1')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{ request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: 'sk-ant-secret-b7c1' } },
{ request: 'POST /api/v1/settings/ai/verify', body: undefined },
])
expect(document.body).not.toHaveTextContent('sk-ant-secret-b7c1')
})
it('still verifies the key when the sheet is dismissed while it is being stored', async () => {
let answerPut: (response: Response) => void = () => {
throw new Error('PUT not sent')
}
const fetchMock = stubAPI({
'PUT /api/v1/settings/ai/api-key': () => new Promise<Response>((resolve) => (answerPut = resolve)),
'POST /api/v1/settings/ai/verify': () =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: '2026-10-06T14:00:00.000Z' }),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
const sheet = screen.getByRole('dialog', { name: 'Replace API key' })
fireEvent.change(within(sheet).getByLabelText('API key'), { target: { value: 'sk-ant-secret-b7c1' } })
fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' }))
await waitFor(() => expect(within(sheet).getByRole('button', { name: 'Saving…' })).toBeDisabled())
fireEvent.keyDown(sheet, { key: 'Escape' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Saving…' })).toBeDisabled()
act(() => answerPut(jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null })))
expect(await screen.findByText('Verified · Oct 6')).toBeInTheDocument()
expect(screen.getByText('••••••••b7c1')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Replace' })).toBeEnabled()
expect(sent(fetchMock)).toEqual([
{ request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: 'sk-ant-secret-b7c1' } },
{ request: 'POST /api/v1/settings/ai/verify', body: undefined },
])
})
it('shows the verify error and verifies again on request', async () => {
let verifyCalls = 0
stubAPI({
'PUT /api/v1/settings/ai/api-key': () =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null }),
'POST /api/v1/settings/ai/verify': () => {
verifyCalls++
return verifyCalls === 1
? errorResponse(502, 'auth_failed')
: jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1' })
},
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
const sheet = screen.getByRole('dialog', { name: 'Replace API key' })
fireEvent.change(within(sheet).getByLabelText('API key'), { target: { value: 'sk-wrong-b7c1' } })
fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Verification failed: the provider rejected this key.')
expect(screen.queryByText(/^Verified ·/)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
expect(await screen.findByText('Verified · Oct 1')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('shows a refused key in the sheet', async () => {
stubAPI({
'PUT /api/v1/settings/ai/api-key': () =>
jsonResponse(400, {
error: { code: 'invalid_settings', message: 'api_key: must be at least 8 characters', retryable: false },
}),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
const sheet = screen.getByRole('dialog', { name: 'Replace API key' })
fireEvent.change(within(sheet).getByLabelText('API key'), { target: { value: 'short' } })
fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' }))
expect(await within(sheet).findByRole('alert')).toHaveTextContent(
'The key was not stored: api_key: must be at least 8 characters',
)
})
it('removes the stored key from the sheet', async () => {
const fetchMock = stubAPI({
'PUT /api/v1/settings/ai/api-key': () =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null }),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove key' }))
expect(await screen.findByText('No key stored')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add key' })).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([{ request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: '' } }])
})
it('resets the system prompt after confirming', async () => {
const fetchMock = stubAPI({
'POST /api/v1/settings/ai/reset-prompt': () => jsonResponse(200, { ...AI_SETTINGS, system_prompt: DEFAULT_PROMPT }),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Reset' }))
const confirm = screen.getByRole('dialog', { name: 'Reset the system prompt?' })
fireEvent.click(within(confirm).getByRole('button', { name: 'Cancel' }))
expect(sent(fetchMock)).toEqual([])
fireEvent.click(screen.getByRole('button', { name: 'Reset' }))
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Reset prompt' }))
await waitFor(() => expect(prompt()).toHaveValue(DEFAULT_PROMPT))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(sent(fetchMock)).toEqual([{ request: 'POST /api/v1/settings/ai/reset-prompt', body: undefined }])
expect(save()).toBeDisabled()
})
it('asks before leaving with unsaved changes', async () => {
stubAPI()
await renderPage()
fireEvent.change(prompt(), { target: { value: 'Unsaved.' } })
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
const confirm = await screen.findByRole('dialog', { name: 'Leave without saving?' })
fireEvent.click(within(confirm).getByRole('button', { name: 'Keep editing' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(prompt()).toHaveValue('Unsaved.')
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Leave' }))
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('leaves without asking when nothing changed', async () => {
stubAPI()
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('guards a page unload only while there are unsaved changes', async () => {
stubAPI()
await renderPage()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
expect(unload()).toBe(false)
fireEvent.change(slider(), { target: { value: '0.2' } })
expect(unload()).toBe(true)
})
it('locks the form while a save is pending, and guards leaving until the save lands', async () => {
let answer: (response: Response) => void = () => {}
stubAPI({ 'PATCH /api/v1/settings/ai': () => new Promise<Response>((resolve) => (answer = resolve)) })
await renderPage()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
fireEvent.change(prompt(), { target: { value: 'Keep it cozy.' } })
fireEvent.click(save())
expect(await screen.findByRole('button', { name: 'Saving…' })).toBeDisabled()
for (const field of [provider(), model(), slider(), prompt()]) expect(field).toBeDisabled()
expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled()
expect(unload()).toBe(true)
answer(jsonResponse(200, { ...AI_SETTINGS, system_prompt: 'Keep it cozy.' }))
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
expect(prompt()).toBeEnabled()
expect(prompt()).toHaveValue('Keep it cozy.')
await waitFor(() => expect(unload()).toBe(false))
})
it('locks the form while a prompt reset is pending, even with the sheet closed', async () => {
let answer: (response: Response) => void = () => {}
stubAPI({
'POST /api/v1/settings/ai/reset-prompt': () => new Promise<Response>((resolve) => (answer = resolve)),
})
await renderPage()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
fireEvent.change(slider(), { target: { value: '0.2' } })
fireEvent.click(screen.getByRole('button', { name: 'Reset' }))
const confirm = screen.getByRole('dialog', { name: 'Reset the system prompt?' })
fireEvent.click(within(confirm).getByRole('button', { name: 'Reset prompt' }))
expect(await within(confirm).findByRole('button', { name: 'Resetting…' })).toBeDisabled()
fireEvent.click(within(confirm).getByRole('button', { name: 'Cancel' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
for (const field of [provider(), model(), slider(), prompt()]) expect(field).toBeDisabled()
expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled()
expect(save()).toBeDisabled()
expect(unload()).toBe(true)
answer(jsonResponse(200, { ...AI_SETTINGS, system_prompt: DEFAULT_PROMPT }))
await waitFor(() => expect(prompt()).toBeEnabled())
expect(prompt()).toHaveValue(DEFAULT_PROMPT)
expect(slider()).toHaveValue('0.2')
expect(unload()).toBe(true)
fireEvent.change(prompt(), { target: { value: 'After the reset.' } })
expect(prompt()).toHaveValue('After the reset.')
})
it('keeps 44 px tap targets', async () => {
stubAPI()
await renderPage()
for (const control of [
screen.getByRole('button', { name: 'Back to Settings' }),
screen.getByRole('button', { name: 'Replace' }),
provider(),
model(),
slider(),
screen.getByRole('button', { name: 'Reset' }),
save(),
]) {
expectMinHitArea(control)
}
})
})
// apiError is an API error response carrying message.
function apiError(status: number, code: string, message: string): Response {
return jsonResponse(status, { error: { code, message, retryable: false } })
}
const BRAVE_SETTINGS = { ...RECIPE_SEARCH_SETTINGS, provider: 'brave', api_key_set: true, api_key_last4: 'b7c2' }
describe('Recipe search card', () => {
const card = () => screen.getByRole('region', { name: 'Recipe search' })
const searxng = () => within(card()).getByRole('radio', { name: 'SearXNG' })
const brave = () => within(card()).getByRole('radio', { name: 'Brave' })
const url = () => within(card()).getByRole('textbox', { name: 'SearXNG URL' })
const saveSearch = () => within(card()).getByRole('button', { name: 'Save recipe search' })
const testSearch = () => within(card()).getByRole('button', { name: 'Test' })
async function renderCard() {
await renderPage()
await within(card()).findByRole('radiogroup', { name: 'Recipe search provider' })
}
// addKey enters key in the Brave key sheet.
function addKey(button: string, key: string) {
fireEvent.click(within(card()).getByRole('button', { name: button }))
const sheet = screen.getByRole('dialog')
fireEvent.change(within(sheet).getByLabelText('Brave API key'), { target: { value: key } })
fireEvent.click(within(sheet).getByRole('button', { name: 'Use this key' }))
}
it('shows the saved SearXNG URL', async () => {
stubAPI()
await renderCard()
expect(searxng()).toHaveAttribute('aria-checked', 'true')
expect(url()).toHaveValue('http://searxng:8080')
expect(within(card()).queryByText('Brave API key')).not.toBeInTheDocument()
expect(saveSearch()).toBeDisabled()
expect(testSearch()).toBeEnabled()
})
const UNCONFIGURED = {
provider: null,
base_url: null,
default_base_url: 'http://searxng:8080',
api_key_set: false,
api_key_last4: null,
updated_at: null,
}
it('starts on SearXNG with the deployment\'s URL before recipe search is set up, and saves it', async () => {
const fetchMock = stubAPI({
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED),
'PATCH /api/v1/settings/recipe-search': (body) =>
jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, ...(body as object) }),
})
await renderCard()
expect(searxng()).toHaveAttribute('aria-checked', 'true')
expect(url()).toHaveValue('http://searxng:8080')
expect(testSearch()).toBeDisabled()
expect(saveSearch()).toBeEnabled()
fireEvent.click(saveSearch())
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{
request: 'PATCH /api/v1/settings/recipe-search',
body: { provider: 'searxng', base_url: 'http://searxng:8080' },
},
])
})
it('starts with an empty URL when the deployment names no SearXNG', async () => {
stubAPI({
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, { ...UNCONFIGURED, default_base_url: null }),
})
await renderCard()
expect(url()).toHaveValue('')
})
it('shows the saved URL, not the deployment\'s', async () => {
stubAPI({
'GET /api/v1/settings/recipe-search': () =>
jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, base_url: 'https://search.example' }),
})
await renderCard()
expect(url()).toHaveValue('https://search.example')
expect(saveSearch()).toBeDisabled()
})
it('switches providers, keeping each provider\'s field', async () => {
stubAPI()
await renderCard()
fireEvent.change(url(), { target: { value: 'https://search.example' } })
fireEvent.click(brave())
expect(brave()).toHaveAttribute('aria-checked', 'true')
expect(within(card()).queryByRole('textbox', { name: 'SearXNG URL' })).not.toBeInTheDocument()
expect(within(card()).getByText('No key stored')).toBeInTheDocument()
expect(within(card()).getByRole('button', { name: 'Add key' })).toBeInTheDocument()
fireEvent.click(searxng())
expect(url()).toHaveValue('https://search.example')
})
it('saves a new SearXNG URL', async () => {
const fetchMock = stubAPI({
'PATCH /api/v1/settings/recipe-search': (body) =>
jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, ...(body as object) }),
})
await renderCard()
fireEvent.change(url(), { target: { value: 'https://search.example' } })
expect(testSearch()).toBeDisabled()
expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument()
fireEvent.click(saveSearch())
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{ request: 'PATCH /api/v1/settings/recipe-search', body: { base_url: 'https://search.example' } },
])
expect(saveSearch()).toBeDisabled()
expect(testSearch()).toBeEnabled()
})
it('saves a switch to Brave with its key, then shows only the key\'s last four characters', async () => {
const fetchMock = stubAPI({ 'PATCH /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) })
await renderCard()
fireEvent.click(brave())
addKey('Add key', 'BSA-secret-key-b7c2')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument()
expect(within(card()).getByText('New key not saved yet')).toBeInTheDocument()
expect(document.body).not.toHaveTextContent('BSA-secret-key-b7c2')
fireEvent.click(saveSearch())
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{ request: 'PATCH /api/v1/settings/recipe-search', body: { provider: 'brave', api_key: 'BSA-secret-key-b7c2' } },
])
expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument()
expect(within(card()).queryByText('New key not saved yet')).not.toBeInTheDocument()
expect(within(card()).getByRole('button', { name: 'Replace' })).toBeInTheDocument()
expect(document.body).not.toHaveTextContent('BSA-secret-key-b7c2')
})
it('masks a stored Brave key and replaces it', async () => {
const fetchMock = stubAPI({
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS),
'PATCH /api/v1/settings/recipe-search': () => jsonResponse(200, { ...BRAVE_SETTINGS, api_key_last4: '9d0e' }),
})
await renderCard()
expect(brave()).toHaveAttribute('aria-checked', 'true')
expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument()
expect(saveSearch()).toBeDisabled()
addKey('Replace', 'BSA-other-key-9d0e')
fireEvent.click(within(card()).getByRole('button', { name: 'Discard' }))
expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument()
expect(saveSearch()).toBeDisabled()
addKey('Replace', 'BSA-other-key-9d0e')
fireEvent.click(saveSearch())
expect(await within(card()).findByText('••••••••9d0e')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{ request: 'PATCH /api/v1/settings/recipe-search', body: { api_key: 'BSA-other-key-9d0e' } },
])
})
it('reports a save the server refuses', async () => {
stubAPI({
'PATCH /api/v1/settings/recipe-search': () =>
apiError(400, 'invalid_settings', 'invalid recipe search settings: api_key: must be at least 8 characters'),
})
await renderCard()
fireEvent.click(brave())
addKey('Add key', 'short')
fireEvent.click(saveSearch())
expect(await within(card()).findByRole('alert')).toHaveTextContent(
'Not saved: invalid recipe search settings: api_key: must be at least 8 characters.',
)
})
it('shows how many recipes the test found', async () => {
const fetchMock = stubAPI({
'POST /api/v1/settings/recipe-search/test': () =>
jsonResponse(200, { query: 'chicken tikka masala', count: 12 }),
})
await renderCard()
fireEvent.click(testSearch())
expect(await within(card()).findByText("Found 12 recipes for 'chicken tikka masala'")).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([{ request: 'POST /api/v1/settings/recipe-search/test', body: undefined }])
})
it('shows the error when the test fails', async () => {
stubAPI({
'POST /api/v1/settings/recipe-search/test': () =>
apiError(502, 'search_failed', 'recipes: SearXNG answered 403 Forbidden'),
})
await renderCard()
fireEvent.click(testSearch())
expect(await within(card()).findByRole('alert')).toHaveTextContent(
'Test failed: recipes: SearXNG answered 403 Forbidden',
)
})
it('clears a test result when the settings are edited', async () => {
stubAPI({
'POST /api/v1/settings/recipe-search/test': () =>
jsonResponse(200, { query: 'chicken tikka masala', count: 12 }),
})
await renderCard()
fireEvent.click(testSearch())
expect(await within(card()).findByText("Found 12 recipes for 'chicken tikka masala'")).toBeInTheDocument()
fireEvent.change(url(), { target: { value: 'https://search.example' } })
expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument()
expect(within(card()).queryByText(/Found 12 recipes/)).not.toBeInTheDocument()
})
it('clears a test error when the settings are edited', async () => {
stubAPI({
'POST /api/v1/settings/recipe-search/test': () =>
apiError(502, 'search_failed', 'recipes: SearXNG answered 403 Forbidden'),
})
await renderCard()
fireEvent.click(testSearch())
expect(await within(card()).findByRole('alert')).toHaveTextContent('Test failed')
fireEvent.click(brave())
expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument()
expect(within(card()).queryByRole('alert')).not.toBeInTheDocument()
})
it('asks before leaving with an unsaved recipe search change', async () => {
stubAPI()
await renderCard()
fireEvent.change(url(), { target: { value: 'https://search.example' } })
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
expect(await screen.findByRole('dialog', { name: 'Leave without saving?' })).toBeInTheDocument()
})
it('does not count the suggested SearXNG URL as an unsaved change until it is edited', async () => {
stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED) })
await renderCard()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
expect(saveSearch()).toBeEnabled()
expect(unload()).toBe(false)
fireEvent.change(url(), { target: { value: 'https://search.example' } })
expect(unload()).toBe(true)
fireEvent.change(url(), { target: { value: 'http://searxng:8080' } })
expect(unload()).toBe(false)
})
it('leaves the untouched first-use card without asking', async () => {
stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED) })
await renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('locks the card while a save is pending, and guards leaving until the save lands', async () => {
let answer: (response: Response) => void = () => {}
stubAPI({ 'PATCH /api/v1/settings/recipe-search': () => new Promise<Response>((resolve) => (answer = resolve)) })
await renderCard()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
fireEvent.change(url(), { target: { value: 'https://search.example' } })
fireEvent.click(saveSearch())
expect(await within(card()).findByRole('button', { name: 'Saving…' })).toBeDisabled()
for (const field of [url(), searxng(), brave()]) expect(field).toBeDisabled()
expect(unload()).toBe(true)
answer(jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, base_url: 'https://search.example' }))
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
expect(url()).toBeEnabled()
expect(url()).toHaveValue('https://search.example')
await waitFor(() => expect(unload()).toBe(false))
})
it('keeps 44 px tap targets', async () => {
stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) })
await renderCard()
for (const control of [
searxng(),
brave(),
within(card()).getByRole('button', { name: 'Replace' }),
testSearch(),
saveSearch(),
]) {
expectMinHitArea(control)
}
})
})
describe('maskedKey', () => {
it('shows only the last four characters', () => {
expect(maskedKey('3f9a')).toBe('••••••••3f9a')
})
})
+546
View File
@@ -0,0 +1,546 @@
import { ChevronLeft } from 'lucide-react'
import { useEffect, useId, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router'
import { ApiError } from '../api/index.ts'
import { Button } from '../ui/Button.tsx'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Select } from '../ui/Select.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Slider } from '../ui/Slider.tsx'
import { Tag } from '../ui/Tag.tsx'
import { TextInput } from '../ui/TextInput.tsx'
import {
aiKeyPolicy,
aiProviderLabels,
maskedKey,
providersWithBaseURL,
useAIModels,
useAISettings,
useResetAIPrompt,
useSaveAISettings,
useSetAIKey,
useVerifyAIKey,
type AIProvider,
type AISettings,
type AISettingsPatch,
} from './api.ts'
import styles from './AISettingsPage.module.css'
import { Confirm, LeaveGuard } from './LeaveGuard.tsx'
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
// TYPE_MODEL is the Model dropdown's "Type a model id" option.
const TYPE_MODEL = '\u0000type'
// Admin · AI assistant (handoff mock 1o). Provider, model, temperature and
// system prompt are edited as a draft and saved together; the API key is
// stored (and verified) on its own, straight away. The client only ever
// sees the key's last four characters. Recipe search is its own card, saved
// on its own.
export function AISettingsPage() {
const [aiDirty, setAIDirty] = useState(false)
const [recipeSearchDirty, setRecipeSearchDirty] = useState(false)
return (
<div className={styles.page}>
<Header />
<AIAssistantSettings onDirtyChange={setAIDirty} />
<RecipeSearchCard onDirtyChange={setRecipeSearchDirty} />
<LeaveGuard dirty={aiDirty || recipeSearchDirty} />
</div>
)
}
type AISettingsProps = {
// Called with whether the form holds unsaved changes.
onDirtyChange: (dirty: boolean) => void
}
// AIAssistantCard is the AI assistant's fields as a card, for the desktop
// settings (handoff mock 2e): the system prompt grows to fill the card.
export function AIAssistantCard({ onDirtyChange }: AISettingsProps) {
const titleId = useId()
return (
<Card className={`${styles.card} ${styles.fill}`} aria-labelledby={titleId} role="region">
<div className={styles.cardHeader}>
<h2 id={titleId} className={styles.cardTitle}>
AI assistant
</h2>
<Tag>Admin</Tag>
</div>
<AIAssistantSettings onDirtyChange={onDirtyChange} />
</Card>
)
}
// AIAssistantSettings is the AI assistant's form once its settings load.
function AIAssistantSettings({ onDirtyChange }: AISettingsProps) {
const settings = useAISettings()
return (
<>
{settings.isPending && <Skeleton className={styles.loading} />}
{settings.isError && (
<ErrorBanner onRetry={() => void settings.refetch()}>
AI settings could not load: {settings.error.message}
</ErrorBanner>
)}
{settings.isSuccess && <AISettingsForm saved={settings.data} onDirtyChange={onDirtyChange} />}
</>
)
}
function Header() {
const navigate = useNavigate()
return (
<header className={styles.header}>
<IconButton label="Back to Settings" onClick={() => void navigate('/settings')}>
<Icon icon={ChevronLeft} size="nav" />
</IconButton>
<div>
<p className={styles.kicker}>Settings · Admin</p>
<h1 className={styles.title}>AI assistant</h1>
</div>
</header>
)
}
type Draft = {
provider: AIProvider
base_url: string
model: string
temperature: number
system_prompt: string
}
function draftOf(saved: AISettings): Draft {
return {
provider: saved.provider,
base_url: saved.base_url,
model: saved.model,
temperature: saved.temperature,
system_prompt: saved.system_prompt ?? '',
}
}
// patchOf is the PATCH body for the fields draft changes.
function patchOf(saved: AISettings, draft: Draft): AISettingsPatch {
const before = draftOf(saved)
const patch: AISettingsPatch = {}
if (draft.provider !== before.provider) patch.provider = draft.provider
if (draft.base_url !== before.base_url) patch.base_url = draft.base_url
if (draft.model !== before.model) patch.model = draft.model
if (draft.temperature !== before.temperature) patch.temperature = draft.temperature
if (draft.system_prompt !== before.system_prompt) patch.system_prompt = draft.system_prompt
return patch
}
type AISettingsFormProps = AISettingsProps & {
saved: AISettings
}
function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) {
const [draft, setDraft] = useState(() => draftOf(saved))
const [typingModel, setTypingModel] = useState(false)
const [confirmingReset, setConfirmingReset] = useState(false)
const save = useSaveAISettings()
const resetPrompt = useResetAIPrompt()
const patch = patchOf(saved, draft)
const dirty = Object.keys(patch).length > 0
const providerChanged = draft.provider !== saved.provider
// The server keeps a stored key's provider and base URL fixed until the
// key is removed.
const keyLocked = saved.api_key_last4 !== null && (providerChanged || draft.base_url !== saved.base_url)
const temperatureId = useId()
// The model list is the saved provider's, so it is asked for only while
// that provider is chosen and has the key it needs.
const canList =
!providerChanged && (aiKeyPolicy[saved.provider] !== 'required' || saved.api_key_last4 !== null)
const models = useAIModels(canList)
const listed = canList && models.isSuccess ? models.data : []
const typedModel = typingModel || !(draft.model === '' || listed.some((m) => m.id === draft.model))
useEffect(() => {
onDirtyChange(dirty)
}, [dirty, onDirtyChange])
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
function edit(change: Partial<Draft>) {
save.reset()
setDraft((current) => ({ ...current, ...change }))
}
function chooseProvider(provider: AIProvider) {
setTypingModel(provider !== saved.provider)
if (provider === saved.provider) {
edit({ provider, base_url: saved.base_url, model: saved.model })
return
}
// A fixed-URL preset refuses any other base URL, and the old model is
// another provider's: the household types the new one.
edit({ provider, base_url: providersWithBaseURL.has(provider) ? draft.base_url : '', model: '' })
}
function onSave() {
save.mutate(patch, {
onSuccess: (settings) => {
setDraft(draftOf(settings))
setTypingModel(false)
},
})
}
function onConfirmReset() {
resetPrompt.mutate(undefined, {
onSuccess: (settings) => {
setDraft((current) => ({ ...current, system_prompt: settings.system_prompt ?? '' }))
setConfirmingReset(false)
},
})
}
return (
<fieldset className={styles.lock} disabled={save.isPending || resetPrompt.isPending}>
<div className={styles.columns}>
<Select
label="Provider"
value={draft.provider}
onChange={(event) => chooseProvider(event.target.value as AIProvider)}
>
{Object.entries(aiProviderLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</Select>
<div className={styles.model}>
<Select
label="Model"
value={typedModel ? TYPE_MODEL : draft.model}
onChange={(event) => {
const model = event.target.value
setTypingModel(model === TYPE_MODEL)
if (model !== TYPE_MODEL) edit({ model })
}}
>
{!typedModel && draft.model === '' && (
<option value="" disabled>
Choose a model
</option>
)}
{listed.map((model) => (
<option key={model.id} value={model.id}>
{model.display_name}
</option>
))}
<option value={TYPE_MODEL}>Type a model id</option>
</Select>
{canList && models.isPending && <p className={styles.note}>Loading models…</p>}
{canList && models.isError && <p className={styles.note}>Model list unavailable: {models.error.message}</p>}
{!canList && !providerChanged && (
<p className={styles.note}>Add the API key to choose from the provider's models.</p>
)}
</div>
</div>
{keyLocked && <p className={styles.hint}>{keyLockedGuidance}</p>}
{typedModel && (
<TextInput
label="Model id"
className={styles.mono}
value={draft.model}
placeholder="e.g. claude-sonnet-4-5"
autoComplete="off"
spellCheck={false}
onChange={(event) => edit({ model: event.target.value })}
/>
)}
{providersWithBaseURL.has(draft.provider) && (
<TextInput
label="Base URL"
type="url"
className={styles.mono}
value={draft.base_url}
placeholder="https://"
autoComplete="off"
spellCheck={false}
onChange={(event) => edit({ base_url: event.target.value })}
/>
)}
<APIKey saved={saved} draftProvider={draft.provider} />
<div className={styles.temperature}>
<div className={styles.temperatureHeader}>
<span id={temperatureId} className={styles.label}>
Creativity (temperature)
</span>
<output htmlFor={temperatureId} className={styles.value}>
{draft.temperature.toFixed(1)}
</output>
</div>
<Slider
aria-labelledby={temperatureId}
aria-valuetext={`${draft.temperature.toFixed(1)}, from Familiar (0) to Adventurous (1)`}
value={draft.temperature}
min={0}
max={1}
step={0.1}
onChange={(event) => edit({ temperature: Math.round(Number(event.target.value) * 10) / 10 })}
/>
<div className={styles.ends} aria-hidden="true">
<span>Familiar</span>
<span>Adventurous</span>
</div>
</div>
<PromptField value={draft.system_prompt} onChange={(system_prompt) => edit({ system_prompt })} />
<div className={styles.status} role="status">
{save.isSuccess && !dirty && 'Changes saved.'}
</div>
{save.isError && <p className={styles.error} role="alert">{saveErrorMessage(save.error)}</p>}
{resetPrompt.isError && !confirmingReset && (
<p className={styles.error} role="alert">
The system prompt was not reset: {resetPrompt.error.message}
</p>
)}
<div className={styles.actions}>
<Button variant="outline" className={styles.reset} onClick={() => setConfirmingReset(true)}>
Reset
</Button>
<Button className={styles.save} disabled={!dirty || save.isPending} onClick={onSave}>
{save.isPending ? 'Saving…' : 'Save changes'}
</Button>
</div>
<Confirm
open={confirmingReset}
title="Reset the system prompt?"
confirmLabel={resetPrompt.isPending ? 'Resetting…' : 'Reset prompt'}
busy={resetPrompt.isPending}
error={resetPrompt.isError ? `The system prompt was not reset: ${resetPrompt.error.message}` : null}
onCancel={() => setConfirmingReset(false)}
onConfirm={onConfirmReset}
>
This restores MealQuest's default system prompt for everyone in the household.
</Confirm>
</fieldset>
)
}
// formatVerifiedDate turns key_verified_at into the handoff's "Oct 1".
export function formatVerifiedDate(timestamp: string): string {
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) throw new Error(`key_verified_at "${timestamp}" is not a timestamp`)
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(date)
}
// keyLockedGuidance is how to change the provider or base URL while a key
// is stored.
const keyLockedGuidance =
'An API key is stored. Remove it (Replace → Remove key) before changing the provider or base URL.'
// APIKey stays mounted whatever provider the draft names, so a key stored
// (and the verify that follows it) completes even if the household changes
// the provider meanwhile, and a stored key can always be removed.
function APIKey({ saved, draftProvider }: { saved: AISettings; draftProvider: AIProvider }) {
const [replacing, setReplacing] = useState(false)
const [key, setKey] = useState('')
const store = useSetAIKey()
const verify = useVerifyAIKey()
const labelId = useId()
const sheetTitleId = useId()
const last4 = saved.api_key_last4
const providerChanged = draftProvider !== saved.provider
function open() {
store.reset()
setReplacing(true)
}
// close keeps the store mutation: a key already sent is still stored and
// verified after the sheet is dismissed.
function close() {
setReplacing(false)
setKey('')
}
function onStore() {
verify.reset()
store.mutate(key, {
onSuccess: () => {
close()
verify.mutate(undefined)
},
})
}
function onRemove() {
verify.reset()
store.mutate('', { onSuccess: close })
}
const storeError = store.isError && (
<p className={styles.error} role="alert">
The key was not stored: {store.error.message}
</p>
)
if (last4 === null && aiKeyPolicy[draftProvider] === 'none') {
return (
<div className={styles.key}>
<p className={styles.hint}>{aiProviderLabels[draftProvider]} takes no API key.</p>
{!replacing && storeError}
</div>
)
}
return (
<div className={styles.key}>
<span id={labelId} className={styles.label}>
API key
</span>
<div className={styles.keyRow} role="group" aria-labelledby={labelId}>
<span className={styles.keyValue}>{last4 === null ? 'No key stored' : maskedKey(last4)}</span>
<button
type="button"
className={styles.replace}
disabled={store.isPending || (last4 === null && providerChanged)}
onClick={open}
>
{store.isPending ? 'Saving…' : last4 === null ? 'Add key' : 'Replace'}
</button>
</div>
{last4 === null && providerChanged && (
<p className={styles.note}>Save the provider change before adding its key.</p>
)}
{!replacing && storeError}
<KeyStatus saved={saved} verify={verify} />
<Sheet open={replacing} onClose={close} aria-labelledby={sheetTitleId}>
<form
className={styles.sheet}
onSubmit={(event) => {
event.preventDefault()
onStore()
}}
>
<h2 id={sheetTitleId} className={styles.sheetTitle}>
{last4 === null ? 'Add API key' : 'Replace API key'}
</h2>
<p className={styles.sheetText}>
The key is stored on the server for {aiProviderLabels[saved.provider]}. This device only ever sees its
last four characters.
</p>
<TextInput
label="API key"
type="password"
className={styles.mono}
value={key}
autoComplete="off"
spellCheck={false}
onChange={(event) => setKey(event.target.value)}
/>
{storeError}
<div className={styles.sheetActions}>
{last4 !== null && (
<Button variant="outline" disabled={store.isPending} onClick={onRemove}>
Remove key
</Button>
)}
<Button type="submit" disabled={key === '' || store.isPending}>
{store.isPending ? 'Saving…' : 'Save and verify'}
</Button>
</div>
</form>
</Sheet>
</div>
)
}
type KeyStatusProps = {
saved: AISettings
verify: ReturnType<typeof useVerifyAIKey>
}
// KeyStatus is the line under the key: verifying, the verify error, when it
// was verified, or that it has not been.
function KeyStatus({ saved, verify }: KeyStatusProps) {
if (saved.api_key_last4 === null) return null
if (verify.isPending) return <p className={styles.note}>Verifying…</p>
if (verify.isError) {
return (
<div className={styles.keyStatus}>
<p className={styles.error} role="alert">
{verifyErrorMessage(verify.error)}
</p>
<VerifyButton verify={verify}>Try again</VerifyButton>
</div>
)
}
if (saved.key_verified_at !== null) {
return <p className={styles.verified}>Verified · {formatVerifiedDate(saved.key_verified_at)}</p>
}
return (
<div className={styles.keyStatus}>
<p className={styles.note}>Not verified yet</p>
<VerifyButton verify={verify}>Verify</VerifyButton>
</div>
)
}
function VerifyButton({ verify, children }: { verify: KeyStatusProps['verify']; children: ReactNode }) {
return (
<button type="button" className={styles.link} onClick={() => verify.mutate(undefined)}>
{children}
</button>
)
}
// verifyErrorMessage says why POST /settings/ai/verify failed, by the
// error code the server names (internal/server/aisettings.go).
export function verifyErrorMessage(error: Error): string {
if (!(error instanceof ApiError)) return `Verification failed: ${error.message}`
switch (error.code) {
case 'auth_failed':
return 'Verification failed: the provider rejected this key.'
case 'rate_limited':
return 'Verification failed: the provider is rate limiting requests. Try again shortly.'
case 'timeout':
return 'Verification failed: the provider did not answer in time.'
case 'settings_changed':
return 'Verification failed: the settings changed while verifying. Try again.'
default:
return `Verification failed: ${error.message}`
}
}
// saveErrorMessage says why a save failed. The server refuses to move a
// stored key to another provider or base URL; it names how to fix that.
function saveErrorMessage(error: Error): string {
if (error instanceof ApiError && error.code === 'invalid_settings') {
const guidance = error.message.includes('while an API key is stored') ? ` ${keyLockedGuidance}` : ''
return `Not saved: ${error.message}.${guidance}`
}
return `Not saved: ${error.message}`
}
function PromptField({ value, onChange }: { value: string; onChange: (value: string) => void }) {
const id = useId()
return (
<div className={styles.prompt}>
<label htmlFor={id} className={styles.label}>
System prompt
</label>
<textarea
id={id}
className={styles.textarea}
value={value}
rows={8}
spellCheck={false}
onChange={(event) => onChange(event.target.value)}
/>
</div>
)
}
@@ -0,0 +1,237 @@
/* Settings · Household. */
.page {
box-sizing: border-box;
min-height: 100%;
max-width: 560px;
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 6px;
}
.header {
display: flex;
align-items: center;
gap: 16px;
}
.kicker {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-accent-700);
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 28px;
font-weight: 400;
}
.shared {
margin: 0;
padding: 14px 16px;
border-radius: 26px;
background: var(--color-accent-2-100);
color: var(--color-accent-2-900);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
}
.loading {
height: 420px;
border-radius: 26px;
}
.card {
display: flex;
flex-direction: column;
gap: 18px;
border: 1px solid var(--color-neutral-300);
}
.cardTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.groupLabel {
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
.columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
align-items: end;
}
.time {
width: 100%;
}
/* Beside a day dropdown, a time matches its height. */
.pairedTime {
height: 56px;
}
.hint,
.error,
.status {
margin: 0;
font-family: var(--font-body);
font-size: 13px;
line-height: 1.4;
}
.hint {
color: var(--color-neutral-700);
}
.error {
font-weight: 700;
color: var(--color-accent-700);
}
.errors {
display: flex;
flex-direction: column;
gap: 4px;
}
.status {
color: var(--color-accent-2-800);
}
.status:empty {
display: none;
}
.zoneRow {
display: flex;
align-items: center;
gap: 10px;
min-height: 56px;
padding: 0 6px 0 20px;
border: 1px solid var(--color-neutral-300);
border-radius: var(--radius-pill);
background: var(--color-surface);
}
.zoneValue {
flex: 1;
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0 8px;
}
.zoneName {
min-width: 0;
overflow: hidden;
font-family: var(--font-body);
font-size: 15px;
font-weight: 700;
text-overflow: ellipsis;
}
.offset {
font-family: var(--font-mono);
font-size: 13px;
color: var(--color-neutral-700);
}
.change {
flex: none;
min-width: 44px;
min-height: 44px;
padding: 0 18px;
border: 0;
border-radius: var(--radius-pill);
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.actions {
margin-top: auto;
display: grid;
padding-top: 6px;
}
.save {
min-height: 52px;
}
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 560px;
margin: 0 auto;
}
.zoneList {
max-height: 50vh;
margin: 0;
padding: 0;
overflow-y: auto;
list-style: none;
}
.zoneOption {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-width: 44px;
min-height: 48px;
padding: 0 16px;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-text);
text-align: start;
cursor: pointer;
}
.zoneOption .zoneName {
flex: 1;
}
.zoneOption[aria-pressed='true'] {
background: var(--color-accent-2-100);
color: var(--color-accent-2-900);
}
.check {
flex: none;
}
/* A form locked while its save is pending, so no edit is made that the
saved settings would then replace. It lays out as if it were not there. */
.lock {
display: contents;
}
@@ -0,0 +1,394 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, ApiError, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { householdSettingsProblems } from './api.ts'
import { HouseholdSettingsPage } from './HouseholdSettingsPage.tsx'
import { matchesZone, utcOffset, zoneLabel } from './timezones.ts'
type Responder = (body: unknown) => Response | Promise<Response>
// stubAPI answers "METHOD path" from responders, each called with the
// request's decoded JSON body; GET /settings/household answers
// HOUSEHOLD_SETTINGS unless overridden. Any other request fails the test.
function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch> {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined
return Promise.resolve(respond(body))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// sent lists the requests other than GETs, as "METHOD path" and body.
function sent(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
.map(([input, init]) => ({
request: `${init?.method} ${String(input)}`,
body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined,
}))
}
afterEach(() => {
vi.unstubAllGlobals()
})
function CurrentPath() {
return <p data-testid="path">{useLocation().pathname}</p>
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the page at /settings/household in a data router, so its leave guard works.
async function renderPage() {
const api = createApiClient(
fakeLocation('/settings/household'),
createNetworkStatus(window),
createVerifiedSession(localStorage),
)
await api.loadMe()
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings/household">
<Routes>
<Route path="/settings/household" element={<HouseholdSettingsPage />} />
<Route path="*" element={<CurrentPath />} />
</Routes>
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
await screen.findByLabelText('Dinner time')
}
const save = () => screen.getByRole('button', { name: 'Save changes' })
const input = (name: string) => screen.getByLabelText(name)
const zoneGroup = () => screen.getByRole('group', { name: 'Time zone' })
describe('HouseholdSettingsPage', () => {
it('loads every household setting with plain-word copy', async () => {
stubAPI()
await renderPage()
expect(screen.getByRole('button', { name: 'Back to Settings' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: 'Household' })).toBeInTheDocument()
expect(screen.getByText('Changes apply to everyone in the household.')).toBeInTheDocument()
expect(zoneGroup()).toHaveTextContent('America / New York')
expect(zoneGroup()).toHaveTextContent(utcOffset('America/New_York', new Date()))
expect(input('Dinner time')).toHaveValue('17:30')
expect(input('Dinner time')).toHaveAccessibleDescription(/crock-pot dinners count back from it/)
expect(input('Day-of reminder')).toHaveValue('16:30')
expect(input('Rotation check')).toHaveValue('09:00')
expect(input('Rotation check')).toHaveAccessibleDescription(/When we ask whether to keep a new dinner/)
expect(input('Plan draft day')).toHaveValue('5')
expect(within(input('Plan draft day')).getByRole('option', { selected: true })).toHaveTextContent('Friday')
expect(input('Plan draft time')).toHaveValue('09:00')
expect(input('New suggestions day')).toHaveValue('0')
expect(input('New suggestions time')).toHaveValue('08:00')
expect(input('Google Chat Supply run day')).toHaveValue('6')
expect(input('Google Chat Supply run time')).toHaveValue('08:00')
expect(input('Google Chat Supply run time')).toHaveAccessibleDescription(/Supply run is sent to your Google Chat/)
expect(save()).toBeDisabled()
})
it('reports a load failure with a retry', async () => {
stubAPI({ 'GET /api/v1/settings/household': () => errorResponse(500, 'internal') })
const api = createApiClient(
fakeLocation('/settings/household'),
createNetworkStatus(window),
createVerifiedSession(localStorage),
)
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings/household">
<HouseholdSettingsPage />
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
expect(await screen.findByRole('alert')).toHaveTextContent('Household settings could not load: internal message')
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
})
it('saves only the edited fields', async () => {
const fetchMock = stubAPI({
'PATCH /api/v1/settings/household': (body) => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, ...(body as object) }),
})
await renderPage()
fireEvent.change(input('Dinner time'), { target: { value: '18:15' } })
fireEvent.change(input('Rotation check'), { target: { value: '10:00' } })
fireEvent.change(input('Plan draft day'), { target: { value: '4' } })
fireEvent.change(input('Google Chat Supply run time'), { target: { value: '07:30' } })
// Changed and changed back: not sent.
fireEvent.change(input('Day-of reminder'), { target: { value: '16:00' } })
fireEvent.change(input('Day-of reminder'), { target: { value: '16:30' } })
fireEvent.click(save())
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{
request: 'PATCH /api/v1/settings/household',
body: { dinner_time: '18:15', review_time: '10:00', plan_draft_dow: 4, chat_autosend_time: '07:30' },
},
])
expect(input('Dinner time')).toHaveValue('18:15')
expect(save()).toBeDisabled()
})
it('chooses a time zone from the searchable list', async () => {
const fetchMock = stubAPI({
'PATCH /api/v1/settings/household': (body) => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, ...(body as object) }),
})
await renderPage()
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
const sheet = screen.getByRole('dialog', { name: 'Choose a time zone' })
const list = within(sheet).getByRole('list', { name: 'Time zones' })
expect(within(list).getByRole('button', { pressed: true })).toHaveAccessibleName(
`America / New York, ${utcOffset('America/New_York', new Date())}`,
)
fireEvent.change(within(sheet).getByRole('searchbox', { name: 'Search time zones' }), {
target: { value: 'los angeles' },
})
const options = within(list).getAllByRole('button')
expect(options).toHaveLength(1)
expect(within(sheet).getByText('1 time zone')).toBeInTheDocument()
expect(options[0]).toHaveAccessibleName(`America / Los Angeles, ${utcOffset('America/Los_Angeles', new Date())}`)
fireEvent.click(options[0]!)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(zoneGroup()).toHaveTextContent('America / Los Angeles')
fireEvent.click(save())
await screen.findByText('Changes saved.')
expect(sent(fetchMock)).toEqual([
{ request: 'PATCH /api/v1/settings/household', body: { timezone: 'America/Los_Angeles' } },
])
})
it('offers the UTC zones the browser list leaves out', async () => {
expect(Intl.supportedValuesOf('timeZone')).not.toContain('Etc/UTC')
const fetchMock = stubAPI({
'PATCH /api/v1/settings/household': (body) => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, ...(body as object) }),
})
await renderPage()
expect(zoneGroup()).toHaveTextContent('America / New York')
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
const sheet = screen.getByRole('dialog', { name: 'Choose a time zone' })
const list = within(sheet).getByRole('list', { name: 'Time zones' })
fireEvent.change(within(sheet).getByRole('searchbox', { name: 'Search time zones' }), {
target: { value: 'utc' },
})
expect(within(list).getByRole('button', { name: 'UTC, UTC+00:00' })).toBeInTheDocument()
fireEvent.click(within(list).getByRole('button', { name: 'Etc / UTC, UTC+00:00' }))
expect(zoneGroup()).toHaveTextContent('Etc / UTC')
fireEvent.click(save())
await screen.findByText('Changes saved.')
expect(sent(fetchMock)).toEqual([
{ request: 'PATCH /api/v1/settings/household', body: { timezone: 'Etc/UTC' } },
])
})
it('keeps offering a saved zone the browser list leaves out after another is chosen', async () => {
expect(Intl.supportedValuesOf('timeZone')).not.toContain('US/Eastern')
stubAPI({
'GET /api/v1/settings/household': () => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, timezone: 'US/Eastern' }),
})
await renderPage()
const search = (value: string) =>
fireEvent.change(screen.getByRole('searchbox', { name: 'Search time zones' }), { target: { value } })
const option = (name: RegExp) =>
within(screen.getByRole('list', { name: 'Time zones' })).getByRole('button', { name })
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
search('los angeles')
fireEvent.click(option(/^America \/ Los Angeles,/))
expect(zoneGroup()).toHaveTextContent('America / Los Angeles')
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
search('us eastern')
fireEvent.click(option(/^US \/ Eastern,/))
expect(zoneGroup()).toHaveTextContent('US / Eastern')
expect(save()).toBeDisabled()
})
it('says when no time zone matches the search', async () => {
stubAPI()
await renderPage()
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
const sheet = screen.getByRole('dialog', { name: 'Choose a time zone' })
fireEvent.change(within(sheet).getByRole('searchbox'), { target: { value: 'Atlantis' } })
expect(within(sheet).getByText('No time zone matches.')).toBeInTheDocument()
expect(within(sheet).queryAllByRole('listitem')).toHaveLength(0)
})
it('shows the API validation errors beside their fields', async () => {
stubAPI({
'PATCH /api/v1/settings/household': () =>
jsonResponse(400, {
error: {
code: 'invalid_settings',
message:
'invalid household settings: timezone: unknown time zone "Mars/Olympus" (want an IANA zone such as America/New_York); dinner_time: invalid time "" (want 24-hour HH:MM, 00:00-23:59)',
retryable: false,
},
}),
})
await renderPage()
fireEvent.change(input('Dinner time'), { target: { value: '' } })
fireEvent.click(save())
expect(await screen.findByRole('alert')).toHaveTextContent('Not saved: fix the settings marked below their fields.')
expect(input('Dinner time')).toHaveAttribute('aria-invalid', 'true')
expect(input('Dinner time')).toHaveAccessibleDescription(/^invalid time "" \(want 24-hour HH:MM, 00:00-23:59\)/)
expect(zoneGroup()).toHaveAccessibleDescription(
'unknown time zone "Mars/Olympus" (want an IANA zone such as America/New_York)',
)
expect(input('Day-of reminder')).toHaveAttribute('aria-invalid', 'false')
// Editing again clears the refused save's errors.
fireEvent.change(input('Dinner time'), { target: { value: '18:00' } })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(input('Dinner time')).toHaveAttribute('aria-invalid', 'false')
})
it('shows an error that names no field as a whole-form error', async () => {
stubAPI({ 'PATCH /api/v1/settings/household': () => errorResponse(500, 'internal') })
await renderPage()
fireEvent.change(input('Dinner time'), { target: { value: '18:00' } })
fireEvent.click(save())
expect(await screen.findByRole('alert')).toHaveTextContent('Not saved: internal message')
expect(input('Dinner time')).toHaveValue('18:00')
expect(save()).toBeEnabled()
})
it('asks before leaving with unsaved changes', async () => {
stubAPI()
await renderPage()
fireEvent.change(input('New suggestions time'), { target: { value: '07:00' } })
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
const confirm = await screen.findByRole('dialog', { name: 'Leave without saving?' })
fireEvent.click(within(confirm).getByRole('button', { name: 'Keep editing' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(input('New suggestions time')).toHaveValue('07:00')
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Leave' }))
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('locks the form while a save is pending, and guards leaving until the save lands', async () => {
let answer: (response: Response) => void = () => {}
stubAPI({
'PATCH /api/v1/settings/household': () => new Promise<Response>((resolve) => (answer = resolve)),
})
await renderPage()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
fireEvent.change(input('Dinner time'), { target: { value: '18:15' } })
fireEvent.click(save())
expect(await screen.findByRole('button', { name: 'Saving…' })).toBeDisabled()
for (const field of ['Dinner time', 'Day-of reminder', 'Plan draft day', 'Google Chat Supply run time']) {
expect(input(field)).toBeDisabled()
}
expect(screen.getByRole('button', { name: 'Change' })).toBeDisabled()
expect(unload()).toBe(true)
answer(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, dinner_time: '18:15' }))
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
expect(input('Dinner time')).toBeEnabled()
expect(input('Dinner time')).toHaveValue('18:15')
await waitFor(() => expect(unload()).toBe(false))
})
it('leaves without asking when nothing changed', async () => {
stubAPI()
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('keeps 44 px tap targets', async () => {
stubAPI()
await renderPage()
for (const control of [
screen.getByRole('button', { name: 'Back to Settings' }),
within(zoneGroup()).getByRole('button', { name: 'Change' }),
input('Dinner time'),
input('Plan draft day'),
input('Plan draft time'),
save(),
]) {
expectMinHitArea(control)
}
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
expectMinHitArea(within(screen.getByRole('dialog')).getAllByRole('button', { pressed: true })[0]!)
})
})
describe('householdSettingsProblems', () => {
it('reads each field the server names', () => {
const error = new ApiError(
400,
'invalid_settings',
'invalid household settings: plan_draft_dow: invalid day of the week 9 (want 0-6, Sunday = 0); review_time: invalid time "9" (want 24-hour HH:MM, 00:00-23:59)',
false,
)
expect(householdSettingsProblems(error)).toEqual({
fields: {
plan_draft_dow: 'invalid day of the week 9 (want 0-6, Sunday = 0)',
review_time: 'invalid time "9" (want 24-hour HH:MM, 00:00-23:59)',
},
other: [],
})
})
it('keeps a problem naming no field, and any other error, whole', () => {
expect(
householdSettingsProblems(
new ApiError(400, 'invalid_settings', 'invalid household settings: settings: no settings to change', false),
),
).toEqual({ fields: {}, other: ['settings: no settings to change'] })
expect(
householdSettingsProblems(new ApiError(400, 'invalid_request', 'plan_draft_dow: want a JSON int', false)),
).toEqual({ fields: {}, other: ['plan_draft_dow: want a JSON int'] })
})
})
describe('time zones', () => {
it('formats the UTC offset', () => {
const winter = new Date('2026-01-15T12:00:00Z')
expect(utcOffset('America/New_York', winter)).toBe('UTC-05:00')
expect(utcOffset('Asia/Kolkata', winter)).toBe('UTC+05:30')
expect(utcOffset('UTC', winter)).toBe('UTC+00:00')
})
it('matches every search word against the name and offset', () => {
const option = { zone: 'America/Argentina/Buenos_Aires', offset: 'UTC-03:00' }
expect(zoneLabel(option.zone)).toBe('America / Argentina / Buenos Aires')
expect(matchesZone(option, 'buenos aires')).toBe(true)
expect(matchesZone(option, 'Buenos_Aires')).toBe(true)
expect(matchesZone(option, 'utc-03')).toBe(true)
expect(matchesZone(option, 'buenos paris')).toBe(false)
})
})
+412
View File
@@ -0,0 +1,412 @@
import { Check, ChevronLeft } from 'lucide-react'
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router'
import { Button } from '../ui/Button.tsx'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Select } from '../ui/Select.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { TextInput } from '../ui/TextInput.tsx'
import {
householdSettingsProblems,
useHouseholdSettings,
useSaveHouseholdSettings,
type HouseholdSettings,
type HouseholdSettingsField,
type HouseholdSettingsPatch,
} from './api.ts'
import styles from './HouseholdSettingsPage.module.css'
import { LeaveGuard } from './LeaveGuard.tsx'
import { matchesZone, timeZoneOptions, utcOffset, zoneLabel } from './timezones.ts'
// Days of the week as the server counts them, from Sunday = 0.
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const
// Settings · Household: the time zone and the times the household's
// reminders and weekly AI runs happen at. Every field is edited as a draft
// and saved together; only the changed fields are sent.
export function HouseholdSettingsPage() {
const [dirty, setDirty] = useState(false)
return (
<div className={styles.page}>
<Header />
<p className={styles.shared}>Changes apply to everyone in the household.</p>
<HouseholdSettingsSections onDirtyChange={setDirty} />
<LeaveGuard dirty={dirty} />
</div>
)
}
type HouseholdSettingsProps = {
// Called with whether the form holds unsaved changes.
onDirtyChange: (dirty: boolean) => void
}
// HouseholdSettingsSections is the screen's form once its settings load,
// without the screen's header, so the desktop settings (mock 2e) can embed
// it.
export function HouseholdSettingsSections({ onDirtyChange }: HouseholdSettingsProps) {
const settings = useHouseholdSettings()
return (
<>
{settings.isPending && <Skeleton className={styles.loading} />}
{settings.isError && (
<ErrorBanner onRetry={() => void settings.refetch()}>
Household settings could not load: {settings.error.message}
</ErrorBanner>
)}
{settings.isSuccess && <HouseholdSettingsForm saved={settings.data} onDirtyChange={onDirtyChange} />}
</>
)
}
function Header() {
const navigate = useNavigate()
return (
<header className={styles.header}>
<IconButton label="Back to Settings" onClick={() => void navigate('/settings')}>
<Icon icon={ChevronLeft} size="nav" />
</IconButton>
<div>
<p className={styles.kicker}>Settings</p>
<h1 className={styles.title}>Household</h1>
</div>
</header>
)
}
// patchOf is the PATCH body for the fields draft changes.
function patchOf(saved: HouseholdSettings, draft: HouseholdSettings): HouseholdSettingsPatch {
const patch: Record<string, string | number> = {}
for (const field of Object.keys(saved) as HouseholdSettingsField[]) {
if (draft[field] !== saved[field]) patch[field] = draft[field]
}
return patch as HouseholdSettingsPatch
}
type HouseholdSettingsFormProps = HouseholdSettingsProps & {
saved: HouseholdSettings
}
function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormProps) {
const [draft, setDraft] = useState(saved)
const save = useSaveHouseholdSettings()
const patch = patchOf(saved, draft)
const dirty = Object.keys(patch).length > 0
const problems = save.isError ? householdSettingsProblems(save.error) : { fields: {}, other: [] }
useEffect(() => {
onDirtyChange(dirty)
}, [dirty, onDirtyChange])
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
function edit(change: Partial<HouseholdSettings>) {
save.reset()
setDraft((current) => ({ ...current, ...change }))
}
function onSave() {
save.mutate(patch, { onSuccess: setDraft })
}
return (
<fieldset className={styles.lock} disabled={save.isPending}>
<Section title="Time zone">
<TimeZoneField
value={draft.timezone}
saved={saved.timezone}
onChange={(timezone) => edit({ timezone })}
error={problems.fields.timezone}
/>
<p className={styles.hint}>Every time on this screen is in this zone.</p>
</Section>
<Section title="Dinner">
<TimeField
draft={draft}
edit={edit}
problems={problems.fields}
name="dinner_time"
label="Dinner time"
hint="When dinner is on the table. Calendar events start then, and crock-pot dinners count back from it to show when to start."
/>
<TimeField
draft={draft}
edit={edit}
problems={problems.fields}
name="reminder_time"
label="Day-of reminder"
hint="When we remind you what's for dinner tonight."
/>
<TimeField
draft={draft}
edit={edit}
problems={problems.fields}
name="review_time"
label="Rotation check"
hint="When we ask whether to keep a new dinner: the day after you try a Quest-log dinner, we ask “Keep it in rotation?”"
/>
</Section>
<Section title="Every week">
<DayTimeField
draft={draft}
edit={edit}
problems={problems.fields}
day="plan_draft_dow"
time="plan_draft_time"
label="Plan draft"
hint="When the AI drafts next week's plan on the Draft board for you to adjust."
/>
<DayTimeField
draft={draft}
edit={edit}
problems={problems.fields}
day="suggestions_dow"
time="suggestions_time"
label="New suggestions"
hint="When a fresh batch of Side quests arrives to swipe through."
/>
<DayTimeField
draft={draft}
edit={edit}
problems={problems.fields}
day="chat_autosend_dow"
time="chat_autosend_time"
label="Google Chat Supply run"
hint="When the week's Supply run is sent to your Google Chat space."
/>
</Section>
<div className={styles.status} role="status">
{save.isSuccess && !dirty && 'Changes saved.'}
</div>
{save.isError && (
<div className={styles.errors} role="alert">
{Object.keys(problems.fields).length > 0 && (
<p className={styles.error}>Not saved: fix the settings marked below their fields.</p>
)}
{problems.other.map((reason) => (
<p key={reason} className={styles.error}>
Not saved: {reason}
</p>
))}
</div>
)}
<div className={styles.actions}>
<Button className={styles.save} disabled={!dirty || save.isPending} onClick={onSave}>
{save.isPending ? 'Saving…' : 'Save changes'}
</Button>
</div>
</fieldset>
)
}
function Section({ title, children }: { title: string; children: ReactNode }) {
const titleId = useId()
return (
<Card role="region" aria-labelledby={titleId} className={styles.card}>
<h2 id={titleId} className={styles.cardTitle}>
{title}
</h2>
{children}
</Card>
)
}
function FieldError({ id, error }: { id: string; error: string | undefined }) {
if (error === undefined) return null
return (
<p id={id} className={styles.error}>
{error}
</p>
)
}
type FieldProps = {
draft: HouseholdSettings
edit: (change: Partial<HouseholdSettings>) => void
problems: Partial<Record<HouseholdSettingsField, string>>
label: string
hint: string
}
type TimeFieldProps = FieldProps & { name: 'dinner_time' | 'reminder_time' | 'review_time' }
function TimeField({ draft, edit, problems, name, label, hint }: TimeFieldProps) {
const hintId = useId()
const errorId = useId()
const error = problems[name]
return (
<div className={styles.field}>
<TextInput
label={label}
type="time"
className={styles.time}
value={draft[name]}
aria-invalid={error !== undefined}
aria-describedby={error === undefined ? hintId : `${errorId} ${hintId}`}
onChange={(event) => edit({ [name]: event.target.value })}
/>
<FieldError id={errorId} error={error} />
<p id={hintId} className={styles.hint}>
{hint}
</p>
</div>
)
}
type DayTimeFieldProps = FieldProps & {
day: 'plan_draft_dow' | 'suggestions_dow' | 'chat_autosend_dow'
time: 'plan_draft_time' | 'suggestions_time' | 'chat_autosend_time'
}
function DayTimeField({ draft, edit, problems, day, time, label, hint }: DayTimeFieldProps) {
const hintId = useId()
const dayErrorId = useId()
const timeErrorId = useId()
const dayError = problems[day]
const timeError = problems[time]
return (
<div className={styles.field} role="group" aria-label={label}>
<div className={styles.columns}>
<Select
label={`${label} day`}
value={draft[day]}
aria-invalid={dayError !== undefined}
aria-describedby={dayError === undefined ? hintId : `${dayErrorId} ${hintId}`}
onChange={(event) => edit({ [day]: Number(event.target.value) })}
>
{days.map((name, index) => (
<option key={name} value={index}>
{name}
</option>
))}
</Select>
<TextInput
label={`${label} time`}
type="time"
className={`${styles.time} ${styles.pairedTime}`}
value={draft[time]}
aria-invalid={timeError !== undefined}
aria-describedby={timeError === undefined ? hintId : `${timeErrorId} ${hintId}`}
onChange={(event) => edit({ [time]: event.target.value })}
/>
</div>
<FieldError id={dayErrorId} error={dayError} />
<FieldError id={timeErrorId} error={timeError} />
<p id={hintId} className={styles.hint}>
{hint}
</p>
</div>
)
}
type TimeZoneFieldProps = {
value: string
saved: string
onChange: (zone: string) => void
error: string | undefined
}
// TimeZoneField shows the chosen zone and its current UTC offset; Change
// opens a searchable list of every IANA zone.
function TimeZoneField({ value, saved, onChange, error }: TimeZoneFieldProps) {
const [open, setOpen] = useState(false)
const labelId = useId()
const errorId = useId()
const offset = useMemo(() => utcOffset(value, new Date()), [value])
return (
<div className={styles.field}>
<span id={labelId} className={styles.groupLabel}>
Time zone
</span>
<div
className={styles.zoneRow}
role="group"
aria-labelledby={labelId}
aria-describedby={error === undefined ? undefined : errorId}
>
<span className={styles.zoneValue}>
<span className={styles.zoneName}>{zoneLabel(value)}</span>
<span className={styles.offset}>{offset}</span>
</span>
<button type="button" className={styles.change} onClick={() => setOpen(true)}>
Change
</button>
</div>
<FieldError id={errorId} error={error} />
{open && (
<TimeZoneSheet
value={value}
saved={saved}
onClose={() => setOpen(false)}
onChoose={(zone) => {
onChange(zone)
setOpen(false)
}}
/>
)}
</div>
)
}
type TimeZoneSheetProps = {
value: string
// The saved zone, offered even after another is chosen.
saved: string
onClose: () => void
onChoose: (zone: string) => void
}
function TimeZoneSheet({ value, saved, onClose, onChoose }: TimeZoneSheetProps) {
const [search, setSearch] = useState('')
const titleId = useId()
const options = useMemo(() => timeZoneOptions([saved, value], new Date()), [saved, value])
const shown = options.filter((option) => matchesZone(option, search))
return (
<Sheet open onClose={onClose} aria-labelledby={titleId}>
<div className={styles.sheet}>
<h2 id={titleId} className={styles.cardTitle}>
Choose a time zone
</h2>
<TextInput
label="Search time zones"
type="search"
value={search}
placeholder="City, region or UTC offset"
autoComplete="off"
spellCheck={false}
onChange={(event) => setSearch(event.target.value)}
/>
<p className={styles.hint} role="status">
{shown.length === 0 ? 'No time zone matches.' : `${shown.length} ${shown.length === 1 ? 'time zone' : 'time zones'}`}
</p>
<ul className={styles.zoneList} aria-label="Time zones">
{shown.map((option) => {
const current = option.zone === value
return (
<li key={option.zone}>
<button
type="button"
className={styles.zoneOption}
aria-pressed={current}
aria-label={`${zoneLabel(option.zone)}, ${option.offset}`}
onClick={() => onChoose(option.zone)}
>
<span className={styles.zoneName}>{zoneLabel(option.zone)}</span>
<span className={styles.offset}>{option.offset}</span>
{current && <Icon icon={Check} className={styles.check} />}
</button>
</li>
)
})}
</ul>
</div>
</Sheet>
)
}
+38
View File
@@ -0,0 +1,38 @@
/* The confirm sheet the settings screens share. */
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 560px;
margin: 0 auto;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.text {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-neutral-700);
}
.error {
margin: 0;
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
line-height: 1.4;
color: var(--color-accent-700);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useId, type ReactNode } from 'react'
import { useBlocker } from 'react-router'
import { Button } from '../ui/Button.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import styles from './LeaveGuard.module.css'
// LeaveGuard asks before leaving the screen with unsaved changes.
export function LeaveGuard({ dirty }: { dirty: boolean }) {
const blocker = useBlocker(
({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname,
)
useEffect(() => {
if (!dirty) return
const onBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault()
window.addEventListener('beforeunload', onBeforeUnload)
return () => window.removeEventListener('beforeunload', onBeforeUnload)
}, [dirty])
return (
<Confirm
open={blocker.state === 'blocked'}
title="Leave without saving?"
confirmLabel="Leave"
cancelLabel="Keep editing"
onCancel={() => blocker.reset?.()}
onConfirm={() => blocker.proceed?.()}
>
Your changes on this screen have not been saved.
</Confirm>
)
}
type ConfirmProps = {
open: boolean
title: string
children: ReactNode
confirmLabel: string
cancelLabel?: string
busy?: boolean
error?: string | null
onCancel: () => void
onConfirm: () => void
}
// Confirm is a sheet asking the household to confirm one action.
export function Confirm({
open,
title,
children,
confirmLabel,
cancelLabel = 'Cancel',
busy = false,
error = null,
onCancel,
onConfirm,
}: ConfirmProps) {
const titleId = useId()
return (
<Sheet open={open} onClose={onCancel} aria-labelledby={titleId}>
<div className={styles.sheet}>
<h2 id={titleId} className={styles.title}>
{title}
</h2>
<p className={styles.text}>{children}</p>
{error !== null && (
<p className={styles.error} role="alert">
{error}
</p>
)}
<div className={styles.actions}>
<Button variant="outline" onClick={onCancel}>
{cancelLabel}
</Button>
<Button disabled={busy} onClick={onConfirm}>
{confirmLabel}
</Button>
</div>
</div>
</Sheet>
)
}
+275
View File
@@ -0,0 +1,275 @@
import { useEffect, useId, useState } from 'react'
import { ApiError } from '../api/index.ts'
import { Button } from '../ui/Button.tsx'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { TextInput } from '../ui/TextInput.tsx'
import {
maskedKey,
recipeSearchProviderLabels,
useRecipeSearchSettings,
useSaveRecipeSearchSettings,
useTestRecipeSearch,
type RecipeSearchProvider,
type RecipeSearchSettings,
type RecipeSearchSettingsPatch,
} from './api.ts'
import styles from './AISettingsPage.module.css'
const providerOptions = Object.entries(recipeSearchProviderLabels).map(([value, label]) => ({
value: value as RecipeSearchProvider,
label,
}))
type RecipeSearchCardProps = {
// Called with whether the card holds unsaved changes.
onDirtyChange: (dirty: boolean) => void
}
// RecipeSearchCard is where recipes are searched for: a SearXNG instance or
// Brave. The provider, SearXNG URL and a new Brave key are edited as a draft
// and saved together; the client only ever sees the stored key's last four
// characters. Test searches with the saved settings.
export function RecipeSearchCard({ onDirtyChange }: RecipeSearchCardProps) {
const settings = useRecipeSearchSettings()
const titleId = useId()
return (
<Card className={styles.card} aria-labelledby={titleId} role="region">
<h2 id={titleId} className={styles.cardTitle}>
Recipe search
</h2>
<p className={styles.hint}>Where MealQuest looks for real recipes behind each Side quest.</p>
{settings.isPending && <Skeleton className={styles.cardLoading} />}
{settings.isError && (
<ErrorBanner onRetry={() => void settings.refetch()}>
Recipe search settings could not load: {settings.error.message}
</ErrorBanner>
)}
{settings.isSuccess && <RecipeSearchForm saved={settings.data} onDirtyChange={onDirtyChange} />}
</Card>
)
}
type Draft = {
provider: RecipeSearchProvider
base_url: string
// A new Brave key to store, null when the stored one stays.
api_key: string | null
}
// draftOf starts a household that has not set up recipe search on SearXNG,
// the sidecar every deployment runs. With no URL saved the field holds the
// deployment's SearXNG URL, which saving stores.
function draftOf(saved: RecipeSearchSettings): Draft {
return {
provider: saved.provider ?? 'searxng',
base_url: saved.base_url ?? saved.default_base_url ?? '',
api_key: null,
}
}
// patchOf is the PATCH body for what draft changes. Only the chosen
// provider's field is sent: the other provider's is left as it is.
function patchOf(saved: RecipeSearchSettings, draft: Draft): RecipeSearchSettingsPatch {
const patch: RecipeSearchSettingsPatch = {}
if (draft.provider !== saved.provider) patch.provider = draft.provider
if (draft.provider === 'searxng' && draft.base_url !== (saved.base_url ?? '')) patch.base_url = draft.base_url
if (draft.provider === 'brave' && draft.api_key !== null) patch.api_key = draft.api_key
return patch
}
function sameDraft(a: Draft, b: Draft): boolean {
return a.provider === b.provider && a.base_url === b.base_url && a.api_key === b.api_key
}
function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSettings } & RecipeSearchCardProps) {
const [draft, setDraft] = useState(() => draftOf(saved))
const save = useSaveRecipeSearchSettings()
const test = useTestRecipeSearch()
const patch = patchOf(saved, draft)
const dirty = Object.keys(patch).length > 0
// Only the household's own edits are unsaved changes. Before recipe search
// is set up the untouched draft already differs from what is saved — it
// suggests SearXNG and the deployment's URL — but leaving it loses nothing.
const edited = dirty && !sameDraft(draft, draftOf(saved))
useEffect(() => {
onDirtyChange(edited)
}, [edited, onDirtyChange])
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
// An edit clears the last save and test results: they describe the saved
// settings, not the draft.
function edit(change: Partial<Draft>) {
save.reset()
test.reset()
setDraft((current) => ({ ...current, ...change }))
}
function onSave() {
save.mutate(patch, {
onSuccess: (settings) => {
setDraft(draftOf(settings))
test.reset()
},
})
}
return (
<fieldset className={styles.lock} disabled={save.isPending}>
<Segmented
label="Recipe search provider"
options={providerOptions}
value={draft.provider}
onValueChange={(provider) => edit({ provider })}
/>
{draft.provider === 'searxng' ? (
<TextInput
label="SearXNG URL"
type="url"
className={styles.mono}
value={draft.base_url}
placeholder="https://"
autoComplete="off"
spellCheck={false}
onChange={(event) => edit({ base_url: event.target.value })}
/>
) : (
<BraveKey
last4={saved.api_key_last4}
newKey={draft.api_key}
onKey={(api_key) => edit({ api_key })}
/>
)}
<div className={styles.status} role="status">
{save.isSuccess && !dirty && 'Recipe search saved.'}
{test.isSuccess && `Found ${test.data.count} ${test.data.count === 1 ? 'recipe' : 'recipes'} for '${test.data.query}'`}
</div>
{save.isError && (
<p className={styles.error} role="alert">
{saveErrorMessage(save.error)}
</p>
)}
{test.isError && (
<p className={styles.error} role="alert">
{testErrorMessage(test.error)}
</p>
)}
{dirty && saved.provider !== null && <p className={styles.note}>Save your changes to test them.</p>}
<div className={styles.cardActions}>
<Button
variant="outline"
disabled={dirty || saved.provider === null || test.isPending}
onClick={() => test.mutate()}
>
{test.isPending ? 'Testing…' : 'Test'}
</Button>
<Button disabled={!dirty || save.isPending} onClick={onSave}>
{save.isPending ? 'Saving…' : 'Save recipe search'}
</Button>
</div>
</fieldset>
)
}
type BraveKeyProps = {
last4: string | null
newKey: string | null
onKey: (key: string | null) => void
}
// BraveKey shows the stored key masked, or the new one waiting to be saved,
// and takes a new key in a sheet. The key is sent only when the card is
// saved.
function BraveKey({ last4, newKey, onKey }: BraveKeyProps) {
const [replacing, setReplacing] = useState(false)
const [key, setKey] = useState('')
const labelId = useId()
const sheetTitleId = useId()
function close() {
setReplacing(false)
setKey('')
}
let shown = 'No key stored'
if (newKey !== null) shown = maskedKey(newKey.slice(-4))
else if (last4 !== null) shown = maskedKey(last4)
return (
<div className={styles.key}>
<span id={labelId} className={styles.label}>
Brave API key
</span>
<div className={styles.keyRow} role="group" aria-labelledby={labelId}>
<span className={styles.keyValue}>{shown}</span>
<button type="button" className={styles.replace} onClick={() => setReplacing(true)}>
{last4 === null && newKey === null ? 'Add key' : 'Replace'}
</button>
</div>
{newKey !== null && (
<div className={styles.keyStatus}>
<p className={styles.note}>New key not saved yet</p>
<button type="button" className={styles.link} onClick={() => onKey(null)}>
Discard
</button>
</div>
)}
<Sheet open={replacing} onClose={close} aria-labelledby={sheetTitleId}>
<form
className={styles.sheet}
onSubmit={(event) => {
event.preventDefault()
onKey(key)
close()
}}
>
<h2 id={sheetTitleId} className={styles.sheetTitle}>
{last4 === null ? 'Add Brave API key' : 'Replace Brave API key'}
</h2>
<p className={styles.sheetText}>
The key is stored on the server when you save recipe search. This device only ever sees its last four
characters.
</p>
<TextInput
label="Brave API key"
type="password"
className={styles.mono}
value={key}
autoComplete="off"
spellCheck={false}
onChange={(event) => setKey(event.target.value)}
/>
<div className={styles.sheetActions}>
<Button variant="outline" onClick={close}>
Cancel
</Button>
<Button type="submit" disabled={key === ''}>
Use this key
</Button>
</div>
</form>
</Sheet>
</div>
)
}
function saveErrorMessage(error: Error): string {
if (error instanceof ApiError && error.code === 'invalid_settings') return `Not saved: ${error.message}.`
return `Not saved: ${error.message}`
}
// testErrorMessage says why POST /settings/recipe-search/test failed, by
// the error code the server names (internal/server/recipesearch.go).
function testErrorMessage(error: Error): string {
if (error instanceof ApiError && error.code === 'not_configured') {
return 'Test failed: recipe search is not set up yet.'
}
return `Test failed: ${error.message}`
}
+153
View File
@@ -0,0 +1,153 @@
/* Settings home, handoff mock 1p. */
.page {
max-width: 560px;
display: flex;
flex-direction: column;
}
.title {
margin: 0;
padding-top: 6px;
font-family: var(--font-heading);
font-size: 28px;
font-weight: 400;
}
.banner {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
padding: 14px 16px;
border-radius: 26px;
background: var(--color-accent-2-100);
color: var(--color-accent-2-900);
}
.avatar {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--color-accent-2-300);
color: var(--color-accent-2-900);
font-family: var(--font-heading);
font-size: 18px;
}
.bannerText {
flex: 1;
display: flex;
flex-direction: column;
margin: 0;
font-family: var(--font-body);
font-size: 13px;
line-height: 1.35;
}
.bannerTitle {
font-size: 15px;
font-weight: 700;
}
.alert {
margin-top: 14px;
}
.groupLabel {
margin: 0;
padding: 18px 4px 8px;
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-neutral-700);
}
.group {
padding: 0;
border-radius: 26px;
}
.admin {
box-shadow: inset 0 0 0 2px var(--color-accent-300);
}
.rows {
margin: 0;
padding: 0;
list-style: none;
}
/* Rows in one card are split by a hairline inset to the row padding. */
.item + .item {
border-top: 1px solid var(--color-neutral-300);
margin: 0 16px;
}
.item + .item .row {
padding-left: 0;
padding-right: 0;
}
.row {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 14px;
min-width: 44px;
min-height: 44px;
padding: 14px 16px;
border-radius: 26px;
color: var(--color-text);
text-decoration: none;
}
.icon {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
background: var(--color-bg);
}
.icon svg {
width: 18px;
height: 18px;
}
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
font-family: var(--font-body);
}
.rowTitle {
font-size: 15px;
font-weight: 700;
}
.status {
font-size: 12px;
color: var(--color-neutral-700);
}
.statusSkeleton {
width: 60%;
height: 12px;
margin-top: 3px;
background: var(--color-neutral-300);
}
.tag {
flex: none;
}
+267
View File
@@ -0,0 +1,267 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { formatClockTime, modelDisplayName } from './api.ts'
import { SettingsPage } from './SettingsPage.tsx'
const HOUSEHOLD = HOUSEHOLD_SETTINGS
const AI = AI_SETTINGS
afterEach(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
})
const REGULARS_PATH = '/api/v1/dinners?status=regular'
const CALENDAR_PATH = '/api/v1/settings/calendar'
const NOTIFICATIONS_PATH = '/api/v1/settings/notifications'
// stubSettings answers as today's server does (see SETTINGS_API), with
// overrides for the paths a test changes.
function stubSettings(overrides: Record<string, () => Response> = {}) {
return stubFetchByPath({
...SETTINGS_API,
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD),
'/api/v1/settings/ai': () => jsonResponse(200, AI),
...overrides,
})
}
function CurrentPath() {
return <p data-testid="path">{useLocation().pathname}</p>
}
// renderSettings renders the settings home on a 390 px phone (mock 1p) in a
// data router (the hidden sub-tabs' leave guard needs one);
// SettingsTabs.test.tsx covers 768 px and up.
function renderSettings() {
mockViewport(390)
const api = createApiClient(fakeLocation('/settings'), createNetworkStatus(window), createVerifiedSession(localStorage))
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings">
<Routes>
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<CurrentPath />} />
</Routes>
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
function row(name: string) {
return screen.getByRole('link', { name: new RegExp(`^${name}`) })
}
describe('SettingsPage', () => {
it('renders the title and the shared household banner', async () => {
stubSettings()
renderSettings()
expect(screen.getByRole('heading', { level: 1, name: 'Settings' })).toBeInTheDocument()
expect(screen.getByText('Our kitchen')).toBeInTheDocument()
expect(screen.getByText('Shared household · changes apply to everyone')).toBeInTheDocument()
expect(await screen.findByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
})
it('groups the rows under Household, Alerts and Admin', async () => {
stubSettings()
renderSettings()
const group = (name: string) => within(screen.getByRole('region', { name }))
expect(group('Household').getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([
'/settings/regulars',
'/settings/calendar',
'/settings/household',
])
expect(group('Alerts').getByRole('link')).toHaveAttribute('href', '/settings/notifications')
expect(group('Admin').getByRole('link')).toHaveAttribute('href', '/settings/ai')
await screen.findByText('Anthropic · Claude Sonnet')
})
it('reads every status line from the server', async () => {
const fetchMock = stubSettings()
renderSettings()
expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
expect(within(row('AI assistant')).getByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
REGULARS_PATH,
'/api/v1/settings/ai',
'/api/v1/settings/ai/models',
CALENDAR_PATH,
'/api/v1/settings/household',
NOTIFICATIONS_PATH,
'/api/v1/settings/recipe-search',
])
})
it('shows the not-yet-available states while the server has no endpoint for them', async () => {
stubSettings()
renderSettings()
expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument()
expect(await within(row('Google Calendar')).findByText('Not connected')).toBeInTheDocument()
expect(
await within(row('Notifications & Google Chat')).findByText('Push off · Google Chat not connected'),
).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('shows the Regulars on rotation, the connected calendar and the notifications once served', async () => {
stubSettings({
[REGULARS_PATH]: () => jsonResponse(200, { dinners: Array.from({ length: 7 }, (_, i) => ({ id: `d${i}` })) }),
[CALENDAR_PATH]: () => jsonResponse(200, { connected: true, calendar_name: 'Dinners calendar' }),
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: true, chat_space: 'Dinner Crew space' }),
})
renderSettings()
expect(await within(row('Regular dinners')).findByText('7 on rotation')).toBeInTheDocument()
expect(await within(row('Google Calendar')).findByText('Connected · Dinners calendar')).toBeInTheDocument()
expect(
await within(row('Notifications & Google Chat')).findByText('Push on · Dinner Crew space'),
).toBeInTheDocument()
})
it('shows no Regulars, a disconnected calendar and push off with no Google Chat space', async () => {
stubSettings({
[REGULARS_PATH]: () => jsonResponse(200, { dinners: [] }),
[CALENDAR_PATH]: () => jsonResponse(200, { connected: false }),
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: false, chat_space: null }),
})
renderSettings()
expect(await within(row('Regular dinners')).findByText('None on rotation yet')).toBeInTheDocument()
expect(await within(row('Google Calendar')).findByText('Not connected')).toBeInTheDocument()
expect(
await within(row('Notifications & Google Chat')).findByText('Push off · Google Chat not connected'),
).toBeInTheDocument()
})
it('reports a status endpoint failure other than a missing endpoint', async () => {
stubSettings({ [CALENDAR_PATH]: () => errorResponse(500, 'internal') })
renderSettings()
expect(await screen.findByRole('alert')).toHaveTextContent('Settings could not load: internal message')
expect(row('Google Calendar')).toHaveAccessibleName('Google Calendar')
})
it('refuses a connected calendar without a name', async () => {
stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { connected: true }) })
renderSettings()
expect(await screen.findByRole('alert')).toHaveTextContent('response is not a calendar status')
})
it('shows another provider and model as the settings name them', async () => {
stubSettings({
'/api/v1/settings/ai': () => jsonResponse(200, { ...AI, provider: 'openai_compatible', model: 'llama3.1:8b' }),
})
renderSettings()
expect(await screen.findByText('OpenAI-compatible · llama3.1:8b')).toBeInTheDocument()
})
it('says no model is chosen for the seeded AI settings', async () => {
stubSettings({ '/api/v1/settings/ai': () => jsonResponse(200, { ...AI, model: '' }) })
renderSettings()
expect(await within(row('AI assistant')).findByText('Anthropic · No model chosen')).toBeInTheDocument()
})
it('marks the AI assistant row Admin and rings its card in grape-300', async () => {
stubSettings()
renderSettings()
expect(within(row('AI assistant')).getByText('Admin')).toBeInTheDocument()
const adminCard = screen.getByRole('region', { name: 'Admin' }).querySelector('ul')!.parentElement!
expect(declaredStyle(adminCard).boxShadow).toBe('inset 0 0 0 2px var(--color-accent-300)')
const householdCard = screen.getByRole('region', { name: 'Household' }).querySelector('ul')!.parentElement!
expect(declaredStyle(householdCard).boxShadow).toBe('')
await screen.findByText('Anthropic · Claude Sonnet')
})
it.each([
['Regular dinners', '/settings/regulars'],
['Google Calendar', '/settings/calendar'],
['Household', '/settings/household'],
['Notifications & Google Chat', '/settings/notifications'],
['AI assistant', '/settings/ai'],
])('the %s row navigates to %s', async (name, path) => {
stubSettings()
renderSettings()
await screen.findByText('Anthropic · Claude Sonnet')
fireEvent.click(row(name))
expect(screen.getByTestId('path')).toHaveTextContent(path)
})
it('gives every row a tap target of at least 44 px', async () => {
stubSettings()
renderSettings()
await screen.findByText('Anthropic · Claude Sonnet')
for (const link of screen.getAllByRole('link')) expectMinHitArea(link)
})
it('reports a failed status with a retry that loads it', async () => {
let householdCalls = 0
stubSettings({
'/api/v1/settings/household': () => {
householdCalls += 1
return householdCalls === 1 ? errorResponse(500, 'internal') : jsonResponse(200, HOUSEHOLD)
},
})
renderSettings()
expect(await screen.findByRole('alert')).toHaveTextContent('Settings could not load: internal message')
expect(screen.getByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
expect(row('Household')).toHaveAccessibleName('Household')
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('refuses an AI provider it has no name for', async () => {
stubSettings({ '/api/v1/settings/ai': () => jsonResponse(200, { ...AI, provider: 'mystery', model: 'm1' }) })
renderSettings()
expect(await screen.findByRole('alert')).toHaveTextContent('unknown AI provider "mystery"')
expect(row('AI assistant')).toHaveAccessibleName('AI assistant Admin')
})
it('refuses a dinner time that is not HH:MM', async () => {
stubSettings({
'/api/v1/settings/household': () => jsonResponse(200, { ...HOUSEHOLD, timezone: 'UTC', dinner_time: '5:30pm' }),
})
renderSettings()
expect(await screen.findByRole('alert')).toHaveTextContent('dinner_time "5:30pm" is not HH:MM')
})
})
describe('modelDisplayName', () => {
it.each([
['claude-sonnet-4-5', 'Claude Sonnet'],
['claude-opus-4-1-20250805', 'Claude Opus'],
['claude-3-5-haiku-20241022', 'Claude Haiku'],
['claude-fable-5-1', 'Claude Fable'],
['gpt-4o', 'gpt-4o'],
['anthropic/claude-sonnet-4.5', 'anthropic/claude-sonnet-4.5'],
])('names %s %s', (model, name) => {
expect(modelDisplayName(model)).toBe(name)
})
})
describe('formatClockTime', () => {
it.each([
['00:05', '12:05 am'],
['09:00', '9:00 am'],
['12:00', '12:00 pm'],
['17:30', '5:30 pm'],
['23:59', '11:59 pm'],
])('formats %s as %s', (time, formatted) => {
expect(formatClockTime(time)).toBe(formatted)
})
it('refuses a time that is not HH:MM', () => {
expect(() => formatClockTime('24:00')).toThrow('not a 24-hour HH:MM time')
})
})
+167
View File
@@ -0,0 +1,167 @@
import type { UseQueryResult } from '@tanstack/react-query'
import type { LucideIcon } from 'lucide-react'
import { Bell, Calendar, ChevronRight, Crosshair, Heart, House } from 'lucide-react'
import { useId, type ReactNode } from 'react'
import { Link } from 'react-router'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import { useBreakpoint } from '../shell/useBreakpoint.ts'
import {
aiStatus,
calendarStatus,
formatClockTime,
notificationsStatus,
regularDinnersStatus,
useAISettings,
useCalendarStatus,
useHouseholdSettings,
useNotificationsStatus,
useRegularDinnersCount,
} from './api.ts'
import styles from './SettingsPage.module.css'
import { SettingsTabs } from './SettingsTabs.tsx'
// Settings home: the list of settings pages on mobile (mock 1p), one page
// with sub-tabs from 768 px (mock 2e). The sub-tabs stay mounted, hidden, on
// mobile, so an unsaved draft survives the viewport crossing 768 px and its
// leave guard keeps asking before the list navigates away.
export function SettingsPage() {
const mobile = useBreakpoint() === 'mobile'
return (
<>
{mobile && <SettingsList />}
<SettingsTabs hidden={mobile} />
</>
)
}
// The mobile settings list (handoff mock 1p). Each row opens its settings
// page and shows a live status line. Regular dinners, Google Calendar and
// Notifications & Google Chat read endpoints their features have not served
// yet; until then each shows its not-yet-available state (see api.ts).
function SettingsList() {
const household = useHouseholdSettings()
const ai = useAISettings()
const regulars = useRegularDinnersCount()
const calendar = useCalendarStatus()
const notifications = useNotificationsStatus()
const failed = [regulars, calendar, household, notifications, ai].filter((query) => query.isError)
return (
<div className={styles.page}>
<h1 className={styles.title}>Settings</h1>
<div className={styles.banner}>
<span className={styles.avatar} aria-hidden="true">
K
</span>
<p className={styles.bannerText}>
<strong className={styles.bannerTitle}>Our kitchen</strong>
<span>Shared household · changes apply to everyone</span>
</p>
</div>
{failed.length > 0 && (
<div className={styles.alert}>
<ErrorBanner onRetry={() => failed.forEach((query) => void query.refetch())}>
Settings could not load: {failed.map((query) => query.error?.message).join('; ')}
</ErrorBanner>
</div>
)}
<Group label="Household">
<SettingsRow
to="/settings/regulars"
icon={Heart}
title="Regular dinners"
status={status(regulars, regularDinnersStatus)}
/>
<SettingsRow
to="/settings/calendar"
icon={Calendar}
title="Google Calendar"
status={status(calendar, calendarStatus)}
/>
<SettingsRow
to="/settings/household"
icon={House}
title="Household"
status={status(household, (s) => `${s.timezone} · Dinner at ${formatClockTime(s.dinner_time)}`)}
/>
</Group>
<Group label="Alerts">
<SettingsRow
to="/settings/notifications"
icon={Bell}
title="Notifications & Google Chat"
status={status(notifications, notificationsStatus)}
/>
</Group>
<Group label="Admin" admin>
<SettingsRow
to="/settings/ai"
icon={Crosshair}
title="AI assistant"
status={status(ai, aiStatus)}
tag={<Tag className={styles.tag}>Admin</Tag>}
/>
</Group>
</div>
)
}
// status is a row's status line: a skeleton while its query loads, the
// formatted data once it has, and nothing when it failed (the error banner
// says why).
function status<T>(query: UseQueryResult<T>, format: (data: T) => string): ReactNode {
if (query.isPending) return <Skeleton className={styles.statusSkeleton} />
if (query.isError) return null
return format(query.data)
}
type GroupProps = {
label: string
// The Admin group's card carries the grape-300 inset ring.
admin?: boolean
children: ReactNode
}
function Group({ label, admin = false, children }: GroupProps) {
const id = useId()
return (
<section aria-labelledby={id}>
<h2 id={id} className={styles.groupLabel}>
{label}
</h2>
<Card className={[styles.group, admin && styles.admin].filter(Boolean).join(' ')}>
<ul className={styles.rows}>{children}</ul>
</Card>
</section>
)
}
type SettingsRowProps = {
to: string
icon: LucideIcon
title: string
status?: ReactNode
tag?: ReactNode
}
function SettingsRow({ to, icon, title, status, tag }: SettingsRowProps) {
return (
<li className={styles.item}>
<Link to={to} className={styles.row}>
<span className={styles.icon}>
<Icon icon={icon} />
</span>
<span className={styles.body}>
<span className={styles.rowTitle}>{title}</span>
{status && <span className={styles.status}>{status}</span>}
</span>
{tag}
<Icon icon={ChevronRight} />
</Link>
</li>
)
}
+118
View File
@@ -0,0 +1,118 @@
/* Settings at tablet and desktop widths, handoff mock 2e. */
.page {
box-sizing: border-box;
min-height: 100%;
display: flex;
flex-direction: column;
gap: 20px;
}
.header {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px;
}
.heading {
flex: 1;
min-width: 0;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 40px;
font-weight: 400;
line-height: 1.1;
}
.shared {
margin: 6px 0 0;
font-family: var(--font-body);
font-size: 14px;
color: var(--color-neutral-800);
}
.tabs {
display: flex;
gap: 6px;
}
.tab {
min-width: 44px;
min-height: 44px;
padding: 0 16px;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-text);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.selected {
background: var(--color-text);
color: var(--color-bg);
}
/* A page's or panel's display would override the hidden attribute. */
.page[hidden],
.single[hidden],
.columns[hidden] {
display: none;
}
.single {
max-width: 560px;
display: flex;
flex-direction: column;
gap: 18px;
}
/* Notifications on the left; the AI assistant and Recipe search on the
right, the AI assistant card growing to fill the column. */
.columns {
flex: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
align-items: stretch;
gap: 20px;
}
.column {
min-width: 0;
display: flex;
flex-direction: column;
gap: 20px;
}
/* Mock 2e's cards: 24 px padding, 30 px corners. */
.column > * {
padding: 24px;
border-radius: 30px;
}
.placeholder {
display: flex;
flex-direction: column;
gap: 14px;
}
.cardTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.hint {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-neutral-700);
}
+193
View File
@@ -0,0 +1,193 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { AI_MODELS, AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { SettingsPage } from './SettingsPage.tsx'
type Responder = (body: unknown) => Response
// stubAPI answers "METHOD path" from responders, each called with the
// request's decoded JSON body; the settings the page loads answer their
// fixtures unless overridden. Any other request fails the test.
function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch> {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'GET /api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined
return Promise.resolve(respond(body))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
afterEach(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
})
// renderAt renders /settings at width px in a data router (the page's leave
// guard needs one), after loading /me for the CSRF token writes need.
// It returns the function that resizes the viewport.
async function renderAt(width: number) {
const resize = mockViewport(width)
const api = createApiClient(fakeLocation('/settings'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings">
<SettingsPage />
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
return resize
}
const tab = (name: string) => screen.getByRole('tab', { name })
// panel is the panel a tab controls, shown or hidden.
function panel(name: string): HTMLElement {
const id = tab(name).getAttribute('aria-controls')
const element = id === null ? null : document.getElementById(id)
if (element === null) throw new Error(`tab "${name}" controls no panel`)
expect(element).toHaveAttribute('role', 'tabpanel')
return element
}
describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
it('shows the title, the shared line and the sub-tabs, Notifications & AI first', async () => {
stubAPI()
await renderAt(width)
expect(screen.getByRole('heading', { level: 1, name: 'Settings' })).toBeInTheDocument()
expect(screen.getByText('Changes apply to everyone in the household.')).toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
const tabs = within(screen.getByRole('tablist', { name: 'Settings sections' })).getAllByRole('tab')
expect(tabs.map((t) => t.textContent)).toEqual(['Household', 'Regular dinners', 'Notifications & AI'])
expect(tab('Notifications & AI')).toHaveAttribute('aria-selected', 'true')
tabs.forEach(expectMinHitArea)
const columns = panel('Notifications & AI')
expect(columns).toBeVisible()
const [left, right] = Array.from(columns.children)
expect(within(left as HTMLElement).getByRole('region', { name: 'Notifications & Google Chat' })).toBeInTheDocument()
const ai = within(right as HTMLElement).getByRole('region', { name: 'AI assistant' })
expect(within(ai).getByText('Admin')).toBeInTheDocument()
expect(await within(ai).findByRole('combobox', { name: 'Provider' })).toHaveValue('anthropic')
expect(within(ai).getByRole('textbox', { name: 'System prompt' })).toHaveValue(AI_SETTINGS.system_prompt)
expect(within(right as HTMLElement).getByRole('region', { name: 'Recipe search' })).toBeInTheDocument()
})
it('switches tabs by click and by arrow keys', async () => {
stubAPI()
await renderAt(width)
fireEvent.click(tab('Household'))
expect(tab('Household')).toHaveAttribute('aria-selected', 'true')
expect(panel('Household')).toBeVisible()
expect(panel('Notifications & AI')).not.toBeVisible()
expect(await within(panel('Household')).findByRole('button', { name: 'Save changes' })).toBeInTheDocument()
fireEvent.keyDown(tab('Household'), { key: 'ArrowRight' })
expect(tab('Regular dinners')).toHaveAttribute('aria-selected', 'true')
expect(tab('Regular dinners')).toHaveFocus()
expect(panel('Regular dinners')).toBeVisible()
expect(within(panel('Regular dinners')).getByRole('region', { name: 'Regular dinners' })).toBeInTheDocument()
expect(panel('Household')).not.toBeVisible()
fireEvent.keyDown(tab('Regular dinners'), { key: 'End' })
expect(tab('Notifications & AI')).toHaveAttribute('aria-selected', 'true')
fireEvent.keyDown(tab('Notifications & AI'), { key: 'ArrowRight' })
expect(tab('Household')).toHaveAttribute('aria-selected', 'true')
expect(tab('Household')).toHaveFocus()
})
it('saves the AI assistant card through the AI settings API', async () => {
const fetchMock = stubAPI({
'PATCH /api/v1/settings/ai': (body) => jsonResponse(200, { ...AI_SETTINGS, ...(body as object) }),
})
await renderAt(width)
const ai = within(screen.getByRole('region', { name: 'AI assistant' }))
const prompt = await ai.findByRole('textbox', { name: 'System prompt' })
const save = ai.getByRole('button', { name: 'Save changes' })
expect(save).toBeDisabled()
fireEvent.change(prompt, { target: { value: 'Plan dinners for us.' } })
fireEvent.click(save)
expect(await ai.findByText('Changes saved.')).toBeInTheDocument()
const writes = fetchMock.mock.calls.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
expect(writes.map(([input, init]) => [init?.method, String(input), init?.body])).toEqual([
['PATCH', '/api/v1/settings/ai', JSON.stringify({ system_prompt: 'Plan dinners for us.' })],
])
expect(prompt).toHaveValue('Plan dinners for us.')
expect(save).toBeDisabled()
})
it('keeps an unsaved draft while another tab is shown', async () => {
stubAPI()
await renderAt(width)
const prompt = await screen.findByRole('textbox', { name: 'System prompt' })
fireEvent.change(prompt, { target: { value: 'A draft' } })
fireEvent.click(tab('Household'))
fireEvent.click(tab('Notifications & AI'))
expect(screen.getByRole('textbox', { name: 'System prompt' })).toHaveValue('A draft')
})
})
describe('SettingsPage across the 768 px breakpoint', () => {
it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => {
stubAPI({
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'GET /api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
})
const resize = await renderAt(767)
expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai')
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
resize(768)
expect(screen.getByRole('tablist', { name: 'Settings sections' })).toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
})
it('keeps an unsaved draft and its leave guard across 1180 px, 390 px and back', async () => {
stubAPI({
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'GET /api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
})
const resize = await renderAt(1180)
fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), {
target: { value: 'A draft' },
})
resize(390)
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('link', { name: /^AI assistant/ }))
const confirm = await screen.findByRole('dialog', { name: 'Leave without saving?' })
fireEvent.click(within(confirm).getByRole('button', { name: 'Keep editing' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
resize(1180)
expect(screen.getByRole('textbox', { name: 'System prompt' })).toHaveValue('A draft')
const unload = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(unload)
expect(unload.defaultPrevented).toBe(true)
})
})
+138
View File
@@ -0,0 +1,138 @@
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
import { Card } from '../ui/Card.tsx'
import { AIAssistantCard } from './AISettingsPage.tsx'
import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx'
import { LeaveGuard } from './LeaveGuard.tsx'
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
import styles from './SettingsTabs.module.css'
type Tab = 'household' | 'regulars' | 'notifications-ai'
const tabs: readonly { value: Tab; label: string }[] = [
{ value: 'household', label: 'Household' },
{ value: 'regulars', label: 'Regular dinners' },
{ value: 'notifications-ai', label: 'Notifications & AI' },
]
const step: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1 }
// Settings at tablet and desktop widths (handoff mock 2e): one page with
// sub-tabs instead of the mobile list. Every tab stays mounted while hidden,
// so a draft survives switching tabs, and one leave guard covers them all.
// hidden hides the page but keeps it mounted, drafts and leave guard included.
export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
const [tab, setTab] = useState<Tab>('notifications-ai')
const [householdDirty, setHouseholdDirty] = useState(false)
const [aiDirty, setAIDirty] = useState(false)
const [recipeSearchDirty, setRecipeSearchDirty] = useState(false)
const refs = useRef<(HTMLButtonElement | null)[]>([])
const id = useId()
const tabId = (value: Tab) => `${id}-tab-${value}`
const panelId = (value: Tab) => `${id}-panel-${value}`
// Arrow keys move between the tabs, wrapping at the ends; Home and End
// jump to the first and last. Moving selects the tab.
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const selected = tabs.findIndex((t) => t.value === tab)
const delta = step[event.key]
let next: number
if (delta !== undefined) next = (selected + delta + tabs.length) % tabs.length
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = tabs.length - 1
else return
const nextTab = tabs[next]
if (!nextTab) throw new Error(`Settings tabs: no tab at index ${next}`)
event.preventDefault()
setTab(nextTab.value)
refs.current[next]?.focus()
}
function panel(value: Tab, className: string | undefined, children: ReactNode) {
return (
<div
id={panelId(value)}
role="tabpanel"
aria-labelledby={tabId(value)}
hidden={tab !== value}
className={className}
>
{children}
</div>
)
}
return (
<div className={styles.page} hidden={hidden}>
<header className={styles.header}>
<div className={styles.heading}>
<h1 className={styles.title}>Settings</h1>
<p className={styles.shared}>Changes apply to everyone in the household.</p>
</div>
<div role="tablist" aria-label="Settings sections" className={styles.tabs} onKeyDown={onKeyDown}>
{tabs.map(({ value, label }, index) => (
<button
key={value}
ref={(el) => {
refs.current[index] = el
}}
id={tabId(value)}
type="button"
role="tab"
aria-selected={tab === value}
aria-controls={panelId(value)}
tabIndex={tab === value ? 0 : -1}
className={tab === value ? `${styles.tab} ${styles.selected}` : styles.tab}
onClick={() => setTab(value)}
>
{label}
</button>
))}
</div>
</header>
{panel('household', styles.single, <HouseholdSettingsSections onDirtyChange={setHouseholdDirty} />)}
{panel(
'regulars',
styles.single,
// TODO(Feature 12): embed the Regular dinners screen here once Feature
// 12 builds it.
<Placeholder title="Regular dinners">Managing the household's Regulars arrives in a later update.</Placeholder>,
)}
{panel(
'notifications-ai',
styles.columns,
<>
<div className={styles.column}>
{/* TODO(Feature 19): the Notifications matrix and Google Chat
webhook cards replace this placeholder. */}
<Placeholder title="Notifications & Google Chat">
Notification and Google Chat webhook settings arrive in a later update.
</Placeholder>
</div>
<div className={styles.column}>
<AIAssistantCard onDirtyChange={setAIDirty} />
<RecipeSearchCard onDirtyChange={setRecipeSearchDirty} />
</div>
</>,
)}
<LeaveGuard dirty={householdDirty || aiDirty || recipeSearchDirty} />
</div>
)
}
// Placeholder is a neutral card holding a section's place until the feature
// that owns it renders it.
function Placeholder({ title, children }: { title: string; children: ReactNode }) {
const titleId = useId()
return (
<Card className={styles.placeholder} aria-labelledby={titleId} role="region">
<h2 id={titleId} className={styles.cardTitle}>
{title}
</h2>
<p className={styles.hint}>{children}</p>
</Card>
)
}
+546
View File
@@ -0,0 +1,546 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ApiError, useApi, type ApiClient } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
export const HOUSEHOLD_SETTINGS_PATH = '/api/v1/settings/household'
export const AI_SETTINGS_PATH = '/api/v1/settings/ai'
export const AI_MODELS_PATH = '/api/v1/settings/ai/models'
export const AI_KEY_PATH = '/api/v1/settings/ai/api-key'
export const AI_VERIFY_PATH = '/api/v1/settings/ai/verify'
export const AI_RESET_PROMPT_PATH = '/api/v1/settings/ai/reset-prompt'
export const RECIPE_SEARCH_PATH = '/api/v1/settings/recipe-search'
export const RECIPE_SEARCH_TEST_PATH = '/api/v1/settings/recipe-search/test'
// TODO(Feature 12): GET /api/v1/dinners?status=regular is Feature 12's
// dinners read API. Until it lands the server answers 404 and the Regular
// dinners row says the count is unavailable.
export const REGULAR_DINNERS_PATH = '/api/v1/dinners?status=regular'
// TODO(Feature 16): the Google Calendar integration status. Until Feature 16
// serves it the server answers 404, which is the truth: not connected.
export const CALENDAR_STATUS_PATH = '/api/v1/settings/calendar'
// TODO(notifications feature): the Web Push and Google Chat status. Until it
// is served the server answers 404, which is the truth: push is off and no
// Google Chat space is connected.
export const NOTIFICATIONS_STATUS_PATH = '/api/v1/settings/notifications'
// HouseholdSettings is the part of GET /api/v1/settings/household the
// settings pages read. Times are zero-padded 24-hour HH:MM in timezone; days
// of the week count from Sunday = 0.
export type HouseholdSettings = {
timezone: string
dinner_time: string
reminder_time: string
// The rotation check: when the household is asked "Keep it in rotation?".
review_time: string
plan_draft_dow: number
plan_draft_time: string
suggestions_dow: number
suggestions_time: string
chat_autosend_dow: number
chat_autosend_time: string
}
// HouseholdSettingsPatch is the PATCH /api/v1/settings/household body: only
// the fields that change.
export type HouseholdSettingsPatch = Partial<HouseholdSettings>
export type HouseholdSettingsField = keyof HouseholdSettings
const householdTimeFields = [
'dinner_time',
'reminder_time',
'review_time',
'plan_draft_time',
'suggestions_time',
'chat_autosend_time',
] as const satisfies readonly HouseholdSettingsField[]
const householdDayFields = [
'plan_draft_dow',
'suggestions_dow',
'chat_autosend_dow',
] as const satisfies readonly HouseholdSettingsField[]
const householdFields: readonly HouseholdSettingsField[] = ['timezone', ...householdTimeFields, ...householdDayFields]
// aiProviderLabels names every AI provider preset the server accepts
// (internal/ai/llm/presets) as the household reads it.
export const aiProviderLabels = {
anthropic: 'Anthropic',
openai: 'OpenAI',
openrouter: 'OpenRouter',
xai: 'xAI',
ollama: 'Ollama',
openai_compatible: 'OpenAI-compatible',
} as const
export type AIProvider = keyof typeof aiProviderLabels
// providersWithBaseURL are the presets with no fixed API root: the
// household supplies its base URL.
export const providersWithBaseURL: ReadonlySet<AIProvider> = new Set(['ollama', 'openai_compatible'])
// aiKeyPolicy is whether each preset needs an API key, may take one, or
// takes none (internal/ai/llm/presets).
export const aiKeyPolicy: Record<AIProvider, 'required' | 'optional' | 'none'> = {
anthropic: 'required',
openai: 'required',
openrouter: 'required',
xai: 'required',
ollama: 'none',
openai_compatible: 'optional',
}
// AISettings is GET /api/v1/settings/ai. The API key itself is never sent:
// only its last four characters, null when none is stored.
export type AISettings = {
provider: AIProvider
base_url: string
model: string
temperature: number
// Null when none is stored.
system_prompt: string | null
api_key_last4: string | null
// When the stored key was last verified, null when it has not been.
key_verified_at: string | null
updated_at: string
}
// AISettingsPatch is the PATCH /api/v1/settings/ai body: only the fields
// that change.
export type AISettingsPatch = Partial<Pick<AISettings, 'provider' | 'base_url' | 'model' | 'temperature'>> & {
system_prompt?: string
}
// AIModel is one model GET /api/v1/settings/ai/models lists.
export type AIModel = { id: string; display_name: string }
function isAIProvider(value: string): value is AIProvider {
return Object.hasOwn(aiProviderLabels, value)
}
function isStringOrNull(value: unknown): value is string | null {
return value === null || typeof value === 'string'
}
// parseAISettings refuses a body that is not the AI settings, or names a
// provider this client has no name for, so the settings never show a value
// the household cannot read.
function parseAISettings(where: string, body: unknown): AISettings {
if (
!isRecord(body) ||
typeof body.provider !== 'string' ||
typeof body.base_url !== 'string' ||
typeof body.model !== 'string' ||
typeof body.temperature !== 'number' ||
!isStringOrNull(body.system_prompt) ||
!isStringOrNull(body.api_key_last4) ||
!isStringOrNull(body.key_verified_at) ||
typeof body.updated_at !== 'string'
) {
throw new Error(`${where}: response is not the AI settings`)
}
if (!isAIProvider(body.provider)) throw new Error(`${where}: unknown AI provider "${body.provider}"`)
return {
provider: body.provider,
base_url: body.base_url,
model: body.model,
temperature: body.temperature,
system_prompt: body.system_prompt,
api_key_last4: body.api_key_last4,
key_verified_at: body.key_verified_at,
updated_at: body.updated_at,
}
}
const AI_SETTINGS_KEY = ['settings', 'ai'] as const
const AI_MODELS_KEY = ['settings', 'ai', 'models'] as const
export function useAISettings() {
const { apiFetch } = useApi()
return useQuery({
queryKey: AI_SETTINGS_KEY,
queryFn: async () => parseAISettings(`GET ${AI_SETTINGS_PATH}`, await apiFetch<unknown>(AI_SETTINGS_PATH)),
})
}
// useAIModels is the saved provider's live model list. Without a usable
// provider (no key yet, say) it fails with the server's 409 not_configured.
export function useAIModels(enabled: boolean) {
const { apiFetch } = useApi()
return useQuery({
queryKey: AI_MODELS_KEY,
enabled,
queryFn: async () => {
const body = await apiFetch<unknown>(AI_MODELS_PATH)
const models = isRecord(body) ? body.models : undefined
if (
!Array.isArray(models) ||
!models.every((m) => isRecord(m) && typeof m.id === 'string' && typeof m.display_name === 'string')
) {
throw new Error(`GET ${AI_MODELS_PATH}: response is not a model list`)
}
return models as AIModel[]
},
})
}
// useAISettingsMutation sends one AI settings change and stores the
// settings the server answers with. A change that can alter which models
// the provider offers refetches the model list.
function useAISettingsMutation<V>(path: string, method: 'POST' | 'PUT' | 'PATCH', body: (variables: V) => unknown) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (variables: V) =>
parseAISettings(`${method} ${path}`, await apiFetch<unknown>(path, { method, body: body(variables) })),
onSuccess: (settings) => {
const before = queryClient.getQueryData<AISettings>(AI_SETTINGS_KEY)
queryClient.setQueryData(AI_SETTINGS_KEY, settings)
if (
before === undefined ||
before.provider !== settings.provider ||
before.base_url !== settings.base_url ||
before.api_key_last4 !== settings.api_key_last4 ||
before.key_verified_at !== settings.key_verified_at
) {
void queryClient.invalidateQueries({ queryKey: AI_MODELS_KEY })
}
},
})
}
export function useSaveAISettings() {
return useAISettingsMutation(AI_SETTINGS_PATH, 'PATCH', (patch: AISettingsPatch) => patch)
}
// useSetAIKey stores a new API key; an empty key removes the stored one.
export function useSetAIKey() {
return useAISettingsMutation(AI_KEY_PATH, 'PUT', (apiKey: string) => ({ api_key: apiKey }))
}
export function useVerifyAIKey() {
return useAISettingsMutation(AI_VERIFY_PATH, 'POST', () => undefined)
}
export function useResetAIPrompt() {
return useAISettingsMutation(AI_RESET_PROMPT_PATH, 'POST', () => undefined)
}
// recipeSearchProviderLabels names every recipe search provider the server
// accepts (internal/recipes/searchsettings.go).
export const recipeSearchProviderLabels = {
searxng: 'SearXNG',
brave: 'Brave',
} as const
export type RecipeSearchProvider = keyof typeof recipeSearchProviderLabels
// RecipeSearchSettings is GET /api/v1/settings/recipe-search. The Brave API
// key itself is never sent: only whether one is stored and its last four
// characters.
export type RecipeSearchSettings = {
// Null until recipe search is first set up.
provider: RecipeSearchProvider | null
// The saved SearXNG instance's URL, null when none is set.
base_url: string | null
// The deployment's SearXNG URL (MEALQUEST_SEARXNG_URL) to suggest while
// base_url is null; null when the deployment names none. Only saving
// base_url stores a URL.
default_base_url: string | null
api_key_set: boolean
api_key_last4: string | null
updated_at: string | null
}
// RecipeSearchSettingsPatch is the PATCH /api/v1/settings/recipe-search
// body: only the fields that change. api_key is the Brave key in plain text.
export type RecipeSearchSettingsPatch = {
provider?: RecipeSearchProvider
base_url?: string
api_key?: string
}
// RecipeSearchTest is POST /api/v1/settings/recipe-search/test: how many
// recipes the saved provider found for query.
export type RecipeSearchTest = { query: string; count: number }
function isRecipeSearchProvider(value: string): value is RecipeSearchProvider {
return Object.hasOwn(recipeSearchProviderLabels, value)
}
function parseRecipeSearchSettings(where: string, body: unknown): RecipeSearchSettings {
if (
!isRecord(body) ||
!isStringOrNull(body.provider) ||
!isStringOrNull(body.base_url) ||
!isStringOrNull(body.default_base_url) ||
typeof body.api_key_set !== 'boolean' ||
!isStringOrNull(body.api_key_last4) ||
!isStringOrNull(body.updated_at)
) {
throw new Error(`${where}: response is not the recipe search settings`)
}
if (body.provider !== null && !isRecipeSearchProvider(body.provider)) {
throw new Error(`${where}: unknown recipe search provider "${body.provider}"`)
}
return {
provider: body.provider,
base_url: body.base_url,
default_base_url: body.default_base_url,
api_key_set: body.api_key_set,
api_key_last4: body.api_key_last4,
updated_at: body.updated_at,
}
}
const RECIPE_SEARCH_KEY = ['settings', 'recipe-search'] as const
export function useRecipeSearchSettings() {
const { apiFetch } = useApi()
return useQuery({
queryKey: RECIPE_SEARCH_KEY,
queryFn: async () =>
parseRecipeSearchSettings(`GET ${RECIPE_SEARCH_PATH}`, await apiFetch<unknown>(RECIPE_SEARCH_PATH)),
})
}
export function useSaveRecipeSearchSettings() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (patch: RecipeSearchSettingsPatch) =>
parseRecipeSearchSettings(
`PATCH ${RECIPE_SEARCH_PATH}`,
await apiFetch<unknown>(RECIPE_SEARCH_PATH, { method: 'PATCH', body: patch }),
),
onSuccess: (settings) => queryClient.setQueryData(RECIPE_SEARCH_KEY, settings),
})
}
// useTestRecipeSearch searches with the saved settings.
export function useTestRecipeSearch() {
const { apiFetch } = useApi()
return useMutation({
mutationFn: async () => {
const body = await apiFetch<unknown>(RECIPE_SEARCH_TEST_PATH, { method: 'POST' })
if (!isRecord(body) || typeof body.query !== 'string' || typeof body.count !== 'number') {
throw new Error(`POST ${RECIPE_SEARCH_TEST_PATH}: response is not a search test result`)
}
return { query: body.query, count: body.count } satisfies RecipeSearchTest
},
})
}
const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
// parseHouseholdSettings refuses a body that is not the household settings,
// or has a time that is not HH:MM, so formatClockTime never sees one.
function parseHouseholdSettings(where: string, body: unknown): HouseholdSettings {
if (!isRecord(body) || typeof body.timezone !== 'string') {
throw new Error(`${where}: response is not the household settings`)
}
const times = {} as Record<(typeof householdTimeFields)[number], string>
for (const field of householdTimeFields) {
const value = body[field]
if (typeof value !== 'string' || !clockTime.test(value)) {
throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not HH:MM`)
}
times[field] = value
}
const days = {} as Record<(typeof householdDayFields)[number], number>
for (const field of householdDayFields) {
const value = body[field]
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 6) {
throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not a day of the week (0-6)`)
}
days[field] = value
}
return { timezone: body.timezone, ...times, ...days }
}
const HOUSEHOLD_SETTINGS_KEY = ['settings', 'household'] as const
export function useHouseholdSettings() {
const { apiFetch } = useApi()
return useQuery({
queryKey: HOUSEHOLD_SETTINGS_KEY,
queryFn: async () =>
parseHouseholdSettings(`GET ${HOUSEHOLD_SETTINGS_PATH}`, await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH)),
})
}
export function useSaveHouseholdSettings() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (patch: HouseholdSettingsPatch) =>
parseHouseholdSettings(
`PATCH ${HOUSEHOLD_SETTINGS_PATH}`,
await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH, { method: 'PATCH', body: patch }),
),
onSuccess: (settings) => queryClient.setQueryData(HOUSEHOLD_SETTINGS_KEY, settings),
})
}
// HouseholdSettingsProblems is a refused household settings save: the reason
// for each field the server named, and any reasons that name no field.
export type HouseholdSettingsProblems = {
fields: Partial<Record<HouseholdSettingsField, string>>
other: string[]
}
const invalidHouseholdPrefix = 'invalid household settings: '
// A problem starts with a field name and ": " (internal/household
// ValidationError.Error joins them with "; ").
const problemStart = /; (?=[a-z_]+: )/
// householdSettingsProblems reads the server's invalid_settings error into
// the problems it lists. Any other error is one problem naming no field.
export function householdSettingsProblems(error: Error): HouseholdSettingsProblems {
const problems: HouseholdSettingsProblems = { fields: {}, other: [] }
if (
!(error instanceof ApiError) ||
error.code !== 'invalid_settings' ||
!error.message.startsWith(invalidHouseholdPrefix)
) {
problems.other.push(error.message)
return problems
}
for (const problem of error.message.slice(invalidHouseholdPrefix.length).split(problemStart)) {
const colon = problem.indexOf(': ')
const field = problem.slice(0, colon)
if (colon !== -1 && (householdFields as readonly string[]).includes(field)) {
problems.fields[field as HouseholdSettingsField] = problem.slice(colon + 2)
} else {
problems.other.push(problem)
}
}
return problems
}
// aiStatus is the AI assistant row's status line: the provider and the
// model's display name, or that no model is chosen yet (the seed).
export function aiStatus(settings: Pick<AISettings, 'provider' | 'model'>): string {
const provider = aiProviderLabels[settings.provider]
if (settings.model === '') return `${provider} · No model chosen`
return `${provider} · ${modelDisplayName(settings.model)}`
}
// Claude model ids, current ("claude-sonnet-4-5") and older
// ("claude-3-5-sonnet-20241022"), name their family.
const claudeModel = /^claude-(?:[0-9-]+-)?(opus|sonnet|haiku|fable)(?:-|$)/
const claudeFamilies: Record<string, string> = { opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku', fable: 'Fable' }
// modelDisplayName names a model as the handoff does ("Claude Sonnet"); a
// model with no known family is shown by its id.
export function modelDisplayName(model: string): string {
const family = claudeModel.exec(model)?.[1]
return family === undefined ? model : `Claude ${claudeFamilies[family]}`
}
// fetchUnlessAbsent GETs path and parses it, or returns null when the server
// has no such endpoint yet (404 not_found). Every other failure is an error.
async function fetchUnlessAbsent<T>(
apiFetch: ApiClient['apiFetch'],
path: string,
parse: (body: unknown) => T,
): Promise<T | null> {
let body: unknown
try {
body = await apiFetch<unknown>(path)
} catch (error) {
if (error instanceof ApiError && error.status === 404 && error.code === 'not_found') return null
throw error
}
return parse(body)
}
// useRegularDinnersCount is how many Regulars the household has, or null
// while the dinners read API does not exist.
export function useRegularDinnersCount() {
const { apiFetch } = useApi()
return useQuery({
queryKey: ['dinners', 'regular', 'count'],
queryFn: () =>
fetchUnlessAbsent(apiFetch, REGULAR_DINNERS_PATH, (body) => {
if (!isRecord(body) || !Array.isArray(body.dinners)) {
throw new Error(`GET ${REGULAR_DINNERS_PATH}: response has no dinners list`)
}
return body.dinners.length
}),
})
}
export function regularDinnersStatus(count: number | null): string {
if (count === null) return 'Rotation count unavailable'
if (count === 0) return 'None on rotation yet'
return `${count} on rotation`
}
// CalendarStatus is the Google Calendar integration status; calendar_name
// is the connected calendar's name.
export type CalendarStatus = { connected: false } | { connected: true; calendar_name: string }
export function useCalendarStatus() {
const { apiFetch } = useApi()
return useQuery({
queryKey: ['settings', 'calendar'],
queryFn: () =>
fetchUnlessAbsent(apiFetch, CALENDAR_STATUS_PATH, (body): CalendarStatus => {
if (isRecord(body) && body.connected === false) return { connected: false }
if (isRecord(body) && body.connected === true && typeof body.calendar_name === 'string') {
return { connected: true, calendar_name: body.calendar_name }
}
throw new Error(`GET ${CALENDAR_STATUS_PATH}: response is not a calendar status`)
}),
})
}
export function calendarStatus(status: CalendarStatus | null): string {
if (status === null || !status.connected) return 'Not connected'
return `Connected · ${status.calendar_name}`
}
// NotificationsStatus is whether Web Push is on and the connected Google
// Chat space's name, null when none is.
export type NotificationsStatus = { push: boolean; chat_space: string | null }
export function useNotificationsStatus() {
const { apiFetch } = useApi()
return useQuery({
queryKey: ['settings', 'notifications'],
queryFn: () =>
fetchUnlessAbsent(apiFetch, NOTIFICATIONS_STATUS_PATH, (body): NotificationsStatus => {
if (
!isRecord(body) ||
typeof body.push !== 'boolean' ||
(body.chat_space !== null && typeof body.chat_space !== 'string')
) {
throw new Error(`GET ${NOTIFICATIONS_STATUS_PATH}: response is not a notifications status`)
}
return { push: body.push, chat_space: body.chat_space }
}),
})
}
// notificationsStatus is the handoff's "Push on · Dinner Crew space".
export function notificationsStatus(status: NotificationsStatus | null): string {
const push = status?.push === true ? 'Push on' : 'Push off'
return `${push} · ${status?.chat_space ?? 'Google Chat not connected'}`
}
// maskedKey shows a stored key as dots and its last four characters, all
// the client ever knows of it.
export function maskedKey(last4: string): string {
return `••••••••${last4}`
}
// formatClockTime turns a 24-hour HH:MM time into the handoff's "5:30 pm".
export function formatClockTime(time: string): string {
const match = clockTime.exec(time)
if (match === null) throw new Error(`not a 24-hour HH:MM time: "${time}"`)
const hours = Number(match[1])
const suffix = hours < 12 ? 'am' : 'pm'
return `${hours % 12 === 0 ? 12 : hours % 12}:${match[2]} ${suffix}`
}
+46
View File
@@ -0,0 +1,46 @@
// The IANA time zones the browser knows, with their current UTC offsets, for
// the household time zone picker.
export type TimeZoneOption = {
zone: string
// "UTC-04:00"; "UTC+00:00" for UTC itself.
offset: string
}
// utcOffset is zone's offset from UTC at instant, as "UTC+05:30".
export function utcOffset(zone: string, instant: Date): string {
const name = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'longOffset' })
.formatToParts(instant)
.find((part) => part.type === 'timeZoneName')?.value
if (name === undefined) throw new Error(`no UTC offset for time zone "${zone}"`)
if (name === 'GMT') return 'UTC+00:00'
if (!/^GMT[+-]\d\d:\d\d$/.test(name)) throw new Error(`time zone "${zone}" has an unreadable offset "${name}"`)
return `UTC${name.slice(3)}`
}
// Zones the server accepts that Intl.supportedValuesOf('timeZone') leaves
// out: it lists only canonical zones, and UTC is not one of them.
const unlistedZones = ['UTC', 'Etc/UTC', 'GMT', 'Etc/GMT']
// timeZoneOptions lists every zone the browser supports, the unlisted UTC
// zones, and kept (the saved and chosen zones, which may be aliases the
// list leaves out), sorted by name.
export function timeZoneOptions(kept: string[], instant: Date): TimeZoneOption[] {
const zones = new Set([...Intl.supportedValuesOf('timeZone'), ...unlistedZones, ...kept])
return [...zones].sort().map((zone) => ({ zone, offset: utcOffset(zone, instant) }))
}
// zoneLabel reads a zone as words: "America/Argentina/Buenos_Aires" is
// "America / Argentina / Buenos Aires".
export function zoneLabel(zone: string): string {
return zone.replaceAll('_', ' ').replaceAll('/', ' / ')
}
// matchesZone is whether every word of the search text is in option's name
// or offset, in any case.
export function matchesZone(option: TimeZoneOption, search: string): boolean {
const needle = search.trim().toLowerCase()
if (needle === '') return true
const haystack = `${option.zone} ${zoneLabel(option.zone)} ${option.offset}`.toLowerCase()
return needle.split(/\s+/).every((word) => haystack.includes(word))
}
+11 -5
View File
@@ -1,9 +1,11 @@
import { render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AppRoutes } from '../App.tsx'
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
import { declaredStyle, outerSize } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { expectMinHitArea } from '../test/hitArea.ts'
import { stubFetchByPath } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import bottomBarStyles from './BottomBar.module.css'
import railStyles from './Rail.module.css'
@@ -16,14 +18,18 @@ const DESKTOP = 1440
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
vi.unstubAllGlobals()
})
function renderAt(path: string, width: number) {
const resize = mockViewport(width)
stubFetchByPath(SETTINGS_API)
render(
<MemoryRouter initialEntries={[path]}>
<AppRoutes />
</MemoryRouter>,
<InDataRouter path={path}>
<ApiProviders>
<AppRoutes />
</ApiProviders>
</InDataRouter>,
)
return resize
}
+10 -4
View File
@@ -1,8 +1,10 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { AppRoutes } from '../App.tsx'
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { stubFetchByPath } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import styles from './BottomBar.module.css'
@@ -65,14 +67,18 @@ afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(Element.prototype, 'animate')
Reflect.deleteProperty(window, 'matchMedia')
vi.unstubAllGlobals()
})
function renderAt(path: string, reducedMotion = false) {
mockViewport(MOBILE, { reducedMotion })
stubFetchByPath(SETTINGS_API)
render(
<MemoryRouter initialEntries={[path]}>
<AppRoutes />
</MemoryRouter>,
<InDataRouter path={path}>
<ApiProviders>
<AppRoutes />
</ApiProviders>
</InDataRouter>,
)
}
+80
View File
@@ -0,0 +1,80 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { useState, type ReactNode } from 'react'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { errorResponse, fakeLocation, jsonResponse } from './fetchStub.ts'
// AI_SETTINGS is a GET /api/v1/settings/ai body: Anthropic with a verified key.
export const AI_SETTINGS = {
provider: 'anthropic',
base_url: '',
model: 'claude-sonnet-4-5',
temperature: 0.7,
system_prompt: 'You are MealQuest, a dinner planning assistant for one household.',
api_key_last4: '3f9a',
key_verified_at: '2026-10-01T15:00:00.000Z',
updated_at: '2026-10-01T15:00:00.000Z',
}
// AI_MODELS is a GET /api/v1/settings/ai/models body: the model AI_SETTINGS
// chooses.
export const AI_MODELS = { models: [{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }] }
// HOUSEHOLD_SETTINGS is a GET /api/v1/settings/household body: the seed
// times in New York.
export const HOUSEHOLD_SETTINGS = {
timezone: 'America/New_York',
dinner_time: '17:30',
reminder_time: '16:30',
review_time: '09:00',
plan_draft_dow: 5,
plan_draft_time: '09:00',
suggestions_dow: 0,
suggestions_time: '08:00',
chat_autosend_dow: 6,
chat_autosend_time: '08:00',
onboarding_completed_at: null,
updates_seen_at: null,
updated_by: null,
created_at: '2026-10-01T15:00:00.000Z',
updated_at: '2026-10-01T15:00:00.000Z',
}
// RECIPE_SEARCH_SETTINGS is a GET /api/v1/settings/recipe-search body: the
// SearXNG sidecar, no Brave key stored.
export const RECIPE_SEARCH_SETTINGS = {
provider: 'searxng',
base_url: 'http://searxng:8080',
default_base_url: 'http://searxng:8080',
api_key_set: false,
api_key_last4: null,
updated_at: '2026-10-01T15:00:00.000Z',
}
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
// today's server does: the dinners, Google Calendar and notifications status
// endpoints do not exist yet. The home's sub-tabs stay mounted on mobile, so
// it also reads the AI models and recipe search settings.
export const SETTINGS_API = {
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'/api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
}
// ApiProviders gives the pages under test what App gives them: an API client
// on https://mealquest.test and a fresh query client.
export function ApiProviders({ children }: { children: ReactNode }) {
const [api] = useState(() =>
createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)),
)
const [queryClient] = useState(createQueryClient)
return (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ApiProvider>
)
}
+5 -1
View File
@@ -8,9 +8,13 @@ function cssRules(): CSSRule[] {
return Array.from(document.styleSheets).flatMap((sheet) => Array.from(sheet.cssRules))
}
// A selector with a pseudo-element never matches an element, and jsdom's
// matches() throws on the vendor ones (::-moz-range-track), so those rules
// are skipped.
function collect(element: Element, rules: CSSRule[], style: CSSStyleDeclaration) {
for (const rule of rules) {
if (!(rule instanceof CSSStyleRule) || !element.matches(rule.selectorText)) continue
if (!(rule instanceof CSSStyleRule) || rule.selectorText.includes('::')) continue
if (!element.matches(rule.selectorText)) continue
for (const property of Array.from(rule.style)) {
style.setProperty(property, rule.style.getPropertyValue(property))
}
+10
View File
@@ -0,0 +1,10 @@
import { useState, type ReactNode } from 'react'
import { createMemoryRouter, RouterProvider } from 'react-router'
// InDataRouter renders children under a memory data router's splat route at
// path, as main.tsx renders App, so pages that block navigation (useBlocker)
// work in tests. The router is created once, on first render.
export function InDataRouter({ path, children }: { path: string; children: ReactNode }) {
const [router] = useState(() => createMemoryRouter([{ path: '*', element: children }], { initialEntries: [path] }))
return <RouterProvider router={router} />
}
+14
View File
@@ -47,3 +47,17 @@ export function request(fetchMock: Mock<typeof fetch>, n: number) {
export function fakeLocation(pathname = '/', search = ''): ApiLocation & { assign: Mock<(url: string | URL) => void> } {
return { origin: 'https://mealquest.test', pathname, search, assign: vi.fn<(url: string | URL) => void>() }
}
// stubFetchByPath replaces the global fetch with a mock that answers each
// path from responses with a fresh response from its factory, so requests
// sent in parallel need no order; a path not in responses fails the test.
// Undo with vi.unstubAllGlobals.
export function stubFetchByPath(responses: Record<string, () => Response>): Mock<typeof fetch> {
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const respond = Object.hasOwn(responses, String(input)) ? responses[String(input)] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${String(input)}`)
return Promise.resolve(respond())
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
+50
View File
@@ -0,0 +1,50 @@
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
}
.label {
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
.control {
position: relative;
display: block;
}
.select {
appearance: none;
box-sizing: border-box;
width: 100%;
min-width: 44px;
height: 56px;
padding: 0 36px 0 16px;
border: 1px solid var(--color-neutral-300);
border-radius: var(--radius-pill);
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-body);
font-size: 15px;
font-weight: 700;
text-overflow: ellipsis;
cursor: pointer;
}
.select:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* The chevron sits over the select's right padding and lets taps through. */
.chevron {
position: absolute;
top: 50%;
right: 14px;
transform: translateY(-50%);
pointer-events: none;
}
+38
View File
@@ -0,0 +1,38 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { Select } from './Select.tsx'
describe('Select', () => {
it('is a native select named by its label', () => {
const onChange = vi.fn()
render(
<Select label="Provider" value="a" onChange={onChange}>
<option value="a">Alpha</option>
<option value="b">Beta</option>
</Select>,
)
const select = screen.getByRole('combobox', { name: 'Provider' })
expect(select).toHaveValue('a')
fireEvent.change(select, { target: { value: 'b' } })
expect(onChange).toHaveBeenCalledOnce()
})
it('keeps a 44 px tap target', () => {
render(
<Select label="Model" defaultValue="a">
<option value="a">Alpha</option>
</Select>,
)
expectMinHitArea(screen.getByRole('combobox', { name: 'Model' }))
})
it('hides its chevron from assistive technology', () => {
const { container } = render(
<Select label="Model" defaultValue="a">
<option value="a">Alpha</option>
</Select>,
)
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true')
})
})
+27
View File
@@ -0,0 +1,27 @@
import { ChevronDown } from 'lucide-react'
import { useId, type ComponentPropsWithRef } from 'react'
import { Icon } from './Icon.tsx'
import styles from './Select.module.css'
export type SelectProps = Omit<ComponentPropsWithRef<'select'>, 'id' | 'multiple'> & {
label: string
}
// Pill dropdown: a native select on the surface fill with a chevron, always
// associated with its label. Pass the options as children.
export function Select({ label, className, children, ...rest }: SelectProps) {
const id = useId()
return (
<div className={styles.field}>
<label htmlFor={id} className={styles.label}>
{label}
</label>
<span className={styles.control}>
<select id={id} className={[styles.select, className].filter(Boolean).join(' ')} {...rest}>
{children}
</select>
<Icon icon={ChevronDown} className={styles.chevron} />
</span>
</div>
)
}
+1
View File
@@ -10,6 +10,7 @@ export { ListRow, type ListRowProps } from './ListRow.tsx'
export { Photo, type PhotoProps } from './Photo.tsx'
export { Segmented, type SegmentedOption, type SegmentedProps } from './Segmented.tsx'
export { Sheet, type SheetProps } from './Sheet.tsx'
export { Select, type SelectProps } from './Select.tsx'
export { Skeleton, type SkeletonProps } from './Skeleton.tsx'
export { Slider, type SliderProps } from './Slider.tsx'
export { Tag, type TagProps, type TagTone } from './Tag.tsx'