Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fd607f125 | ||
|
|
701021d327 | ||
|
|
8b18399710 | ||
|
|
f26cfd5129 | ||
|
|
3319434bd1 | ||
|
|
1c59e5d9d7 | ||
|
|
0ea1c218ea | ||
|
|
6c105416a9 | ||
|
|
74611da62b | ||
|
|
9088c313cc | ||
|
|
c0ea1b9323 | ||
|
|
db677d591f | ||
|
|
4c34467f8b | ||
|
|
7d1e983fc8 | ||
|
|
2b0e5ff01a | ||
|
|
0c89eef577 | ||
|
|
fcbdebd6e8 |
@@ -44,6 +44,9 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
|
||||
# trusts no peer, which is right for local development.
|
||||
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
|
||||
# Optional: a SearXNG instance the Settings screen suggests as the recipe
|
||||
# search URL until one is saved.
|
||||
# export MEALQUEST_SEARXNG_URL=http://localhost:8888
|
||||
export MEALQUEST_DEFAULT_TZ=America/New_York
|
||||
make dev
|
||||
```
|
||||
@@ -53,8 +56,9 @@ make dev
|
||||
http://localhost:5173. Configuration is environment-only and fails fast:
|
||||
every variable above is required except the commented-out session
|
||||
lifetimes (when set they must be positive, with MAX at least IDLE),
|
||||
trusted proxy list (every entry must be an IP or CIDR) and
|
||||
`MEALQUEST_DEFAULT_TZ`, and
|
||||
trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL
|
||||
(an absolute `http://` or `https://` URL without credentials, a query or a
|
||||
fragment) and `MEALQUEST_DEFAULT_TZ`, and
|
||||
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
|
||||
endpoints the issuer advertises must be `https://`; plain `http://` is
|
||||
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
|
||||
|
||||
+67
-6
@@ -13,8 +13,12 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
@@ -23,6 +27,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
@@ -74,11 +79,35 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
photos, err := recipes.NewPhotoCache(recipes.NewFetcher(), db, cfg.DataDir)
|
||||
// One fetcher serves pages and photos, so its per-host spacing and
|
||||
// page cache cover both.
|
||||
fetcher := recipes.NewFetcher()
|
||||
photos, err := recipes.NewPhotoCache(fetcher, db, cfg.DataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
search, err := recipes.NewSearchSettingsService(db, box)
|
||||
search, err := recipes.NewSearchSettingsService(db, box, cfg.SearXNGURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", config.EnvSearXNGURL, err)
|
||||
}
|
||||
|
||||
ai, err := aisettings.New(db, box, cfg.PublicURL.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grounder, err := recipes.NewGrounder(db, search, fetcher, photos, normalizer(ai, guard))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dinnerSvc, err := dinners.NewService(db, grounder, runner, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
suggestSvc, err := suggest.NewService(db, grounder, sideQuests(ai, guard), runner, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -99,7 +128,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, runner, photos, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, runner, photos, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -115,6 +144,12 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
if err := runner.Start(jobsCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
// With those jobs failed, a dinner still pending with no grounding job
|
||||
// queued or running is failed as interrupted, before any request can
|
||||
// create one.
|
||||
if _, err := dinnerSvc.FailInterruptedGroundings(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: router,
|
||||
@@ -196,7 +231,7 @@ func seedDefaults(ctx context.Context, db *store.Store, defaultTZ string) error
|
||||
return household.SeedNotificationPrefs(ctx, db)
|
||||
}
|
||||
|
||||
// jobKinds enqueues every job kind the server runs.
|
||||
// jobKinds enqueues the framework's job kinds.
|
||||
type jobKinds struct {
|
||||
builtins *jobs.Builtins
|
||||
}
|
||||
@@ -215,8 +250,10 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
|
||||
return runner, kinds, nil
|
||||
}
|
||||
|
||||
// registerJobKinds registers every job kind's handler on runner. Each
|
||||
// feature's job kinds are registered here.
|
||||
// registerJobKinds registers the framework's job kinds on runner. A feature
|
||||
// service registers its own kinds when it is built, before the runner
|
||||
// starts (dinners.NewService registers dinners.KindGroundDinner and
|
||||
// suggest.NewService suggest.KindSuggestionBatch).
|
||||
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
||||
builtins, err := jobs.RegisterBuiltins(runner)
|
||||
if err != nil {
|
||||
@@ -225,6 +262,30 @@ func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
||||
return &jobKinds{builtins: builtins}, nil
|
||||
}
|
||||
|
||||
// normalizer runs normalize_recipe with the AI settings current at each
|
||||
// run, sharing guard.
|
||||
func normalizer(ai *aisettings.Service, guard *harness.Guard) recipes.Normalizer {
|
||||
return func(ctx context.Context, scope string, in skills.RecipeText) (skills.NormalizedRecipe, error) {
|
||||
cfg, err := ai.HarnessConfig(ctx, guard)
|
||||
if err != nil {
|
||||
return skills.NormalizedRecipe{}, err
|
||||
}
|
||||
return skills.NormalizeRecipe(ctx, cfg, scope, in)
|
||||
}
|
||||
}
|
||||
|
||||
// sideQuests runs suggest_side_quests with the AI settings current at each
|
||||
// run, sharing guard.
|
||||
func sideQuests(ai *aisettings.Service, guard *harness.Guard) suggest.Suggester {
|
||||
return func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
cfg, err := ai.HarnessConfig(ctx, guard)
|
||||
if err != nil {
|
||||
return skills.SideQuests{}, err
|
||||
}
|
||||
return skills.SuggestSideQuests(ctx, cfg, scope, in)
|
||||
}
|
||||
}
|
||||
|
||||
// logListening logs the startup line. Only the public URL's origin is logged
|
||||
// so no credential-bearing URL component can reach the log.
|
||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||
|
||||
+4
-4
@@ -68,10 +68,10 @@ MEALQUEST_SESSION_MAX=4320h
|
||||
# MEALQUEST_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
|
||||
# The Authentik provider's client secret.
|
||||
# MEALQUEST_SEARXNG_URL=http://searxng:8080
|
||||
# The SearXNG recipe-search sidecar on the compose network. Planned; not
|
||||
# read by the service yet. The app takes its recipe-search provider and
|
||||
# base URL from PATCH /api/v1/settings/recipe-search; see
|
||||
# deploy/RUNBOOK.md "Recipe search (SearXNG)".
|
||||
# The SearXNG recipe-search sidecar on the compose network. The Settings
|
||||
# screen pre-fills it as the SearXNG URL until one is saved; the saved
|
||||
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
|
||||
# See deploy/RUNBOOK.md "Recipe search (SearXNG)".
|
||||
|
||||
# --- Planned; not read by the service yet -------------------------------------
|
||||
#
|
||||
|
||||
+16
-8
@@ -417,15 +417,23 @@ The `searxng` service in `deploy/compose.yaml` is a private SearXNG
|
||||
instance for recipe search. On the compose network it is reachable at
|
||||
`http://searxng:8080`; that is the address to configure in the app.
|
||||
|
||||
Starting the sidecar does not connect the app to it. The service does not
|
||||
read `MEALQUEST_SEARXNG_URL`: `deploy/compose.yaml` sets it for a future
|
||||
wiring, and today it does nothing. The recipe-search provider and base URL
|
||||
live in the `recipe_search_settings` row and are set through
|
||||
`GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the Settings
|
||||
screen's recipe search; the screen itself is not built yet). Until that row
|
||||
exists, recipe search answers 409 `not_configured`.
|
||||
Starting the sidecar does not connect the app to it. The recipe-search
|
||||
provider and base URL live in the `recipe_search_settings` row and are set
|
||||
through `GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the
|
||||
recipe search card on Settings → AI assistant). Until that row exists,
|
||||
recipe search answers 409 `not_configured`.
|
||||
|
||||
After the first deploy, from a signed-in session (every non-`GET` request
|
||||
`deploy/compose.yaml` sets `MEALQUEST_SEARXNG_URL=http://searxng:8080`.
|
||||
The service only suggests it: `GET` returns it as `default_base_url`, and
|
||||
the recipe search card pre-fills the SearXNG URL with it while no URL is
|
||||
saved. Nothing is stored until the card is saved, and a saved `base_url`
|
||||
always wins. A set value must be an absolute `http://` or `https://` URL
|
||||
without credentials, a query or a fragment, or the service refuses to
|
||||
start; unset, `default_base_url` is `null`.
|
||||
|
||||
To set it up, open Settings → AI assistant, check that the recipe search
|
||||
card shows SearXNG with `http://searxng:8080`, select **Save recipe search**
|
||||
and then **Test**. Or, from a signed-in session (every non-`GET` request
|
||||
needs the `X-MealQuest-CSRF` header set to `csrfToken` from
|
||||
`GET /api/v1/me`):
|
||||
|
||||
|
||||
+223
-4
@@ -96,9 +96,9 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
|
||||
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
|
||||
| `recipe_steps` | recipe_id, position, text. |
|
||||
| `photos` | source_url, local_path, width, height, sha256, fetched_at. |
|
||||
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by. |
|
||||
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised when a pending dinner is renamed, so only the latest `ground_dinner` job may write it), updated_by. |
|
||||
| `suggestion_batches` | trigger, status, ai_run_id. |
|
||||
| `suggestions` | dinner_id, batch_id, reason, cited_regular_ids (JSON), status, decided_at. |
|
||||
| `suggestions` | dinner_id, batch_id, name (the name the dish was suggested under, kept whatever its dinner is later renamed, so a Pass stays an immutable record every later batch dedupes against), reason, cited_regular_ids (JSON), status, decided_at. |
|
||||
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. |
|
||||
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. |
|
||||
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), decided_at, updated_by. |
|
||||
@@ -127,7 +127,7 @@ suggested (deck) ──Try it──▶ quest_log ──planned date passes──
|
||||
│ └──────── explicit decision ─────┤
|
||||
│ ├──▶ regular
|
||||
│ └──▶ archived
|
||||
└──Pass──▶ archived (name kept for dedupe)
|
||||
└──Pass──▶ archived (suggested name kept for dedupe)
|
||||
```
|
||||
|
||||
From `tried`, an explicit decision moves the dinner to `regular`, to
|
||||
@@ -149,9 +149,173 @@ automatic** (D-021).
|
||||
the Brave key (`api_key`, write-only: responses carry only
|
||||
`api_key_set` and `api_key_last4`; an empty string removes it). The
|
||||
result must be usable: SearXNG needs a base_url, Brave a key.
|
||||
Responses also carry the read-only `default_base_url`, the deployment's
|
||||
`MEALQUEST_SEARXNG_URL` (or null), which the client pre-fills while no
|
||||
base_url is saved; only a PATCH of base_url stores a URL.
|
||||
`POST /api/v1/settings/recipe-search/test` searches "chicken tikka
|
||||
masala" and returns `{query, count}`, 409 `not_configured`, or 502
|
||||
`search_failed` with the provider's error.
|
||||
- `GET`/`PATCH /api/v1/settings/ai` (`aisettings.Service`) — provider,
|
||||
base_url, model, temperature, system_prompt, plus the read-only
|
||||
`api_key_last4` and `key_verified_at`. PATCH requires a preset name as
|
||||
provider, temperature 0–1 in 0.1 steps, a non-empty model and a
|
||||
system_prompt of at most 8000 characters; a fixed-URL preset's base_url
|
||||
must be empty or its own. Provider and base_url cannot change while a
|
||||
key is stored (remove the key first), so a stored key is never sent to
|
||||
an endpoint it was not stored for.
|
||||
`PUT /api/v1/settings/ai/api-key` takes `{api_key}` (write-only, at least
|
||||
8 characters, sealed with the secret box; an empty string removes it) and
|
||||
clears `key_verified_at`; a preset without a fixed base_url takes a key
|
||||
only when its base_url passes `secureurl.Check`. `POST /api/v1/settings/ai/verify` calls
|
||||
`Provider.Verify` and records `key_verified_at`;
|
||||
`GET /api/v1/settings/ai/models` returns `{models: [{id, display_name}]}`
|
||||
live from the provider, with the key redacted from every id and
|
||||
display_name. Both work before a model is chosen
|
||||
(`presets.ForAccount`), answer 409 `not_configured` when the settings
|
||||
cannot build a provider, and 502 with the provider's typed error
|
||||
(`auth_failed`, `rate_limited`, `bad_output`, `provider_error`; 504
|
||||
`timeout`) when the call fails. `POST /api/v1/settings/ai/reset-prompt`
|
||||
restores `household.DefaultPersonaPrompt` (CTXD PROMPTS.MD §4). No
|
||||
response holds the key. `Service.HarnessConfig` reads the settings for
|
||||
each skill run, so a saved change applies to the next run.
|
||||
- Dinners (`dinners.Service`). `POST /api/v1/dinners` adds a Regular from
|
||||
`{name}` or `{name, recipe_url}` (an https:// link, kept in
|
||||
`recipe_url` for grounding and never returned in a dinner response, as
|
||||
it may carry a personal token) with status and origin `regular` and
|
||||
grounding_status `pending`, answering 201 with the dinner. The name is
|
||||
always the household's: no part of a link is ever used as a dinner's
|
||||
name, and a link without a name is 400. A name
|
||||
is trimmed, at most 120 characters and must keep a word after
|
||||
`harness.Normalize`, which sets `normalized_name`. A name
|
||||
`harness.Similar` to an unarchived dinner's, or a link one already has
|
||||
(the source_url of the recipe it is grounded in, else its pasted
|
||||
`recipe_url`), is 409 `duplicate_dinner`; archived names may be added
|
||||
again.
|
||||
`GET /api/v1/dinners?status=regular|quest_log` returns
|
||||
`{dinners: [...]}` ordered by normalized name, each with `photo_url`
|
||||
(`/media/photos/{photo_id}-400.jpg`, or null) and `grounding_status`;
|
||||
any other status is 400. `PATCH /api/v1/dinners/{id}` edits `name`
|
||||
(deduplicated against the other unarchived dinners), `method`
|
||||
(`stovetop` / `oven` / `crockpot` / `grill` / `other`), `minutes_total`
|
||||
(1–1440) and `difficulty` (`easy` / `medium` / `hard`); invalid values
|
||||
are 400 `invalid_dinner` and change nothing. `DELETE /api/v1/dinners/{id}`
|
||||
sets status `archived` and keeps the row; `POST
|
||||
/api/v1/dinners/{id}/favorite` toggles `is_favorite`. Every write records
|
||||
the session's subject as `updated_by`; an unknown id is 404.
|
||||
- Dinner grounding. `POST /api/v1/dinners` also enqueues a `ground_dinner`
|
||||
job and answers with its `grounding_job_id` without waiting for it
|
||||
(`GET /api/v1/jobs/{id}` reports it); if the job cannot be enqueued it
|
||||
still answers 201, with grounding_status `failed` (`grounding_error`)
|
||||
and `grounding_job_id` null. The job grounds the dinner's
|
||||
`recipe_url` directly with `GroundURL`, else its name with `Ground`
|
||||
(see Recipe grounding), and sets grounding_status `grounded` with the
|
||||
recipe's recipe_id, photo_id, method, minutes_total and difficulty, or
|
||||
`failed` with `grounding_error` — also the job's failure code — one of
|
||||
`no_recipe_found`, `recipe_unusable` (the pasted link), `search_not_configured`,
|
||||
`search_failed`, `ai_failed`, `interrupted` (the server stopped) or
|
||||
`grounding_error`. Each job carries the dinner's `grounding_revision`
|
||||
from when it was enqueued; renaming a `pending` dinner clears its
|
||||
`recipe_url`, raises the revision and enqueues a job for the new name.
|
||||
A job for a dinner archived, no longer `pending` or at another revision
|
||||
does no work, and a job only writes a dinner still unarchived, `pending`
|
||||
and at its revision, so a fix-up made meanwhile wins and an earlier job
|
||||
for an old name — queued or running — never decides the outcome.
|
||||
Archiving or renaming a dinner also stops its running job where it is,
|
||||
so no further search, page fetch, photo download or model call is made
|
||||
for it. A
|
||||
method, minutes_total or difficulty a member saved while the dinner was
|
||||
`pending` is kept; grounding fills only those left empty. At boot, after
|
||||
the job runner fails the jobs a previous process left running, every
|
||||
unarchived dinner still `pending` with no `ground_dinner` job for its revision
|
||||
queued or running is set `failed` with `interrupted`, so no dinner stays
|
||||
pending with nothing left to ground it.
|
||||
`GET /api/v1/dinners/{id}/recipe-candidates`
|
||||
searches for the dinner's name and returns `{candidates: [{candidate_id,
|
||||
title, site_name, photo_url, minutes}]}`, at most 3, ranked as `Ground`
|
||||
ranks them; it fetches pages but downloads no photo and runs no model,
|
||||
so `photo_url` is set only for a page already stored, and `candidate_id`
|
||||
is the page's URL. `POST /api/v1/dinners/{id}/ground` takes exactly one
|
||||
of `{candidate_id}`, `{recipe_url}` (both grounded with `GroundURL`,
|
||||
setting `grounded`) or `{manual: {ingredients[], steps[], minutes,
|
||||
method}}` (1–100 ingredient lines, up to 100 steps, each non-blank and at
|
||||
most 500 characters; minutes 1–1440). A manual recipe is stored as typed
|
||||
— no model runs — as a `recipes` row whose source_url is
|
||||
`mealquest:manual:{recipe_id}` and whose raw_jsonld is a schema.org
|
||||
Recipe built from it, and sets `manual` with its method and minutes, no
|
||||
photo and the dinner's difficulty unchanged. Either clears the dinner's
|
||||
pasted `recipe_url`: the recipe chosen replaces it. It answers 200 with
|
||||
the dinner; an invalid body is 400, an unknown dinner 404, a page that cannot
|
||||
ground it 422 `ungroundable` (the dinner is unchanged), recipe search not
|
||||
set up 409 `not_configured`, a search or model failure 502
|
||||
`search_failed` / `ai_failed` (only the reason code is logged, never the
|
||||
provider's text), and grounding a candidate or link that runs past 90
|
||||
seconds (fetch, photo and `normalize_recipe`, all in the request) 504
|
||||
`ground_timeout`, leaving the dinner unchanged. An archived dinner is 409
|
||||
`dinner_archived`. A candidate or link is written only if the dinner is
|
||||
still as it was before grounding began: one renamed or grounded some
|
||||
other way meanwhile is 409 `ground_conflict`, and one archived meanwhile
|
||||
409 `dinner_archived`, either leaving the dinner unchanged. A method,
|
||||
minutes or difficulty a member edits meanwhile is kept over the recipe's.
|
||||
Any other failure grounding a candidate or link, or recording it on the
|
||||
dinner, is 500, logged with a fixed reason and the dinner id only —
|
||||
never the link, which can carry a personal token, or dependency error
|
||||
text.
|
||||
- Side quest suggestions (`suggest.Service`). `POST
|
||||
/api/v1/suggestions/batches` creates a `manual` batch and enqueues its
|
||||
`suggestion_batch` job, answering 202 `{batch_id, job_id, trigger,
|
||||
status}` without waiting; while a batch is `pending` or `running` it
|
||||
answers 202 with that batch instead, so at most one runs at a time (the
|
||||
check, the batch and its job share one write transaction). The job reads the
|
||||
household context, the deck's names and every declined name, runs
|
||||
`suggest_side_quests` for 5 Side quests (scope: the batch id), then
|
||||
grounds the survivors in the model's order with `Grounder.GroundMany`,
|
||||
as many as are still wanted at a time, until 5 are kept or none are
|
||||
left. A Side quest no recipe grounds is dropped; each round's grounded
|
||||
ones are stored before the next — a dinner with status `suggested`,
|
||||
origin `side_quest`, grounding_status `grounded` with the recipe's
|
||||
recipe_id, photo_id, method, minutes_total and difficulty and the
|
||||
model's cuisine, and a `pending` suggestion with the name it is
|
||||
suggested under, the reason and `cited_regular_ids` — unless, since the
|
||||
job read its input, a dinner it repeats was added, a suggestion it
|
||||
repeats passed, or a Regular it cites renamed or archived (checked in
|
||||
the storing transaction). The job publishes
|
||||
`{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
|
||||
`grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the
|
||||
skill or `normalize_recipe` failed; no Side quest is made up instead,
|
||||
D-017), `search_not_configured` or `search_failed`, and the batch is set
|
||||
`failed`; Side quests stored before the failure stay in the deck. If
|
||||
recording the batch's end fails, its job fails; the job runner retries
|
||||
a job's final status write with backoff until it lands, each attempt
|
||||
bounded at 5 seconds; at shutdown an attempt that fails or times out is
|
||||
not retried, and the next start fails the job as interrupted. Recording
|
||||
the batch's end is bounded the same way (one attempt, 5 seconds, made
|
||||
even during shutdown), so a busy writer cannot hold a stopping worker;
|
||||
a batch whose end timed out is left `running` with its job. Each time a
|
||||
batch is started, before the active batch is selected, every batch
|
||||
still `pending` or `running` with no job queued or running (the process
|
||||
stopped, or recording its end failed) is set `failed`, so it does not
|
||||
block the next.
|
||||
`GET /api/v1/suggestions/deck` returns `{suggestions: [{id, dinner_id,
|
||||
name, photo_url, difficulty, method, minutes_total, cuisine, reason,
|
||||
cited_regular_ids, status, dinner_status, decided_at, created_at}]}`:
|
||||
the `pending` suggestions whose dinner is still `suggested`, oldest
|
||||
first. `POST /api/v1/suggestions/{id}/accept` (Try it) sets the
|
||||
suggestion `accepted` and moves its dinner to `quest_log`; `POST
|
||||
/api/v1/suggestions/{id}/decline` (Pass) sets it `declined` and archives
|
||||
the dinner; every later batch, and the household context's passed list,
|
||||
reads the suggestion's own name as declined, so renaming the archived
|
||||
dinner changes nothing. Both record
|
||||
the session's subject as the dinner's `updated_by` and answer 200
|
||||
`{suggestion, replenish_job_id}`. Repeating a decision changes nothing
|
||||
and answers 200; the opposite decision is 409 `suggestion_decided`, a
|
||||
suggestion whose dinner left the deck some other way 409
|
||||
`dinner_not_suggested`, and an unknown id 404. A decision that leaves
|
||||
fewer than 5 in the deck starts a `replenish` batch, whose job id it
|
||||
returns, unless a batch is already pending or running
|
||||
(`replenish_job_id` is then null). The batch and its job are written in
|
||||
the decision's own transaction, so a decision is never recorded without
|
||||
the replenish it calls for; when they cannot be written, neither is the
|
||||
decision, and a retry decides again.
|
||||
- `/health` — liveness.
|
||||
- Everything else serves the embedded PWA with SPA fallback.
|
||||
|
||||
@@ -214,6 +378,18 @@ automatic** (D-021).
|
||||
dinner when the token-set (Jaccard) similarity of their words is at least
|
||||
0.85. A `Deduper` over the Regulars, Quest log, pending suggestions and
|
||||
passed names rejects a suggestion that repeats any of them.
|
||||
- **Side quest suggestions** (`skills.SuggestSideQuests`, skill
|
||||
`suggest_side_quests` v1): asks for twice the Side quests needed, at the
|
||||
household temperature, with the household context plus the pending Side
|
||||
quests. Malformed replies (bad JSON, a missing field, a bad enum, an
|
||||
empty name) get the one repair turn. Well-formed Side quests that break a
|
||||
rule are dropped, not repaired, and listed in `Dropped` with the rule:
|
||||
one that repeats a Regular, Quest log dinner, pending Side quest, any
|
||||
passed name (the whole history, not only the 60 in the context) or an
|
||||
earlier Side quest of the batch; one citing no Regular or a name that is
|
||||
not a Regular; and one whose reason is over 180 characters or names none
|
||||
of its cited Regulars. Cited names are rewritten as the household writes
|
||||
them. Grounding each survivor in a recipe is the caller's next step.
|
||||
|
||||
## Recipe grounding
|
||||
|
||||
@@ -320,7 +496,10 @@ automatic** (D-021).
|
||||
Rank's per-candidate reasons, photo or normalize failures). Failures
|
||||
that are not the candidates' — search, model provider, database — are
|
||||
returned as they are. `GroundURL(ctx, url)` does the same for one pasted
|
||||
URL. `GroundMany` grounds items 3 at a time and returns one result per
|
||||
URL. `Candidates(ctx, dishName, query, n)` returns the first n of the
|
||||
candidates `Ground` would try, without grounding any. Search failures
|
||||
wrap `recipes.ErrSearchFailed` and model provider failures
|
||||
`recipes.ErrNormalizeFailed`. `GroundMany` grounds items 3 at a time and returns one result per
|
||||
item, so one failure never affects its siblings. A recipe is usable only
|
||||
with ingredients, an image and, if given, a positive total time.
|
||||
|
||||
@@ -396,6 +575,46 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
`/settings/household`, `/settings/regulars` and `/settings/calendar` come from
|
||||
CTXD; the other routes are defined in the UI handoff.
|
||||
|
||||
First run: while `household_settings.onboarding_completed_at` (read from
|
||||
`GET /api/v1/settings/household`) is null, every route redirects to
|
||||
`/onboarding`. Onboarding is a full screen outside the app shell, in three
|
||||
steps: the welcome (`/onboarding`), the Regulars (`/onboarding/regulars`,
|
||||
mock 1h, adding and archiving through the dinners API optimistically; Next
|
||||
waits for three saved Regulars, and a common pick is not offered once a
|
||||
Regular has a name `harness.Similar` to it — the PWA's copy of the name
|
||||
rules in `web/src/dinners/names.ts` folds letters with
|
||||
`web/src/dinners/letterFolds.json`, which `gen_folds.py` writes from the
|
||||
same table as the server's, and runs the same cases,
|
||||
`internal/ai/harness/testdata/dedupe_cases.json`, as the server's) and
|
||||
notifications (`/onboarding/notifications`: what MealQuest notifies about,
|
||||
the iOS install hint, "Turn on notifications" and "Maybe later"). Either
|
||||
button sends `PATCH /api/v1/settings/household` with
|
||||
`{"onboarding_completed": true}`; the server sets `onboarding_completed_at`
|
||||
to the time of that update (keeping the first time if it is already set;
|
||||
the field itself is read-only and `false` is refused) and the app goes Home.
|
||||
Completing needs at least three Regulars (`household.MinRegulars`); with
|
||||
fewer the server answers 400 `invalid_settings` naming
|
||||
`onboarding_completed` and changes nothing. Step 3 can be reached with
|
||||
fewer — by its URL, or after another member removes a Regular — so it then
|
||||
shows the server's reason with an "Add Regulars" button back to step 2.
|
||||
Notification permission is never asked for on page load; until Feature 19
|
||||
wires Web Push, "Turn on notifications" asks for nothing. Offline on the saved pages the
|
||||
settings cannot be read (the service worker never caches `/api/v1/settings`),
|
||||
so the saved pages open as they are.
|
||||
|
||||
Regular dinners (`/settings/regulars`) lists the Regulars from
|
||||
`GET /api/v1/dinners?status=regular`, each with its photo, "method ·
|
||||
minutes", favorite heart and grounding badge ("Finding recipe…" while
|
||||
`pending`, "Recipe found" when `grounded` or `manual`, "Needs a recipe" when
|
||||
`failed`). The add field takes a name or a pasted `https://` recipe link.
|
||||
Tapping a Regular opens an edit sheet (name, method, minutes, difficulty;
|
||||
remove archives it after a confirmation); tapping one that needs a recipe
|
||||
opens "Fix recipe" (pick one of the recipe candidates, paste a link or type
|
||||
the ingredients and steps in). Adds, removals and the heart show at once and
|
||||
are undone if the server refuses them. While any Regular is `pending`, the
|
||||
list is read again every 3 seconds (not while a change is in flight), and
|
||||
polling stops once none is.
|
||||
|
||||
## Boundaries and gotchas
|
||||
|
||||
- **Secrets never reach the client** — API keys, OAuth tokens and the Chat
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
// Package aisettings reads and changes the household's AI settings — the
|
||||
// ai_settings row seeded by household.SeedAISettings — and builds the
|
||||
// provider and harness.Config they describe. The API key is sealed with the
|
||||
// secret box and never leaves the package except to the provider.
|
||||
package aisettings
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/presets"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxSystemPromptChars is the longest system prompt accepted, in
|
||||
// characters.
|
||||
MaxSystemPromptChars = 8000
|
||||
// MinKeyLength is the shortest API key accepted, so its last four
|
||||
// characters never give away most of it.
|
||||
MinKeyLength = 8
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotConfigured is returned when the stored AI settings cannot
|
||||
// build a provider, e.g. there is no API key yet. It is joined with
|
||||
// the llm configuration error that says why.
|
||||
ErrNotConfigured = errors.New("aisettings: AI provider is not configured")
|
||||
// ErrProviderFailed is returned when a call to the provider fails. It
|
||||
// is joined with the provider's error, so the llm typed error it
|
||||
// matches says why.
|
||||
ErrProviderFailed = errors.New("aisettings: AI provider call failed")
|
||||
// ErrChanged is returned when the AI settings changed while
|
||||
// their key was being verified, so the result applies to old settings.
|
||||
ErrChanged = errors.New("aisettings: AI settings changed during verification")
|
||||
)
|
||||
|
||||
// Settings is the household's AI configuration as the API shows it. The
|
||||
// API key is never part of it: only its last four characters.
|
||||
type Settings struct {
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
// SystemPrompt is the admin persona, nil when none is stored.
|
||||
SystemPrompt *string `json:"system_prompt"`
|
||||
// APIKeyLast4 is nil when no key is stored.
|
||||
APIKeyLast4 *string `json:"api_key_last4"`
|
||||
// KeyVerifiedAt is when the stored key was last verified with these
|
||||
// provider settings, nil when it has not been.
|
||||
KeyVerifiedAt *string `json:"key_verified_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Patch changes the settings. A nil field is left as it is.
|
||||
type Patch struct {
|
||||
Provider *string `json:"provider"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
Model *string `json:"model"`
|
||||
Temperature *float64 `json:"temperature"`
|
||||
SystemPrompt *string `json:"system_prompt"`
|
||||
}
|
||||
|
||||
// Model is one model the provider offers.
|
||||
type Model struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// Problem is one invalid field, named by its JSON key.
|
||||
type Problem struct {
|
||||
Field string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ValidationError lists every problem with an Patch or API
|
||||
// key, or with the settings it would leave. No problem quotes the key.
|
||||
type ValidationError struct {
|
||||
Problems []Problem
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
parts := make([]string, len(e.Problems))
|
||||
for i, p := range e.Problems {
|
||||
parts[i] = p.Field + ": " + p.Reason
|
||||
}
|
||||
return "invalid AI settings: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// Service reads and changes the ai_settings row and builds the provider it
|
||||
// names. API keys are sealed with its secret box and never leave it except
|
||||
// to the provider.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
box *secretbox.Box
|
||||
publicURL string
|
||||
// newProvider and newAccount are presets.FromSettings and
|
||||
// presets.ForAccount; tests substitute a fake provider.
|
||||
newProvider harness.NewProvider
|
||||
newAccount func(llm.Settings, string) (presets.Account, error)
|
||||
}
|
||||
|
||||
// New returns the service over st, sealing keys with box.
|
||||
// publicURL is MEALQUEST_PUBLIC_URL, which some presets send.
|
||||
func New(st *store.Store, box *secretbox.Box, publicURL string) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("aisettings: nil store")
|
||||
case box == nil:
|
||||
return nil, errors.New("aisettings: nil secret box")
|
||||
case publicURL == "":
|
||||
return nil, errors.New("aisettings: empty public URL")
|
||||
}
|
||||
return &Service{st: st, box: box, publicURL: publicURL, newProvider: presets.FromSettings, newAccount: presets.ForAccount}, nil
|
||||
}
|
||||
|
||||
// Get returns the settings.
|
||||
func (s *Service) Get(ctx context.Context) (Settings, error) {
|
||||
row, err := s.read(ctx, db.New(s.st.Reader))
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return settingsFrom(row), nil
|
||||
}
|
||||
|
||||
// Update applies patch and returns the settings it leaves. The provider
|
||||
// and base URL cannot change while an API key is stored, so the key never
|
||||
// reaches an endpoint it was not stored for. Every problem is reported in
|
||||
// a *ValidationError and nothing changes.
|
||||
func (s *Service) Update(ctx context.Context, patch Patch) (Settings, error) {
|
||||
return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) {
|
||||
return applyPatch(row, patch)
|
||||
})
|
||||
}
|
||||
|
||||
// SetAPIKey seals key and records its last four characters, clearing
|
||||
// key_verified_at. An empty key removes the stored one. A preset without
|
||||
// a fixed base URL takes a key only when its base URL is secure (see
|
||||
// secureurl.Check).
|
||||
func (s *Service) SetAPIKey(ctx context.Context, key string) (Settings, error) {
|
||||
return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) {
|
||||
row.ApiKeyCt, row.ApiKeyLast4, row.KeyVerifiedAt = nil, sql.NullString{}, sql.NullString{}
|
||||
if key == "" {
|
||||
return row, nil
|
||||
}
|
||||
p, _ := presets.Lookup(row.Provider)
|
||||
var reason string
|
||||
switch {
|
||||
case p.Key == presets.KeyNone:
|
||||
reason = row.Provider + " takes no API key"
|
||||
case p.BaseURL == "" && !secureBaseURL(row.BaseUrl):
|
||||
reason = "is sent only over https://, or http:// to localhost: set such a base_url first"
|
||||
case !validHeaderValue(key):
|
||||
reason = "has a character not allowed in a header"
|
||||
case len(key) < MinKeyLength:
|
||||
reason = fmt.Sprintf("must be at least %d characters", MinKeyLength)
|
||||
}
|
||||
if reason != "" {
|
||||
return db.AiSetting{}, &ValidationError{Problems: []Problem{{Field: "api_key", Reason: reason}}}
|
||||
}
|
||||
ct, err := s.box.Encrypt([]byte(key))
|
||||
if err != nil {
|
||||
return db.AiSetting{}, fmt.Errorf("aisettings: seal AI API key: %w", err)
|
||||
}
|
||||
row.ApiKeyCt = ct
|
||||
row.ApiKeyLast4 = sql.NullString{String: key[len(key)-4:], Valid: true}
|
||||
return row, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ResetPrompt restores household.DefaultPersonaPrompt as the system prompt.
|
||||
func (s *Service) ResetPrompt(ctx context.Context) (Settings, error) {
|
||||
return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) {
|
||||
row.SystemPrompt = sql.NullString{String: household.DefaultPersonaPrompt, Valid: true}
|
||||
return row, nil
|
||||
})
|
||||
}
|
||||
|
||||
// Verify checks the stored key with the provider and, when it is
|
||||
// accepted, records key_verified_at. Settings that cannot build a
|
||||
// provider fail with ErrNotConfigured; a provider failure matches
|
||||
// ErrProviderFailed and the llm typed error the provider returned. If the
|
||||
// settings change while the provider is asked, nothing is recorded and the
|
||||
// error is ErrChanged.
|
||||
func (s *Service) Verify(ctx context.Context) (Settings, error) {
|
||||
row, err := s.read(ctx, db.New(s.st.Reader))
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
acct, key, err := s.account(row)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
if err := acct.Verify(ctx); err != nil {
|
||||
return Settings{}, redact(fmt.Errorf("%w: verify: %w", ErrProviderFailed, err), key)
|
||||
}
|
||||
verified := store.Now().String()
|
||||
return s.change(ctx, func(now db.AiSetting) (db.AiSetting, error) {
|
||||
if now.Provider != row.Provider || now.BaseUrl != row.BaseUrl || !bytes.Equal(now.ApiKeyCt, row.ApiKeyCt) {
|
||||
return db.AiSetting{}, ErrChanged
|
||||
}
|
||||
now.KeyVerifiedAt = sql.NullString{String: verified, Valid: true}
|
||||
return now, nil
|
||||
})
|
||||
}
|
||||
|
||||
// Models returns the provider's live model list with the API key removed
|
||||
// from every model's ID and display name. Its errors are Verify's.
|
||||
func (s *Service) Models(ctx context.Context) ([]Model, error) {
|
||||
row, err := s.read(ctx, db.New(s.st.Reader))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
acct, key, err := s.account(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
models, err := acct.ListModels(ctx)
|
||||
if err != nil {
|
||||
return nil, redact(fmt.Errorf("%w: list models: %w", ErrProviderFailed, err), key)
|
||||
}
|
||||
out := make([]Model, len(models))
|
||||
for i, m := range models {
|
||||
out[i] = Model{ID: llm.Redact(m.ID, key), DisplayName: llm.Redact(m.DisplayName, key)}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// HarnessConfig returns the harness.Config for a skill run with the
|
||||
// current settings, sharing guard. Read it for each run, so a run always
|
||||
// uses the latest saved provider, model, temperature and persona.
|
||||
func (s *Service) HarnessConfig(ctx context.Context, guard *harness.Guard) (harness.Config, error) {
|
||||
row, err := s.read(ctx, db.New(s.st.Reader))
|
||||
if err != nil {
|
||||
return harness.Config{}, err
|
||||
}
|
||||
key, err := s.key(row)
|
||||
if err != nil {
|
||||
return harness.Config{}, err
|
||||
}
|
||||
return harness.Config{
|
||||
Settings: s.llmSettings(row),
|
||||
APIKey: key,
|
||||
NewProvider: s.newProvider,
|
||||
Persona: row.SystemPrompt.String,
|
||||
Store: s.st,
|
||||
Guard: guard,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// account builds the provider's Account from row, returning the key too
|
||||
// so the caller can redact it.
|
||||
func (s *Service) account(row db.AiSetting) (presets.Account, string, error) {
|
||||
key, err := s.key(row)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
acct, err := s.newAccount(s.llmSettings(row), key)
|
||||
if err != nil {
|
||||
return nil, "", redact(fmt.Errorf("%w: %w", ErrNotConfigured, err), key)
|
||||
}
|
||||
return acct, key, nil
|
||||
}
|
||||
|
||||
func (s *Service) llmSettings(row db.AiSetting) llm.Settings {
|
||||
return llm.Settings{
|
||||
Provider: row.Provider,
|
||||
BaseURL: row.BaseUrl,
|
||||
Model: row.Model,
|
||||
Temperature: row.Temperature,
|
||||
PublicURL: s.publicURL,
|
||||
}
|
||||
}
|
||||
|
||||
// key opens row's API key; "" when none is stored.
|
||||
func (s *Service) key(row db.AiSetting) (string, error) {
|
||||
if len(row.ApiKeyCt) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
key, err := s.box.Decrypt(row.ApiKeyCt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("aisettings: open AI API key: %w", err)
|
||||
}
|
||||
return string(key), nil
|
||||
}
|
||||
|
||||
func (s *Service) read(ctx context.Context, q *db.Queries) (db.AiSetting, error) {
|
||||
row, err := q.GetAISettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return db.AiSetting{}, errors.New("aisettings: AI settings are not seeded")
|
||||
}
|
||||
if err != nil {
|
||||
return db.AiSetting{}, fmt.Errorf("aisettings: read AI settings: %w", err)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// change saves edit's result for the current row in one transaction.
|
||||
func (s *Service) change(ctx context.Context, edit func(db.AiSetting) (db.AiSetting, error)) (Settings, error) {
|
||||
var saved db.AiSetting
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
row, err := s.read(ctx, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next, err := edit(row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
saved, err = q.UpsertAISettings(ctx, db.UpsertAISettingsParams{
|
||||
Provider: next.Provider,
|
||||
BaseUrl: next.BaseUrl,
|
||||
Model: next.Model,
|
||||
Temperature: next.Temperature,
|
||||
SystemPrompt: next.SystemPrompt,
|
||||
ApiKeyCt: next.ApiKeyCt,
|
||||
ApiKeyLast4: next.ApiKeyLast4,
|
||||
KeyVerifiedAt: next.KeyVerifiedAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: store.Now().String(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("aisettings: save AI settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return settingsFrom(saved), nil
|
||||
}
|
||||
|
||||
// applyPatch returns row with patch applied, validated.
|
||||
func applyPatch(row db.AiSetting, patch Patch) (db.AiSetting, error) {
|
||||
var problems []Problem
|
||||
fail := func(field, reason string) {
|
||||
problems = append(problems, Problem{Field: field, Reason: reason})
|
||||
}
|
||||
if patch == (Patch{}) {
|
||||
fail("settings", "no settings to change")
|
||||
}
|
||||
before := row
|
||||
if patch.Provider != nil {
|
||||
if _, ok := presets.Lookup(*patch.Provider); ok {
|
||||
row.Provider = *patch.Provider
|
||||
} else {
|
||||
fail("provider", "must be one of "+strings.Join(presets.Names(), ", "))
|
||||
}
|
||||
}
|
||||
if patch.BaseURL != nil {
|
||||
row.BaseUrl = *patch.BaseURL
|
||||
}
|
||||
if patch.Model != nil {
|
||||
switch m := *patch.Model; {
|
||||
case strings.TrimSpace(m) == "":
|
||||
fail("model", "must not be empty")
|
||||
case !validHeaderValue(m):
|
||||
fail("model", "must not contain control characters")
|
||||
default:
|
||||
row.Model = m
|
||||
}
|
||||
}
|
||||
if patch.Temperature != nil {
|
||||
t := *patch.Temperature
|
||||
tenths := math.Round(t * 10)
|
||||
if t < 0 || t > 1 || math.Abs(t*10-tenths) > 1e-9 {
|
||||
fail("temperature", "must be between 0 and 1 in steps of 0.1")
|
||||
} else {
|
||||
row.Temperature = tenths / 10
|
||||
}
|
||||
}
|
||||
if patch.SystemPrompt != nil {
|
||||
if n := utf8.RuneCountInString(*patch.SystemPrompt); n > MaxSystemPromptChars {
|
||||
fail("system_prompt", fmt.Sprintf("must be at most %d characters, got %d", MaxSystemPromptChars, n))
|
||||
} else {
|
||||
row.SystemPrompt = sql.NullString{String: *patch.SystemPrompt, Valid: true}
|
||||
}
|
||||
}
|
||||
if p, ok := presets.Lookup(row.Provider); ok && len(problems) == 0 {
|
||||
switch {
|
||||
case p.BaseURL != "" && row.BaseUrl != "" && row.BaseUrl != p.BaseURL:
|
||||
fail("base_url", fmt.Sprintf("is fixed for %s: must be empty or %s", p.Name, p.BaseURL))
|
||||
case p.BaseURL == "" && row.BaseUrl != "":
|
||||
// The preset's own check of the URL; its errors never quote it.
|
||||
if _, err := presets.ForAccount(llm.Settings{Provider: p.Name, BaseURL: row.BaseUrl}, ""); err != nil {
|
||||
fail("base_url", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
// The stored key is only ever sent where it was stored for: moving it
|
||||
// to another provider or server would let a changed base URL receive
|
||||
// it. Remove the key, change the endpoint, then store the new key.
|
||||
if len(problems) == 0 && len(row.ApiKeyCt) > 0 {
|
||||
if row.Provider != before.Provider {
|
||||
fail("provider", "cannot change while an API key is stored: remove the key first")
|
||||
}
|
||||
if row.BaseUrl != before.BaseUrl {
|
||||
fail("base_url", "cannot change while an API key is stored: remove the key first")
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return db.AiSetting{}, &ValidationError{Problems: problems}
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func settingsFrom(row db.AiSetting) Settings {
|
||||
return Settings{
|
||||
Provider: row.Provider,
|
||||
BaseURL: row.BaseUrl,
|
||||
Model: row.Model,
|
||||
Temperature: row.Temperature,
|
||||
SystemPrompt: nullString(row.SystemPrompt),
|
||||
APIKeyLast4: nullString(row.ApiKeyLast4),
|
||||
KeyVerifiedAt: nullString(row.KeyVerifiedAt),
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func nullString(s sql.NullString) *string {
|
||||
if !s.Valid {
|
||||
return nil
|
||||
}
|
||||
return &s.String
|
||||
}
|
||||
|
||||
// secureBaseURL reports whether base is a URL a key may be sent to.
|
||||
func secureBaseURL(base string) bool {
|
||||
u, err := url.Parse(base)
|
||||
return base != "" && err == nil && secureurl.Check(u) == nil
|
||||
}
|
||||
|
||||
// validHeaderValue reports whether value has no control character other
|
||||
// than tab.
|
||||
func validHeaderValue(value string) bool {
|
||||
for i := 0; i < len(value); i++ {
|
||||
if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// redactedError is an error with the API key removed from its text. It
|
||||
// does not unwrap, because the original may hold the key; Is still matches
|
||||
// the sentinels the original wraps.
|
||||
type redactedError struct {
|
||||
text string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *redactedError) Error() string { return e.text }
|
||||
|
||||
// Is reports whether the original error matches target.
|
||||
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
|
||||
|
||||
// redact returns err with key removed from its text; an empty key returns
|
||||
// err unchanged.
|
||||
func redact(err error, key string) error {
|
||||
if key == "" {
|
||||
return err
|
||||
}
|
||||
return &redactedError{text: llm.Redact(err.Error(), key), err: err}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
package aisettings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/presets"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
const testKey = "sk-ant-test-secret-key-41ab"
|
||||
|
||||
// newService returns a Service over a migrated store with the seed AI
|
||||
// settings.
|
||||
func newService(t *testing.T) (*Service, *store.Store) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedAISettings(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := make([]byte, secretbox.KeySize)
|
||||
rand.Read(raw)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := New(st, box, "https://mealquest.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, st
|
||||
}
|
||||
|
||||
// useFake makes s build f for every run, verification and model list, and
|
||||
// returns a func reporting the settings and key each build was given.
|
||||
func useFake(s *Service, f *llm.Fake) func() ([]llm.Settings, []string) {
|
||||
var mu sync.Mutex
|
||||
var settings []llm.Settings
|
||||
var keys []string
|
||||
record := func(set llm.Settings, key string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
settings = append(settings, set)
|
||||
keys = append(keys, key)
|
||||
}
|
||||
s.newProvider = func(set llm.Settings, key string) (llm.Provider, error) {
|
||||
record(set, key)
|
||||
return f, nil
|
||||
}
|
||||
s.newAccount = func(set llm.Settings, key string) (presets.Account, error) {
|
||||
record(set, key)
|
||||
return f, nil
|
||||
}
|
||||
return func() ([]llm.Settings, []string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return settings, keys
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
// echoSkill asks the model to repeat a word; its prompt is the harness's
|
||||
// test template prompts/echo.v1.tmpl.
|
||||
type echoSkill struct{}
|
||||
|
||||
type echoInput struct{ Word string }
|
||||
|
||||
type echoOutput struct {
|
||||
Echo string `json:"echo"`
|
||||
}
|
||||
|
||||
func (echoSkill) Name() string { return "echo" }
|
||||
func (echoSkill) Version() int { return 1 }
|
||||
func (echoSkill) PromptInput(in echoInput) (harness.PromptInput, error) {
|
||||
return harness.PromptInput{Data: in}, nil
|
||||
}
|
||||
func (echoSkill) Schema() llm.JSONSchema {
|
||||
return llm.JSONSchema{Name: "echo", Schema: json.RawMessage(
|
||||
`{"type":"object","properties":{"echo":{"type":"string"}},"required":["echo"],"additionalProperties":false}`)}
|
||||
}
|
||||
func (echoSkill) Validate(echoOutput) []string { return nil }
|
||||
func (echoSkill) PostProcess(out echoOutput) (echoOutput, error) { return out, nil }
|
||||
func (echoSkill) Timeout() time.Duration { return harness.DefaultTimeout }
|
||||
|
||||
// A PATCHed model and temperature are what the next skill run uses: the
|
||||
// provider is built with them, the request carries the temperature and the
|
||||
// ai_runs row records both. A second PATCH changes the run after it.
|
||||
func TestPatchedModelAndTemperatureUsedByNextRun(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, st := newService(t)
|
||||
f := &llm.Fake{}
|
||||
built := useFake(s, f)
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i, want := range []struct {
|
||||
model string
|
||||
temp float64
|
||||
}{{"claude-patched", 0.3}, {"claude-repatched", 1}} {
|
||||
if _, err := s.Update(ctx, Patch{Model: ptr(want.model), Temperature: ptr(want.temp)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := s.HarnessConfig(ctx, guard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.QueueGenerate(llm.GenerateResult{Response: llm.Response{JSON: json.RawMessage(`{"echo":"hi"}`)}})
|
||||
if _, err := harness.Run(ctx, cfg, echoSkill{}, "test", echoInput{Word: "hi"}); err != nil {
|
||||
t.Fatalf("run %d: %v", i, err)
|
||||
}
|
||||
|
||||
settings, keys := built()
|
||||
if got := settings[i]; got.Provider != "anthropic" || got.Model != want.model || got.Temperature != want.temp || keys[i] != testKey {
|
||||
t.Errorf("run %d provider built with %+v, key ok %v; want model %s at %v", i, got, keys[i] == testKey, want.model, want.temp)
|
||||
}
|
||||
req := f.Requests()[i]
|
||||
if req.Temperature == nil || *req.Temperature != want.temp {
|
||||
t.Errorf("run %d request temperature = %v, want %v", i, req.Temperature, want.temp)
|
||||
}
|
||||
if !strings.HasPrefix(req.System, household.DefaultPersonaPrompt[:40]) {
|
||||
t.Errorf("run %d system prompt does not start with the persona: %.80q", i, req.System)
|
||||
}
|
||||
var model string
|
||||
var temp float64
|
||||
err = st.Reader.QueryRowContext(ctx, `SELECT model, temperature FROM ai_runs ORDER BY created_at DESC, rowid DESC LIMIT 1`).Scan(&model, &temp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if model != want.model || temp != want.temp {
|
||||
t.Errorf("run %d ai_runs row = %s at %v, want %s at %v", i, model, temp, want.model, want.temp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateValidates(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
patch Patch
|
||||
field string
|
||||
}{
|
||||
{"empty", Patch{}, "settings"},
|
||||
{"unknown provider", Patch{Provider: ptr("gemini")}, "provider"},
|
||||
{"temperature above 1", Patch{Temperature: ptr(1.1)}, "temperature"},
|
||||
{"negative temperature", Patch{Temperature: ptr(-0.1)}, "temperature"},
|
||||
{"temperature between steps", Patch{Temperature: ptr(0.35)}, "temperature"},
|
||||
{"empty model", Patch{Model: ptr(" ")}, "model"},
|
||||
{"long system prompt", Patch{SystemPrompt: ptr(strings.Repeat("ü", MaxSystemPromptChars+1))}, "system_prompt"},
|
||||
{"other base URL for a fixed preset", Patch{BaseURL: ptr("https://api.example/v1")}, "base_url"},
|
||||
{"bad base URL", Patch{Provider: ptr("ollama"), BaseURL: ptr("ftp://ollama:11434")}, "base_url"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
s, _ := newService(t)
|
||||
before, err := s.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = s.Update(ctx, c.patch)
|
||||
var invalid *ValidationError
|
||||
if !errors.As(err, &invalid) || len(invalid.Problems) != 1 || invalid.Problems[0].Field != c.field {
|
||||
t.Fatalf("Update = %v, want one %s problem", err, c.field)
|
||||
}
|
||||
if after, err := s.Get(ctx); err != nil || !reflect.DeepEqual(after, before) {
|
||||
t.Errorf("settings changed by a refused patch: %+v, %v", after, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAccepts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _ := newService(t)
|
||||
for _, temp := range []float64{0, 0.1, 0.2, 0.3, 0.6, 0.7, 0.9, 1} {
|
||||
got, err := s.Update(ctx, Patch{Temperature: ptr(temp)})
|
||||
if err != nil || got.Temperature != temp {
|
||||
t.Errorf("temperature %v = %v, %v", temp, got.Temperature, err)
|
||||
}
|
||||
}
|
||||
prompt := strings.Repeat("ü", MaxSystemPromptChars)
|
||||
if got, err := s.Update(ctx, Patch{SystemPrompt: &prompt}); err != nil || *got.SystemPrompt != prompt {
|
||||
t.Errorf("system prompt of %d characters: %v", MaxSystemPromptChars, err)
|
||||
}
|
||||
for _, p := range presets.Names() {
|
||||
patch := Patch{Provider: ptr(p), BaseURL: ptr("")}
|
||||
if pr, _ := presets.Lookup(p); pr.BaseURL == "" {
|
||||
patch.BaseURL = ptr("http://llm.local:11434")
|
||||
}
|
||||
if got, err := s.Update(ctx, patch); err != nil || got.Provider != p {
|
||||
t.Errorf("provider %s: %+v, %v", p, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// While a key is stored the provider and base URL cannot change, so the
|
||||
// key never reaches an endpoint it was not stored for; the model,
|
||||
// temperature and system prompt can, keeping the verification.
|
||||
func TestEndpointLockedWhileKeyStored(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _ := newService(t)
|
||||
f := &llm.Fake{}
|
||||
useFake(s, f)
|
||||
if _, err := s.Update(ctx, Patch{Provider: ptr("openai_compatible"), BaseURL: ptr("https://llm.example/v1")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.QueueVerify(nil)
|
||||
if got, err := s.Verify(ctx); err != nil || got.KeyVerifiedAt == nil {
|
||||
t.Fatalf("Verify = %+v, %v", got, err)
|
||||
}
|
||||
got, err := s.Update(ctx, Patch{Model: ptr("m"), Temperature: ptr(0.5), SystemPrompt: ptr("p")})
|
||||
if err != nil || got.KeyVerifiedAt == nil {
|
||||
t.Errorf("model change = %+v, %v; want verification kept", got, err)
|
||||
}
|
||||
before, err := s.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
patch Patch
|
||||
field string
|
||||
}{
|
||||
{Patch{BaseURL: ptr("https://attacker.example/v1")}, "base_url"},
|
||||
{Patch{Provider: ptr("openai"), BaseURL: ptr("")}, "provider"},
|
||||
{Patch{Provider: ptr("ollama"), BaseURL: ptr("http://localhost:11434")}, "provider"},
|
||||
} {
|
||||
var invalid *ValidationError
|
||||
_, err := s.Update(ctx, c.patch)
|
||||
if !errors.As(err, &invalid) || !slices.ContainsFunc(invalid.Problems, func(p Problem) bool { return p.Field == c.field }) {
|
||||
t.Errorf("Update(%+v) with a key stored = %v, want a %s problem", c.patch, err, c.field)
|
||||
}
|
||||
}
|
||||
if after, err := s.Get(ctx); err != nil || !reflect.DeepEqual(after, before) {
|
||||
t.Errorf("refused endpoint change altered the settings: %+v, %v", after, err)
|
||||
}
|
||||
|
||||
// Removing the key frees the endpoint; a new key is unverified.
|
||||
if _, err := s.SetAPIKey(ctx, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Update(ctx, Patch{Provider: ptr("openai"), BaseURL: ptr("")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = s.SetAPIKey(ctx, "sk-another-key-9999")
|
||||
if err != nil || got.KeyVerifiedAt != nil || *got.APIKeyLast4 != "9999" {
|
||||
t.Errorf("new key = %+v, %v; want it unverified", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A key is never stored for a base URL it would cross the network to in
|
||||
// plain text.
|
||||
func TestSetAPIKeyNeedsSecureBaseURL(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, c := range []struct {
|
||||
base string
|
||||
ok bool
|
||||
}{
|
||||
{"", false},
|
||||
{"http://llm.lan:8080/v1", false},
|
||||
{"http://127.0.0.1:8080/v1", true},
|
||||
{"https://llm.example/v1", true},
|
||||
} {
|
||||
s, _ := newService(t)
|
||||
patch := Patch{Provider: ptr("openai_compatible")}
|
||||
if c.base != "" {
|
||||
patch.BaseURL = ptr(c.base)
|
||||
}
|
||||
if _, err := s.Update(ctx, patch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := s.SetAPIKey(ctx, testKey)
|
||||
var invalid *ValidationError
|
||||
if c.ok != (err == nil) || (!c.ok && !errors.As(err, &invalid)) {
|
||||
t.Errorf("SetAPIKey with base URL %q = %v, want ok %v", c.base, err, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyReturnsProviderTypedError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _ := newService(t)
|
||||
f := &llm.Fake{}
|
||||
useFake(s, f)
|
||||
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []error{llm.ErrAuth, llm.NewRateLimitedError(time.Second, testKey), llm.NewProviderError(500, []byte("bad "+testKey), testKey)} {
|
||||
f.QueueVerify(want)
|
||||
_, err := s.Verify(ctx)
|
||||
if !errors.Is(err, ErrProviderFailed) || !errors.Is(err, want) {
|
||||
t.Errorf("Verify = %v, want ErrProviderFailed and %v", err, want)
|
||||
}
|
||||
if err != nil && strings.Contains(err.Error(), testKey) {
|
||||
t.Errorf("Verify error holds the key: %v", err)
|
||||
}
|
||||
}
|
||||
if got, err := s.Get(ctx); err != nil || got.KeyVerifiedAt != nil {
|
||||
t.Errorf("failed verification recorded: %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A key error that quotes the key, as a misbehaving adapter might, is
|
||||
// redacted by the service.
|
||||
func TestVerifyRedactsKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _ := newService(t)
|
||||
f := &llm.Fake{}
|
||||
useFake(s, f)
|
||||
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.QueueVerify(errors.Join(llm.ErrAuth, errors.New("rejected "+testKey)))
|
||||
_, err := s.Verify(ctx)
|
||||
if !errors.Is(err, llm.ErrAuth) || strings.Contains(err.Error(), testKey) {
|
||||
t.Errorf("Verify = %v, want ErrAuth without the key", err)
|
||||
}
|
||||
f.QueueListModels(llm.ListModelsResult{Err: errors.New("rejected " + testKey)})
|
||||
if _, err := s.Models(ctx); !errors.Is(err, ErrProviderFailed) || strings.Contains(err.Error(), testKey) {
|
||||
t.Errorf("Models = %v, want ErrProviderFailed without the key", err)
|
||||
}
|
||||
f.QueueListModels(llm.ListModelsResult{Models: []llm.Model{{ID: "m-" + testKey, DisplayName: "Model " + testKey}}})
|
||||
models, err := s.Models(ctx)
|
||||
if err != nil || len(models) != 1 || models[0] != (Model{ID: "m-[REDACTED]", DisplayName: "Model [REDACTED]"}) {
|
||||
t.Errorf("Models = %+v, %v, want the key redacted", models, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Settings changed while the provider verifies the key are not marked
|
||||
// verified.
|
||||
func TestVerifyRefusesChangedSettings(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _ := newService(t)
|
||||
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.newAccount = func(llm.Settings, string) (presets.Account, error) {
|
||||
return changingAccount{s: s}, nil
|
||||
}
|
||||
if _, err := s.Verify(ctx); !errors.Is(err, ErrChanged) {
|
||||
t.Fatalf("Verify = %v, want ErrChanged", err)
|
||||
}
|
||||
if got, err := s.Get(ctx); err != nil || got.KeyVerifiedAt != nil || *got.APIKeyLast4 != "5678" {
|
||||
t.Errorf("settings after a changed verification = %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// changingAccount replaces the stored key while it verifies.
|
||||
type changingAccount struct{ s *Service }
|
||||
|
||||
func (a changingAccount) Verify(ctx context.Context) error {
|
||||
_, err := a.s.SetAPIKey(ctx, "sk-replaced-5678")
|
||||
return err
|
||||
}
|
||||
|
||||
func (changingAccount) ListModels(context.Context) ([]llm.Model, error) { return nil, nil }
|
||||
|
||||
func TestNotConfigured(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _ := newService(t)
|
||||
// The seed is anthropic with no key; the real presets refuse it.
|
||||
if _, err := s.Verify(ctx); !errors.Is(err, ErrNotConfigured) || !errors.Is(err, llm.ErrMissingKey) {
|
||||
t.Errorf("Verify = %v, want ErrNotConfigured and ErrMissingKey", err)
|
||||
}
|
||||
if _, err := s.Models(ctx); !errors.Is(err, ErrNotConfigured) {
|
||||
t.Errorf("Models = %v, want ErrNotConfigured", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAPIKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, st := newService(t)
|
||||
got, err := s.SetAPIKey(ctx, testKey)
|
||||
if err != nil || got.APIKeyLast4 == nil || *got.APIKeyLast4 != "41ab" {
|
||||
t.Fatalf("SetAPIKey = %+v, %v", got, err)
|
||||
}
|
||||
var ct []byte
|
||||
if err := st.Reader.QueryRowContext(ctx, `SELECT api_key_ct FROM ai_settings`).Scan(&ct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(ct), testKey) {
|
||||
t.Error("the key is stored in plain text")
|
||||
}
|
||||
if key, err := s.key(mustRow(t, s)); err != nil || key != testKey {
|
||||
t.Errorf("stored key opens to %q, %v", key, err)
|
||||
}
|
||||
|
||||
for _, bad := range []string{"short", "sk-bad\r\nkey"} {
|
||||
var invalid *ValidationError
|
||||
if _, err := s.SetAPIKey(ctx, bad); !errors.As(err, &invalid) {
|
||||
t.Errorf("SetAPIKey(%q) = %v, want a ValidationError", bad, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ollama takes no key: neither storing one nor switching to it with one.
|
||||
var invalid *ValidationError
|
||||
if _, err := s.Update(ctx, Patch{Provider: ptr("ollama"), BaseURL: ptr("http://ollama:11434")}); !errors.As(err, &invalid) {
|
||||
t.Errorf("switch to ollama with a key = %v, want a ValidationError", err)
|
||||
}
|
||||
if got, err := s.SetAPIKey(ctx, ""); err != nil || got.APIKeyLast4 != nil {
|
||||
t.Fatalf("remove key = %+v, %v", got, err)
|
||||
}
|
||||
if _, err := s.Update(ctx, Patch{Provider: ptr("ollama"), BaseURL: ptr("http://ollama:11434")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.SetAPIKey(ctx, testKey); !errors.As(err, &invalid) {
|
||||
t.Errorf("SetAPIKey for ollama = %v, want a ValidationError", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPrompt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _ := newService(t)
|
||||
if _, err := s.Update(ctx, Patch{SystemPrompt: ptr("")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.ResetPrompt(ctx)
|
||||
if err != nil || got.SystemPrompt == nil || *got.SystemPrompt != household.DefaultPersonaPrompt {
|
||||
t.Errorf("ResetPrompt = %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRow(t *testing.T, s *Service) db.AiSetting {
|
||||
t.Helper()
|
||||
row, err := s.read(context.Background(), db.New(s.st.Reader))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
@@ -82,9 +82,9 @@ func (s *seeder) pass(name, decidedAt string) {
|
||||
batch := s.id("batch")
|
||||
s.exec(`INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
|
||||
VALUES (?, 'manual', 'done', ?, ?)`, batch, decidedAt, decidedAt)
|
||||
s.exec(`INSERT INTO suggestions (id, dinner_id, batch_id, reason, cited_regular_ids, status,
|
||||
decided_at, created_at, updated_at) VALUES (?, ?, ?, 'why', '[]', 'declined', ?, ?, ?)`,
|
||||
s.id("suggestion"), s.dinner(name, "archived", "", "", 0, false), batch, decidedAt, decidedAt, decidedAt)
|
||||
s.exec(`INSERT INTO suggestions (id, dinner_id, batch_id, name, reason, cited_regular_ids, status,
|
||||
decided_at, created_at, updated_at) VALUES (?, ?, ?, ?, 'why', '[]', 'declined', ?, ?, ?)`,
|
||||
s.id("suggestion"), s.dinner(name, "archived", "", "", 0, false), batch, name, decidedAt, decidedAt, decidedAt)
|
||||
}
|
||||
|
||||
// plan inserts the week plan starting weekStart with one dinner per date;
|
||||
|
||||
@@ -1,10 +1,70 @@
|
||||
package harness
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// TestSharedDedupeCases runs testdata/dedupe_cases.json, which the PWA's
|
||||
// copy of Normalize and Similar (web/src/dinners/names.ts) runs too, so the
|
||||
// two cannot drift apart on these names.
|
||||
func TestSharedDedupeCases(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/dedupe_cases.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cases struct {
|
||||
Normalize []struct{ Name, Want string }
|
||||
Similar []struct {
|
||||
A, B string
|
||||
Want bool
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cases); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cases.Normalize) == 0 || len(cases.Similar) == 0 {
|
||||
t.Fatal("dedupe_cases.json has no cases")
|
||||
}
|
||||
for _, c := range cases.Normalize {
|
||||
if got := Normalize(c.Name); got != c.Want {
|
||||
t.Errorf("Normalize(%q) = %q, want %q", c.Name, got, c.Want)
|
||||
}
|
||||
}
|
||||
for _, c := range cases.Similar {
|
||||
if got := Similar(c.A, c.B); got != c.Want {
|
||||
t.Errorf("Similar(%q, %q) = %v, want %v", c.A, c.B, got, c.Want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLetterFoldsMatchPWA checks that the PWA's letter-fold table, which
|
||||
// gen_folds.py writes beside folds_table.go, is letterFolds exactly; run
|
||||
// go generate when it fails.
|
||||
func TestLetterFoldsMatchPWA(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../../web/src/dinners/letterFolds.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var pwa map[string]string
|
||||
if err := json.Unmarshal(raw, &pwa); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for r, want := range letterFolds {
|
||||
if got, ok := pwa[string(r)]; !ok || got != want {
|
||||
t.Errorf("PWA folds %U to %q (present %v), want %q", r, got, ok, want)
|
||||
}
|
||||
}
|
||||
for k := range pwa {
|
||||
if utf8.RuneCountInString(k) != 1 || letterFolds[[]rune(k)[0]] == "" {
|
||||
t.Errorf("PWA folds %q, which letterFolds does not", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, want string
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate folds_table.go, the letter-fold table used by harness.Normalize.
|
||||
"""Generate folds_table.go, the letter-fold table used by harness.Normalize,
|
||||
and its copy web/src/dinners/letterFolds.json, used by the PWA's
|
||||
normalizeName (web/src/dinners/names.ts). TestLetterFoldsMatchPWA fails when
|
||||
the two differ.
|
||||
|
||||
Run from this directory with `go generate ./internal/ai/harness` (or
|
||||
`python3 gen_folds.py`; gofmt must be on PATH). It uses only the Python
|
||||
@@ -28,11 +31,14 @@ Hangul syllables are left out: their decomposition is into conjoining jamo,
|
||||
not combining marks.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unicodedata
|
||||
|
||||
PWA_TABLE = "../../../web/src/dinners/letterFolds.json"
|
||||
|
||||
EXTRA = {
|
||||
"ß": "ss", "ẞ": "ss", "æ": "ae", "Æ": "ae", "œ": "oe", "Œ": "oe",
|
||||
"þ": "th", "Þ": "th", "ð": "d", "Ð": "d", "ı": "i", "ʼn": "n",
|
||||
@@ -110,6 +116,9 @@ def main():
|
||||
with open("folds_table.go", "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(out) + "\n")
|
||||
subprocess.run(["gofmt", "-w", "folds_table.go"], check=True)
|
||||
with open(PWA_TABLE, "w", encoding="ascii") as f:
|
||||
json.dump({chr(cp): folded for cp, folded in entries}, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"normalize": [
|
||||
{
|
||||
"name": "Easy Chicken Tikka-Masala!",
|
||||
"want": "chicken tikka masala"
|
||||
},
|
||||
{
|
||||
"name": " Homemade Chili ",
|
||||
"want": "chili"
|
||||
},
|
||||
{
|
||||
"name": "Grandma's Stew",
|
||||
"want": "grandmas stew"
|
||||
},
|
||||
{
|
||||
"name": "Grandma’s Stew",
|
||||
"want": "grandmas stew"
|
||||
},
|
||||
{
|
||||
"name": "İskender Kebab",
|
||||
"want": "iskender kebab"
|
||||
},
|
||||
{
|
||||
"name": "Paella à la Valenciana",
|
||||
"want": "paella la valenciana"
|
||||
},
|
||||
{
|
||||
"name": "Crème Brûlée",
|
||||
"want": "creme brulee"
|
||||
},
|
||||
{
|
||||
"name": "Crème Brûlée",
|
||||
"want": "creme brulee"
|
||||
},
|
||||
{
|
||||
"name": "Smørrebrød",
|
||||
"want": "smorrebrod"
|
||||
},
|
||||
{
|
||||
"name": "Pierogi z Łososiem",
|
||||
"want": "pierogi z lososiem"
|
||||
},
|
||||
{
|
||||
"name": "Straße Würstchen",
|
||||
"want": "strasse wurstchen"
|
||||
},
|
||||
{
|
||||
"name": "Œufs en Cocotte",
|
||||
"want": "oeufs en cocotte"
|
||||
},
|
||||
{
|
||||
"name": "Æbleskiver",
|
||||
"want": "aebleskiver"
|
||||
},
|
||||
{
|
||||
"name": "Phở Bò",
|
||||
"want": "pho bo"
|
||||
},
|
||||
{
|
||||
"name": "The Best Quick Recipe",
|
||||
"want": ""
|
||||
},
|
||||
{
|
||||
"name": "Chicken stir-fry",
|
||||
"want": "chicken stir fry"
|
||||
},
|
||||
{
|
||||
"name": "7-layer dip",
|
||||
"want": "7 layer dip"
|
||||
},
|
||||
{
|
||||
"name": "Ƈhili",
|
||||
"want": "chili"
|
||||
},
|
||||
{
|
||||
"name": "Ꞔarbanzo Stew",
|
||||
"want": "carbanzo stew"
|
||||
},
|
||||
{
|
||||
"name": "ΜΟΥΣΑΚΑΣ",
|
||||
"want": "μουσακασ"
|
||||
}
|
||||
],
|
||||
"similar": [
|
||||
{
|
||||
"a": "Homemade chili",
|
||||
"b": "Chili",
|
||||
"want": true
|
||||
},
|
||||
{
|
||||
"a": "Easy Spaghetti Bolognese",
|
||||
"b": "spaghetti bolognese",
|
||||
"want": true
|
||||
},
|
||||
{
|
||||
"a": "Chicken stir fry",
|
||||
"b": "Chicken stir-fry",
|
||||
"want": true
|
||||
},
|
||||
{
|
||||
"a": "Crème brûlée",
|
||||
"b": "Creme brulee",
|
||||
"want": true
|
||||
},
|
||||
{
|
||||
"a": "Smørrebrød",
|
||||
"b": "smorrebrod",
|
||||
"want": true
|
||||
},
|
||||
{
|
||||
"a": "Beef tacos",
|
||||
"b": "Fish tacos",
|
||||
"want": false
|
||||
},
|
||||
{
|
||||
"a": "Chili",
|
||||
"b": "White chicken chili",
|
||||
"want": false
|
||||
},
|
||||
{
|
||||
"a": "Pot roast",
|
||||
"b": "Roast",
|
||||
"want": false
|
||||
},
|
||||
{
|
||||
"a": "The best",
|
||||
"b": "Easy recipe",
|
||||
"want": false
|
||||
},
|
||||
{
|
||||
"a": "Ƈhili",
|
||||
"b": "Chili",
|
||||
"want": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"regexp"
|
||||
"slices"
|
||||
@@ -115,6 +116,11 @@ func Lookup(name string) (Preset, bool) {
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// Names returns every preset's name, sorted.
|
||||
func Names() []string {
|
||||
return slices.Sorted(maps.Keys(all))
|
||||
}
|
||||
|
||||
// all is every preset, by Settings.Provider name.
|
||||
var all = map[string]Preset{
|
||||
"anthropic": {
|
||||
@@ -181,19 +187,40 @@ var all = map[string]Preset{
|
||||
// preset's OmitTemperature matches. It is wrapped in llm.NewRetrying, so
|
||||
// Generate retries transient vendor failures and has the default timeout.
|
||||
func FromSettings(s llm.Settings, key string) (llm.Provider, error) {
|
||||
cp, err := fromSettings(s, key)
|
||||
cp, err := fromSettings(s, key, true)
|
||||
if err != nil {
|
||||
return nil, redact(err, key)
|
||||
}
|
||||
return llm.NewRetrying(cp), nil
|
||||
}
|
||||
|
||||
func fromSettings(s llm.Settings, key string) (*configured, error) {
|
||||
// Account is what a provider offers before a model is chosen: the live
|
||||
// model list and a check of the credentials.
|
||||
type Account interface {
|
||||
ListModels(ctx context.Context) ([]llm.Model, error)
|
||||
Verify(ctx context.Context) error
|
||||
}
|
||||
|
||||
// ForAccount returns the Account s describes, using key as its API key. It
|
||||
// checks s and key as FromSettings does except that s.Model may be empty,
|
||||
// so the household can verify its key and pick from the live model list
|
||||
// before it has chosen a model. Neither call is retried.
|
||||
func ForAccount(s llm.Settings, key string) (Account, error) {
|
||||
cp, err := fromSettings(s, key, false)
|
||||
if err != nil {
|
||||
return nil, redact(err, key)
|
||||
}
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
// fromSettings builds the provider s describes; needModel is whether
|
||||
// s.Model is required.
|
||||
func fromSettings(s llm.Settings, key string, needModel bool) (*configured, error) {
|
||||
p, ok := Lookup(s.Provider)
|
||||
if !ok {
|
||||
return nil, llm.ErrUnknownProvider
|
||||
}
|
||||
if err := check(p, s, key); err != nil {
|
||||
if err := check(p, s, key, needModel); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", p.Name, err)
|
||||
}
|
||||
base := p.BaseURL
|
||||
@@ -233,8 +260,9 @@ func fromSettings(s llm.Settings, key string) (*configured, error) {
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
// check validates s and key against p. Its errors never quote a value.
|
||||
func check(p Preset, s llm.Settings, key string) error {
|
||||
// check validates s and key against p, and that s names a model when
|
||||
// needModel is set. Its errors never quote a value.
|
||||
func check(p Preset, s llm.Settings, key string, needModel bool) error {
|
||||
switch {
|
||||
case p.Key == KeyRequired && key == "":
|
||||
return llm.ErrMissingKey
|
||||
@@ -249,7 +277,7 @@ func check(p Preset, s llm.Settings, key string) error {
|
||||
case p.BaseURL != "" && s.BaseURL != "" && s.BaseURL != p.BaseURL:
|
||||
return fmt.Errorf("%w: base URL is fixed by the preset", llm.ErrInvalidSetting)
|
||||
}
|
||||
if s.Model == "" {
|
||||
if needModel && s.Model == "" {
|
||||
return llm.ErrMissingModel
|
||||
}
|
||||
if p.RequiresPublicURL && s.PublicURL == "" {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -660,3 +661,35 @@ func TestFromSettingsRetriesTransportTimeout(t *testing.T) {
|
||||
t.Errorf("sent %d requests, want 2", st.sent)
|
||||
}
|
||||
}
|
||||
|
||||
// ForAccount builds without a model, so a key can be verified and the
|
||||
// model list read before a model is chosen, but keeps every other check.
|
||||
func TestForAccount(t *testing.T) {
|
||||
if _, err := ForAccount(llm.Settings{Provider: "openai"}, testKey); err != nil {
|
||||
t.Errorf("ForAccount without a model = %v", err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
s llm.Settings
|
||||
key string
|
||||
want error
|
||||
}{
|
||||
{llm.Settings{Provider: "gemini"}, testKey, llm.ErrUnknownProvider},
|
||||
{llm.Settings{Provider: "openai"}, "", llm.ErrMissingKey},
|
||||
{llm.Settings{Provider: "ollama"}, "", llm.ErrMissingBaseURL},
|
||||
{llm.Settings{Provider: "openrouter"}, testKey, llm.ErrMissingPublicURL},
|
||||
} {
|
||||
if _, err := ForAccount(c.s, c.key); !errors.Is(err, c.want) {
|
||||
t.Errorf("ForAccount(%s) = %v, want %v", c.s.Provider, err, c.want)
|
||||
}
|
||||
}
|
||||
if _, err := FromSettings(llm.Settings{Provider: "openai"}, testKey); !errors.Is(err, llm.ErrMissingModel) {
|
||||
t.Errorf("FromSettings without a model = %v, want ErrMissingModel", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNames(t *testing.T) {
|
||||
want := []string{"anthropic", "ollama", "openai", "openai_compatible", "openrouter", "xai"}
|
||||
if got := Names(); !slices.Equal(got, want) {
|
||||
t.Errorf("Names = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
# version, rename its template to match and add a line for the new version.
|
||||
echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce
|
||||
normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee
|
||||
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{{/* suggest_side_quests drafts Side quests: new dinners for the household to try, each based on its Regulars. */}}
|
||||
{{define "instructions" -}}
|
||||
You suggest Side quests: new dinners for the household to try, based on the dinners it already makes.
|
||||
|
||||
Every Side quest must be a dinner the household has not had: never one of its Regulars, its Quest log, its pending Side quests or the dinners it has passed, and never one of those under another name. Base every Side quest on one or more of the household's Regulars, such as a familiar protein in a new cuisine or familiar flavours cooked a new way. Prefer the favorite Regulars, and vary the cuisines across the batch.
|
||||
|
||||
Give for each Side quest:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- cuisine: its cuisine, e.g. "Thai" or "Tex-Mex".
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- reason: one sentence of at most 180 characters saying why the household would like it. It must name at least one of the Regulars in cited_regulars.
|
||||
- cited_regulars: the Regulars the reason relies on, at least one, each written exactly as its name appears in the Regulars list of the household context, without the details in parentheses. Cite only Regulars from that list.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
|
||||
One valid Side quest, for a household with a Regular named "Chicken Fajitas" (an example only, not about this household):
|
||||
{"name": "Chicken Shawarma Bowls", "cuisine": "Middle Eastern", "method": "oven", "est_minutes": 40, "difficulty": "easy", "reason": "Spiced roast chicken and crisp toppings, like your Chicken Fajitas with a Middle Eastern twist.", "cited_regulars": ["Chicken Fajitas"], "search_query": "chicken shawarma bowls recipe"}
|
||||
{{- end}}
|
||||
{{define "user" -}}
|
||||
Suggest {{.Count}} Side quests.
|
||||
{{- end}}
|
||||
@@ -0,0 +1,420 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// ReasonLimit is the most characters a Side quest's reason, its "Why
|
||||
// this?", may have.
|
||||
const ReasonLimit = 180
|
||||
|
||||
// SideQuestsRequest is what suggest_side_quests reads.
|
||||
type SideQuestsRequest struct {
|
||||
// Context is the household context (see harness.BuildContext). It must
|
||||
// hold at least one Regular, since every Side quest cites one.
|
||||
Context harness.HouseholdContext
|
||||
// Needed is how many Side quests the caller wants to keep. The skill
|
||||
// asks the model for twice as many, since validation and grounding
|
||||
// drop some. It must be positive.
|
||||
Needed int
|
||||
// Pending holds the names of the Side quests not yet tried or passed.
|
||||
Pending []string
|
||||
// Declined holds the name of every passed Side quest, not only the
|
||||
// recent ones Context holds.
|
||||
Declined []string
|
||||
}
|
||||
|
||||
// SideQuest is one suggested new dinner. Method, EstMinutes and Difficulty
|
||||
// are the model's estimates until the dinner is grounded in a recipe.
|
||||
type SideQuest struct {
|
||||
Name string `json:"name"`
|
||||
Cuisine string `json:"cuisine"`
|
||||
// Method is one of stovetop, oven, crockpot, grill and other.
|
||||
Method string `json:"method"`
|
||||
EstMinutes int `json:"est_minutes"`
|
||||
// Difficulty is easy, medium or hard.
|
||||
Difficulty string `json:"difficulty"`
|
||||
// Reason is the "Why this?": at most ReasonLimit characters, naming at
|
||||
// least one of CitedRegulars.
|
||||
Reason string `json:"reason"`
|
||||
// CitedRegulars are names of the household's Regulars, as the
|
||||
// household writes them.
|
||||
CitedRegulars []string `json:"cited_regulars"`
|
||||
// SearchQuery is a web search query that finds a recipe for the dinner.
|
||||
SearchQuery string `json:"search_query"`
|
||||
}
|
||||
|
||||
// SideQuests is suggest_side_quests' output.
|
||||
type SideQuests struct {
|
||||
// Suggestions are the Side quests that passed every rule, in the
|
||||
// model's order. There may be more or fewer than Needed.
|
||||
Suggestions []SideQuest `json:"suggestions"`
|
||||
// Dropped are the Side quests the model gave that broke a rule, in the
|
||||
// model's order. It is never part of the model's reply.
|
||||
Dropped []DroppedSideQuest `json:"-"`
|
||||
}
|
||||
|
||||
// DroppedSideQuest is a Side quest dropped by PostProcess.
|
||||
type DroppedSideQuest struct {
|
||||
Name string
|
||||
// Why says which rule it broke.
|
||||
Why string
|
||||
}
|
||||
|
||||
// SuggestSideQuests runs suggest_side_quests v1 on in and returns the Side
|
||||
// quests that cite only real Regulars, give a short reason naming one of
|
||||
// them, and repeat no Regular, Quest log dinner, pending or passed Side
|
||||
// quest, nor each other. The rest are in Dropped. scope names the batch
|
||||
// (see harness.Run).
|
||||
func SuggestSideQuests(ctx context.Context, cfg harness.Config, scope string, in SideQuestsRequest) (SideQuests, error) {
|
||||
return harness.Run(ctx, cfg, suggestSideQuests{in: in}, scope, in)
|
||||
}
|
||||
|
||||
// suggestSideQuests is the skill. It holds its input because validation
|
||||
// and post-processing compare the output with it.
|
||||
type suggestSideQuests struct {
|
||||
in SideQuestsRequest
|
||||
}
|
||||
|
||||
var _ harness.Skill[SideQuestsRequest, SideQuests] = suggestSideQuests{}
|
||||
|
||||
func (suggestSideQuests) Name() string { return "suggest_side_quests" }
|
||||
|
||||
func (suggestSideQuests) Version() int { return 1 }
|
||||
|
||||
func (suggestSideQuests) Timeout() time.Duration { return harness.DefaultTimeout }
|
||||
|
||||
// sideQuestsPrompt is the template data of suggest_side_quests.v1.tmpl.
|
||||
type sideQuestsPrompt struct {
|
||||
// Count is how many Side quests to ask for.
|
||||
Count int
|
||||
}
|
||||
|
||||
// requested is how many Side quests the model is asked for.
|
||||
func (s suggestSideQuests) requested() int { return 2 * s.in.Needed }
|
||||
|
||||
func (suggestSideQuests) PromptInput(in SideQuestsRequest) (harness.PromptInput, error) {
|
||||
if in.Needed <= 0 {
|
||||
return harness.PromptInput{}, fmt.Errorf("%d Side quests needed; the number must be positive", in.Needed)
|
||||
}
|
||||
if len(in.Context.Regulars) == 0 {
|
||||
return harness.PromptInput{}, errors.New("household has no Regulars for Side quests to be based on")
|
||||
}
|
||||
sections := append(in.Context.Sections(), harness.ContextSection{Title: "Pending Side quests", Lines: in.Pending})
|
||||
return harness.PromptInput{Data: sideQuestsPrompt{Count: suggestSideQuests{in: in}.requested()}, Context: sections}, nil
|
||||
}
|
||||
|
||||
func (suggestSideQuests) Schema() llm.JSONSchema {
|
||||
return llm.JSONSchema{Name: "suggest_side_quests", Schema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suggestions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"cuisine": {"type": "string"},
|
||||
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
|
||||
"est_minutes": {"type": "integer"},
|
||||
"difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
|
||||
"reason": {"type": "string"},
|
||||
"cited_regulars": {"type": "array", "items": {"type": "string"}},
|
||||
"search_query": {"type": "string"}
|
||||
},
|
||||
"required": ["name", "cuisine", "method", "est_minutes", "difficulty", "reason", "cited_regulars", "search_query"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["suggestions"],
|
||||
"additionalProperties": false
|
||||
}`)}
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes a Side quest, requiring each of its fields as the
|
||||
// schema does: encoding/json would otherwise read a missing or null one as
|
||||
// empty.
|
||||
func (q *SideQuest) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "name", "cuisine", "method", "est_minutes", "difficulty", "reason", "cited_regulars", "search_query"); err != nil {
|
||||
return fmt.Errorf("suggestion %s", err)
|
||||
}
|
||||
type plain SideQuest
|
||||
return json.Unmarshal(data, (*plain)(q))
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes the output, requiring its one field as the schema
|
||||
// does.
|
||||
func (out *SideQuests) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "suggestions"); err != nil {
|
||||
return fmt.Errorf("output %s", err)
|
||||
}
|
||||
type plain SideQuests
|
||||
return json.Unmarshal(data, (*plain)(out))
|
||||
}
|
||||
|
||||
// Validate finds what makes the reply unusable as a whole: a wrong number
|
||||
// of Side quests or one that is malformed. A well-formed Side quest that
|
||||
// breaks a rule is dropped by PostProcess instead.
|
||||
func (s suggestSideQuests) Validate(out SideQuests) []string {
|
||||
var problems []string
|
||||
switch n := len(out.Suggestions); {
|
||||
case n == 0 && len(out.Dropped) == 0:
|
||||
problems = append(problems, fmt.Sprintf("suggestions is empty; give %d Side quests", s.requested()))
|
||||
case n > s.requested():
|
||||
problems = append(problems, fmt.Sprintf("suggestions has %d entries; give at most %d Side quests", n, s.requested()))
|
||||
}
|
||||
for i, q := range out.Suggestions {
|
||||
for _, f := range []struct{ field, value string }{
|
||||
{"name", q.Name}, {"cuisine", q.Cuisine}, {"reason", q.Reason}, {"search_query", q.SearchQuery},
|
||||
} {
|
||||
if strings.TrimSpace(f.value) == "" {
|
||||
problems = append(problems, fmt.Sprintf("suggestion %d has an empty %s; give one", i+1, f.field))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(q.Name) != "" && harness.Normalize(q.Name) == "" {
|
||||
problems = append(problems, fmt.Sprintf("suggestion %d name %q has only filler words; name the dish", i+1, q.Name))
|
||||
}
|
||||
if !slices.Contains(methods, q.Method) {
|
||||
problems = append(problems, fmt.Sprintf("suggestion %d method %q is not one of %s", i+1, q.Method, strings.Join(methods, ", ")))
|
||||
}
|
||||
if q.EstMinutes <= 0 {
|
||||
problems = append(problems, fmt.Sprintf("suggestion %d est_minutes %d is not positive; give the minutes from prep to table", i+1, q.EstMinutes))
|
||||
}
|
||||
if !slices.Contains(difficulties, q.Difficulty) {
|
||||
problems = append(problems, fmt.Sprintf("suggestion %d difficulty %q is not one of %s", i+1, q.Difficulty, strings.Join(difficulties, ", ")))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// PostProcess tidies every Side quest's text, writes its cited Regulars as
|
||||
// the household does, and moves each one that breaks a rule to Dropped
|
||||
// (see drop).
|
||||
func (s suggestSideQuests) PostProcess(out SideQuests) (SideQuests, error) {
|
||||
regulars := regularNames{exact: map[string][]string{}, normalized: map[string][]string{}}
|
||||
var names []string
|
||||
for _, r := range s.in.Context.Regulars {
|
||||
regulars.add(r.Name)
|
||||
names = append(names, r.Name)
|
||||
}
|
||||
regulars.all = names
|
||||
known := harness.NewDeduper(harness.DedupeSources{
|
||||
Regulars: names,
|
||||
QuestLog: s.in.Context.QuestLog,
|
||||
Pending: s.in.Pending,
|
||||
Declined: s.in.Declined,
|
||||
})
|
||||
result := SideQuests{Suggestions: []SideQuest{}}
|
||||
for _, q := range out.Suggestions {
|
||||
q = SideQuest{
|
||||
Name: oneLine(q.Name),
|
||||
Cuisine: oneLine(q.Cuisine),
|
||||
Method: q.Method,
|
||||
EstMinutes: q.EstMinutes,
|
||||
Difficulty: q.Difficulty,
|
||||
Reason: oneLine(q.Reason),
|
||||
CitedRegulars: q.CitedRegulars,
|
||||
SearchQuery: oneLine(q.SearchQuery),
|
||||
}
|
||||
cited, why := drop(q, regulars, known, result.Suggestions)
|
||||
if why != "" {
|
||||
result.Dropped = append(result.Dropped, DroppedSideQuest{Name: q.Name, Why: why})
|
||||
continue
|
||||
}
|
||||
q.CitedRegulars = cited
|
||||
result.Suggestions = append(result.Suggestions, q)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// regularNames finds the Regular a cited name means. Names need not be
|
||||
// unique, even ignoring case, so each key holds every Regular with it.
|
||||
type regularNames struct {
|
||||
// exact is keyed by the name in lower case on one line.
|
||||
exact map[string][]string
|
||||
// normalized is keyed by harness.Normalize of the name.
|
||||
normalized map[string][]string
|
||||
// all holds every Regular's name, in the household's order.
|
||||
all []string
|
||||
}
|
||||
|
||||
func exactName(name string) string { return strings.ToLower(oneLine(name)) }
|
||||
|
||||
func (r regularNames) add(name string) {
|
||||
r.exact[exactName(name)] = append(r.exact[exactName(name)], name)
|
||||
if n := harness.Normalize(name); n != "" {
|
||||
r.normalized[n] = append(r.normalized[n], name)
|
||||
}
|
||||
}
|
||||
|
||||
// find returns the one Regular cited means, or "" and why there is none.
|
||||
// A Regular named exactly cited, ignoring case and spacing, comes first;
|
||||
// failing that, cited may name a Regular by its normalized name.
|
||||
func (r regularNames) find(cited string) (string, string) {
|
||||
matches := r.exact[exactName(cited)]
|
||||
if len(matches) == 0 {
|
||||
matches = r.normalized[harness.Normalize(cited)]
|
||||
}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return "", fmt.Sprintf("cites %q, which is not a Regular", cited)
|
||||
case 1:
|
||||
return matches[0], ""
|
||||
default:
|
||||
return "", fmt.Sprintf("cites %q, which could be any of the Regulars %q", cited, matches)
|
||||
}
|
||||
}
|
||||
|
||||
// drop returns why q must be dropped, or "" and its cited Regulars as the
|
||||
// household writes them when it may be kept. q is dropped when it repeats
|
||||
// a known dinner or a Side quest already kept, cites no Regular or one
|
||||
// regulars finds no single Regular for, or has a reason over ReasonLimit
|
||||
// characters or naming none of its cited Regulars.
|
||||
func drop(q SideQuest, regulars regularNames, known *harness.Deduper, kept []SideQuest) ([]string, string) {
|
||||
if known.IsDuplicate(q.Name) {
|
||||
return nil, "repeats a Regular, Quest log dinner, pending or passed Side quest"
|
||||
}
|
||||
for _, k := range kept {
|
||||
if harness.Similar(q.Name, k.Name) {
|
||||
return nil, fmt.Sprintf("repeats the Side quest %q in the same batch", k.Name)
|
||||
}
|
||||
}
|
||||
if len(q.CitedRegulars) == 0 {
|
||||
return nil, "cites no Regular"
|
||||
}
|
||||
var cited []string
|
||||
for _, c := range q.CitedRegulars {
|
||||
name, why := regulars.find(c)
|
||||
if why != "" {
|
||||
return nil, why
|
||||
}
|
||||
cited = append(cited, name)
|
||||
}
|
||||
if n := utf8.RuneCountInString(q.Reason); n > ReasonLimit {
|
||||
return nil, fmt.Sprintf("reason has %d characters, over %d", n, ReasonLimit)
|
||||
}
|
||||
named := regulars.named(q.Reason)
|
||||
if !slices.ContainsFunc(cited, func(name string) bool { return slices.Contains(named, name) }) {
|
||||
return nil, "reason names none of its cited Regulars"
|
||||
}
|
||||
return cited, ""
|
||||
}
|
||||
|
||||
// named returns the Regulars text names, by one rule. A mention of a
|
||||
// Regular is a place where text spells its name, ignoring case and spacing,
|
||||
// with no letter, mark or digit just outside it: the start or end of text,
|
||||
// white space, punctuation or a symbol must border it, so "Beef
|
||||
// Chili-inspired" and "Beef Chili's" mention "Beef Chili" while "Beef
|
||||
// Chilis" does not, nor does "Cafe" with a combining accent after it
|
||||
// mention "Cafe". Punctuation at either end of the name, with any spacing
|
||||
// beside it, is optional, and a mention takes in as much of it as text
|
||||
// spells, spaced in any way, with the border still holding after it: "Easy
|
||||
// Beef Chili!" is mentioned by "Easy Beef Chili." too, by "Easy Beef Chili
|
||||
// !" with its "!", and by "Easy Beef Chili!x" without its "!", and "Beef
|
||||
// Chili!!" by "Beef Chili!" with one "!". A
|
||||
// combining mark is part of its letter, never punctuation. A mention names
|
||||
// its Regular unless a mention of a different Regular covers the same text
|
||||
// or more, so "Your Easy Beef Chili." names "Easy Beef Chili" and not "Beef
|
||||
// Chili", and text that two Regulars spell alike names neither there.
|
||||
func (r regularNames) named(text string) []string {
|
||||
type mention struct {
|
||||
name string
|
||||
start, end int
|
||||
}
|
||||
text = exactName(text)
|
||||
bordered := func(start, end int) bool {
|
||||
before, _ := utf8.DecodeLastRuneInString(text[:start])
|
||||
after, _ := utf8.DecodeRuneInString(text[end:])
|
||||
return (start == 0 || !isWord(before)) && (end == len(text) || !isWord(after))
|
||||
}
|
||||
var mentions []mention
|
||||
for _, name := range r.all {
|
||||
full := exactName(name)
|
||||
core := strings.TrimFunc(full, isOuter)
|
||||
if core == "" {
|
||||
continue
|
||||
}
|
||||
lead := full[:len(full)-len(strings.TrimLeftFunc(full, isOuter))]
|
||||
trail := full[len(lead)+len(core):]
|
||||
for at := 0; ; {
|
||||
i := strings.Index(text[at:], core)
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
start, end := at+i, at+i+len(core)
|
||||
at = start + 1
|
||||
if !bordered(start, end) {
|
||||
continue
|
||||
}
|
||||
start = spelledBefore(text, start, lead, func(s int) bool { return bordered(s, end) })
|
||||
end = spelledAfter(text, end, trail, func(e int) bool { return bordered(start, e) })
|
||||
mentions = append(mentions, mention{name, start, end})
|
||||
}
|
||||
}
|
||||
var names []string
|
||||
for _, m := range mentions {
|
||||
covered := slices.ContainsFunc(mentions, func(o mention) bool {
|
||||
return o.name != m.name && o.start <= m.start && o.end >= m.end
|
||||
})
|
||||
if !covered && !slices.Contains(names, m.name) {
|
||||
names = append(names, m.name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// spelledAfter returns where the longest start of outer that text spells
|
||||
// from at ends, ignoring spacing, among those after which bordered holds,
|
||||
// or at when there are none.
|
||||
func spelledAfter(text string, at int, outer string, bordered func(int) bool) int {
|
||||
end := at
|
||||
for _, c := range strings.Join(strings.Fields(outer), "") {
|
||||
at = len(text) - len(strings.TrimLeftFunc(text[at:], unicode.IsSpace))
|
||||
if !strings.HasPrefix(text[at:], string(c)) {
|
||||
break
|
||||
}
|
||||
at += utf8.RuneLen(c)
|
||||
if bordered(at) {
|
||||
end = at
|
||||
}
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
// spelledBefore returns where the longest end of outer that text spells up
|
||||
// to at starts, ignoring spacing, among those before which bordered holds,
|
||||
// or at when there are none.
|
||||
func spelledBefore(text string, at int, outer string, bordered func(int) bool) int {
|
||||
start := at
|
||||
punct := []rune(strings.Join(strings.Fields(outer), ""))
|
||||
for i := len(punct) - 1; i >= 0; i-- {
|
||||
at = len(strings.TrimRightFunc(text[:at], unicode.IsSpace))
|
||||
if !strings.HasSuffix(text[:at], string(punct[i])) {
|
||||
break
|
||||
}
|
||||
at -= utf8.RuneLen(punct[i])
|
||||
if bordered(at) {
|
||||
start = at
|
||||
}
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
// isWord reports whether c is a letter, a mark on one or a digit.
|
||||
func isWord(c rune) bool { return unicode.IsLetter(c) || unicode.IsMark(c) || unicode.IsDigit(c) }
|
||||
|
||||
// isOuter reports whether c may be left off the end of a name: punctuation
|
||||
// or white space.
|
||||
func isOuter(c rune) bool { return unicode.IsPunct(c) || unicode.IsSpace(c) }
|
||||
@@ -0,0 +1,488 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"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/store"
|
||||
)
|
||||
|
||||
func date(y int, m time.Month, d int) store.LocalDate {
|
||||
return store.LocalDateOf(time.Date(y, m, d, 0, 0, 0, 0, time.UTC))
|
||||
}
|
||||
|
||||
// household asks for two Side quests, so four are requested.
|
||||
var household = SideQuestsRequest{
|
||||
Context: harness.HouseholdContext{
|
||||
Today: date(2026, 10, 6),
|
||||
Timezone: "America/New_York",
|
||||
Regulars: []harness.RegularDinner{
|
||||
{Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240},
|
||||
{Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30, Favorite: true},
|
||||
{Name: "Spaghetti Bolognese", Cuisine: "Italian", Method: "stovetop", Minutes: 45},
|
||||
},
|
||||
QuestLog: []string{"Pad Thai"},
|
||||
Passed: []string{"Chicken Tikka Masala"},
|
||||
Plans: []harness.PlannedDinner{
|
||||
{Date: date(2026, 10, 5), Name: "Chicken Fajitas"},
|
||||
{Date: date(2026, 10, 6), Name: "Beef Chili"},
|
||||
},
|
||||
},
|
||||
Needed: 2,
|
||||
Pending: []string{"Shrimp Scampi"},
|
||||
Declined: []string{"Chicken Tikka Masala", "Fish Tacos"},
|
||||
}
|
||||
|
||||
// sideQuest renders one Side quest of a reply.
|
||||
func sideQuest(name, reason string, cited ...string) string {
|
||||
c, _ := json.Marshal(append([]string{}, cited...))
|
||||
r, _ := json.Marshal(reason)
|
||||
return fmt.Sprintf(`{"name":%q,"cuisine":"Fusion","method":"stovetop","est_minutes":35,"difficulty":"easy","reason":%s,"cited_regulars":%s,"search_query":%q}`,
|
||||
name, r, c, strings.ToLower(name)+" recipe")
|
||||
}
|
||||
|
||||
// batch renders a reply holding the Side quests given.
|
||||
func batch(quests ...string) string {
|
||||
return `{"suggestions":[` + strings.Join(quests, ",") + `]}`
|
||||
}
|
||||
|
||||
var (
|
||||
shawarma = sideQuest("Chicken Shawarma Bowls", "Spiced chicken and crisp toppings, like your Chicken Fajitas.", "Chicken Fajitas")
|
||||
ragu = sideQuest("Beef Ragu Pappardelle", "A slow beef sauce like your Spaghetti Bolognese, on wide noodles.", "Spaghetti Bolognese")
|
||||
verde = sideQuest("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "beef chili")
|
||||
bulgogi = sideQuest("Beef Bulgogi", "Sweet, quick-seared beef for fans of Chicken Fajitas and Beef Chili.", "Chicken Fajitas", "Beef Chili")
|
||||
)
|
||||
|
||||
// want returns the Side quest that sideQuest renders, with cited as the
|
||||
// household writes them.
|
||||
func want(name, reason string, cited ...string) SideQuest {
|
||||
return SideQuest{
|
||||
Name: name, Cuisine: "Fusion", Method: "stovetop", EstMinutes: 35, Difficulty: "easy",
|
||||
Reason: reason, CitedRegulars: cited, SearchQuery: strings.ToLower(name) + " recipe",
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
wantShawarma = want("Chicken Shawarma Bowls", "Spiced chicken and crisp toppings, like your Chicken Fajitas.", "Chicken Fajitas")
|
||||
wantRagu = want("Beef Ragu Pappardelle", "A slow beef sauce like your Spaghetti Bolognese, on wide noodles.", "Spaghetti Bolognese")
|
||||
wantVerde = want("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "Beef Chili")
|
||||
wantBulgogi = want("Beef Bulgogi", "Sweet, quick-seared beef for fans of Chicken Fajitas and Beef Chili.", "Chicken Fajitas", "Beef Chili")
|
||||
)
|
||||
|
||||
// suggest runs SuggestSideQuests on household with f and checks f was
|
||||
// called exactly calls times, each at the household temperature.
|
||||
func suggest(t *testing.T, f *llm.Fake, in SideQuestsRequest, calls int) (SideQuests, []llm.Request, error) {
|
||||
t.Helper()
|
||||
out, err := SuggestSideQuests(context.Background(), testConfig(t, f), "2026-W41", in)
|
||||
reqs := f.Requests()
|
||||
if len(reqs) != calls {
|
||||
t.Fatalf("Generate called %d times, want %d", len(reqs), calls)
|
||||
}
|
||||
if g, _, _ := f.Remaining(); g != 0 {
|
||||
t.Fatalf("%d scripted Generate results unused", g)
|
||||
}
|
||||
for i, req := range reqs {
|
||||
if req.Temperature == nil || *req.Temperature != 0.7 {
|
||||
t.Fatalf("request %d temperature = %v, want 0.7", i+1, req.Temperature)
|
||||
}
|
||||
if req.Schema == nil || req.Schema.Name != "suggest_side_quests" {
|
||||
t.Fatalf("request %d schema = %v, want suggest_side_quests", i+1, req.Schema)
|
||||
}
|
||||
}
|
||||
return out, reqs, err
|
||||
}
|
||||
|
||||
func assertSideQuests(t *testing.T, got, want SideQuests) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("output = %+v\nwant %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsCleanBatch(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(batch(shawarma, ragu, verde, bulgogi)))
|
||||
out, reqs, err := suggest(t, &f, household, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSideQuests(t, out, SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu, wantVerde, wantBulgogi}})
|
||||
|
||||
system, user := reqs[0].System, reqs[0].Messages[0].Content
|
||||
golden(t, "suggest_side_quests_clean.system.golden", system)
|
||||
golden(t, "suggest_side_quests_clean.user.golden", user)
|
||||
for _, part := range []string{
|
||||
"## Household context",
|
||||
"- Chicken Fajitas (Tex-Mex, stovetop, 30 min, favorite)",
|
||||
"### Quest log\n- Pad Thai",
|
||||
"### Passed, oldest first\n- Chicken Tikka Masala",
|
||||
"- 2026-10-06 Tue: Beef Chili",
|
||||
"### Pending Side quests\n- Shrimp Scampi",
|
||||
"## Output contract",
|
||||
`"cited_regulars"`,
|
||||
`"search_query"`,
|
||||
} {
|
||||
if !strings.Contains(system, part) {
|
||||
t.Errorf("system prompt lacks %q", part)
|
||||
}
|
||||
}
|
||||
if strings.Index(system, "## Household context") > strings.Index(system, "## Output contract") {
|
||||
t.Error("the output contract is not after the household context")
|
||||
}
|
||||
if user != "Suggest 4 Side quests." {
|
||||
t.Errorf("user message = %q, want twice the 2 needed", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsPersonaFirst(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(batch(shawarma)))
|
||||
cfg := testConfig(t, &f)
|
||||
cfg.Persona = "You are the household's cheerful kitchen helper."
|
||||
if _, err := SuggestSideQuests(context.Background(), cfg, "2026-W41", household); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if system := f.Requests()[0].System; !strings.HasPrefix(system, cfg.Persona+"\n\n## Instructions\n") {
|
||||
t.Fatalf("system prompt does not start with the persona then the instructions:\n%s", system)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsInventedRegularDropped(t *testing.T) {
|
||||
invented := sideQuest("Meatloaf Wellington", "Your Grandma's Meatloaf, wrapped in pastry.", "Grandma's Meatloaf")
|
||||
partly := sideQuest("Fajita Meatballs", "Chicken Fajitas meets your Grandma's Meatloaf.", "Chicken Fajitas", "Grandma's Meatloaf")
|
||||
none := sideQuest("Mushroom Risotto", "Creamy and comforting.")
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(batch(invented, shawarma, partly, none)))
|
||||
out, _, err := suggest(t, &f, household, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSideQuests(t, out, SideQuests{
|
||||
Suggestions: []SideQuest{wantShawarma},
|
||||
Dropped: []DroppedSideQuest{
|
||||
{Name: "Meatloaf Wellington", Why: `cites "Grandma's Meatloaf", which is not a Regular`},
|
||||
{Name: "Fajita Meatballs", Why: `cites "Grandma's Meatloaf", which is not a Regular`},
|
||||
{Name: "Mushroom Risotto", Why: "cites no Regular"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsSharedNormalizedRegularNotRewritten(t *testing.T) {
|
||||
in := household
|
||||
in.Context.Regulars = append(slices.Clone(household.Context.Regulars),
|
||||
harness.RegularDinner{Name: "Easy Beef Chili", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30})
|
||||
plain := sideQuest("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "beef chili")
|
||||
easy := sideQuest("Frito Pie", "Your Easy Beef Chili over corn chips.", "EASY beef chili")
|
||||
either := sideQuest("Tamale Bake", "Your Beef Chili under a cornbread crust.", "Beef Chili!")
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(batch(plain, easy, either)))
|
||||
out, _, err := suggest(t, &f, in, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSideQuests(t, out, SideQuests{
|
||||
Suggestions: []SideQuest{
|
||||
want("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "Beef Chili"),
|
||||
want("Frito Pie", "Your Easy Beef Chili over corn chips.", "Easy Beef Chili"),
|
||||
},
|
||||
Dropped: []DroppedSideQuest{
|
||||
{Name: "Tamale Bake", Why: `cites "Beef Chili!", which could be any of the Regulars ["Beef Chili" "Easy Beef Chili"]`},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsReasonNamesResolvedRegular(t *testing.T) {
|
||||
in := household
|
||||
in.Context.Regulars = append(slices.Clone(household.Context.Regulars),
|
||||
harness.RegularDinner{Name: "Easy Beef Chili", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30})
|
||||
other := sideQuest("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "Easy Beef Chili")
|
||||
easy := sideQuest("Frito Pie", "Your Easy Beef Chili over corn chips.", "Easy Beef Chili")
|
||||
plain := sideQuest("Chili Mac", "The Beef Chili, not the Easy Beef Chili, over macaroni.", "Beef Chili")
|
||||
inside := sideQuest("Tamale Bake", "Your Easy Beef Chili under a cornbread crust.", "Beef Chili")
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(batch(other, easy, plain, inside)))
|
||||
out, _, err := suggest(t, &f, in, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSideQuests(t, out, SideQuests{
|
||||
Suggestions: []SideQuest{
|
||||
want("Frito Pie", "Your Easy Beef Chili over corn chips.", "Easy Beef Chili"),
|
||||
want("Chili Mac", "The Beef Chili, not the Easy Beef Chili, over macaroni.", "Beef Chili"),
|
||||
},
|
||||
Dropped: []DroppedSideQuest{
|
||||
{Name: "Chili Verde", Why: "reason names none of its cited Regulars"},
|
||||
{Name: "Tamale Bake", Why: "reason names none of its cited Regulars"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsReasonMentionRule(t *testing.T) {
|
||||
const none = "reason names none of its cited Regulars"
|
||||
for name, tc := range map[string]struct {
|
||||
extra []string // Regulars added to household's
|
||||
reason string
|
||||
cited string
|
||||
why string // "" when the Side quest is kept
|
||||
}{
|
||||
"exact name": {nil, "Like your Beef Chili, gone green.", "Beef Chili", ""},
|
||||
"other case and spacing": {nil, "Like your beef CHILI, gone green.", "Beef Chili", ""},
|
||||
"sentence punctuation": {nil, `Green, like "Beef Chili" (yours).`, "Beef Chili", ""},
|
||||
"possessive": {nil, "Beef Chili's spices, gone green.", "Beef Chili", ""},
|
||||
"joined by punctuation": {nil, "A Beef Chili-inspired stew.", "Beef Chili", ""},
|
||||
"no mention": {nil, "Grilled skewers with a peanut sauce.", "Beef Chili", none},
|
||||
"letters run on": {[]string{"Ham"}, "Like your Hamburgers, glazed.", "Ham", none},
|
||||
"stored punctuation written": {[]string{"Easy Beef Chili!"}, "Your Easy Beef Chili! over corn chips.", "Easy Beef Chili!", ""},
|
||||
"stored punctuation as period": {[]string{"Easy Beef Chili!"}, "Corn chips under your Easy Beef Chili.", "Easy Beef Chili!", ""},
|
||||
"inside stored punctuation": {[]string{"Easy Beef Chili!"}, "Your Easy Beef Chili! over corn chips.", "Beef Chili", none},
|
||||
"inside before period": {[]string{"Easy Beef Chili!"}, "Corn chips under your Easy Beef Chili.", "Beef Chili", none},
|
||||
"longer name cited": {[]string{"Easy Beef Chili"}, "Your Easy Beef Chili over corn chips.", "Easy Beef Chili", ""},
|
||||
"shorter inside longer": {[]string{"Easy Beef Chili"}, "Your Easy Beef Chili over corn chips.", "Beef Chili", none},
|
||||
"longer not mentioned": {[]string{"Easy Beef Chili"}, "Your Beef Chili over corn chips.", "Easy Beef Chili", none},
|
||||
"shorter also on its own": {[]string{"Easy Beef Chili"}, "The Beef Chili, not the Easy Beef Chili.", "Beef Chili", ""},
|
||||
"punctuation-only difference": {[]string{"Beef Chili!"}, "Your Beef Chili! over corn chips.", "Beef Chili!", ""},
|
||||
"bare name inside punctuated": {[]string{"Beef Chili!"}, "Your Beef Chili! over corn chips.", "Beef Chili", none},
|
||||
"spelled alike by both": {[]string{"Beef Chili!"}, "Corn chips under your Beef Chili.", "Beef Chili", none},
|
||||
"punctuation then a letter": {[]string{"Beef Chili!"}, "Your Beef Chili!X is our inspiration.", "Beef Chili!", none},
|
||||
"letter before punctuation": {[]string{"¡Beef Chili"}, "Your X¡Beef Chili is our inspiration.", "¡Beef Chili", none},
|
||||
"spacing before punctuation": {[]string{"Beef Chili!"}, "Your Beef Chili ! over corn chips.", "Beef Chili!", ""},
|
||||
"spacing after punctuation": {[]string{"¡Beef Chili"}, "Your ¡ Beef Chili is our inspiration.", "¡Beef Chili", ""},
|
||||
"stored spacing left off": {[]string{"Beef Chili !", "Chili!"}, "Like your Beef Chili!", "Beef Chili !", ""},
|
||||
"inside unspaced punctuation": {[]string{"Beef Chili !", "Chili!"}, "Like your Beef Chili!", "Chili!", none},
|
||||
"stored punctuation partly": {[]string{"Beef Chili!!", "Chili!"}, "Like your Beef Chili!", "Beef Chili!!", ""},
|
||||
"inside partial punctuation": {[]string{"Beef Chili!!", "Chili!"}, "Like your Beef Chili!", "Chili!", none},
|
||||
"partly then a letter": {[]string{"Beef Chili!!", "Chili!"}, "Your Beef Chili!!X is our inspiration.", "Chili!", none},
|
||||
"leading punctuation partly": {[]string{"¡¡Beef Chili", "¡Beef"}, "Like your ¡Beef Chili.", "¡¡Beef Chili", ""},
|
||||
"inside partial leading": {[]string{"¡¡Beef Chili", "¡Beef"}, "Like your ¡Beef Chili.", "¡Beef", none},
|
||||
"stored symbol left off": {[]string{"Green Chili 🌶"}, "Like your Green Chili, gone green.", "Green Chili 🌶", none},
|
||||
"stored accent left off": {[]string{"Chicken Cafe\u0301"}, "Like your Chicken Cafe, gone green.", "Chicken Cafe\u0301", none},
|
||||
"accent written after name": {[]string{"Chicken Cafe"}, "Like your Chicken Cafe\u0301, gone green.", "Chicken Cafe", none},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
in := household
|
||||
in.Context.Regulars = slices.Clone(household.Context.Regulars)
|
||||
for _, r := range tc.extra {
|
||||
in.Context.Regulars = append(in.Context.Regulars, harness.RegularDinner{Name: r, Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30})
|
||||
}
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(batch(sideQuest("Frito Pie", tc.reason, tc.cited))))
|
||||
out, _, err := suggest(t, &f, in, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := SideQuests{Suggestions: []SideQuest{want("Frito Pie", oneLine(tc.reason), tc.cited)}}
|
||||
if tc.why != "" {
|
||||
result = SideQuests{Suggestions: []SideQuest{}, Dropped: []DroppedSideQuest{{Name: "Frito Pie", Why: tc.why}}}
|
||||
}
|
||||
assertSideQuests(t, out, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsDuplicatesDropped(t *testing.T) {
|
||||
declined := sideQuest("The Best Homemade Chicken Tikka-Masala", "Spiced like your Chicken Fajitas.", "Chicken Fajitas")
|
||||
oldDeclined := sideQuest("Fish Tacos!", "Tortillas like your Chicken Fajitas.", "Chicken Fajitas")
|
||||
regular := sideQuest("Easy Beef Chili", "Just like your Beef Chili.", "Beef Chili")
|
||||
questLog := sideQuest("Pad Thai", "Noodles like your Spaghetti Bolognese.", "Spaghetti Bolognese")
|
||||
pending := sideQuest("Shrimp Scampi", "Pasta like your Spaghetti Bolognese.", "Spaghetti Bolognese")
|
||||
again := sideQuest("Chicken Shawarma Bowls", "Chicken Fajitas, but Middle Eastern.", "Chicken Fajitas")
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(batch(declined, oldDeclined, regular, questLog, pending, shawarma, again)))
|
||||
in := household
|
||||
in.Needed = 4
|
||||
out, _, err := suggest(t, &f, in, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
known := "repeats a Regular, Quest log dinner, pending or passed Side quest"
|
||||
assertSideQuests(t, out, SideQuests{
|
||||
Suggestions: []SideQuest{wantShawarma},
|
||||
Dropped: []DroppedSideQuest{
|
||||
{Name: "The Best Homemade Chicken Tikka-Masala", Why: known},
|
||||
{Name: "Fish Tacos!", Why: known},
|
||||
{Name: "Easy Beef Chili", Why: known},
|
||||
{Name: "Pad Thai", Why: known},
|
||||
{Name: "Shrimp Scampi", Why: known},
|
||||
{Name: "Chicken Shawarma Bowls", Why: `repeats the Side quest "Chicken Shawarma Bowls" in the same batch`},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsReasonRulesDropped(t *testing.T) {
|
||||
long := sideQuest("Beef Stroganoff", "Your Beef Chili"+strings.Repeat(" and more", 20)+".", "Beef Chili")
|
||||
unnamed := sideQuest("Chicken Satay", "Grilled skewers with a peanut sauce.", "Chicken Fajitas")
|
||||
wrongOne := sideQuest("Beef Tacos", "Like your Beef Chili, in a shell.", "Chicken Fajitas")
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(batch(long, unnamed, ragu, wrongOne)))
|
||||
out, _, err := suggest(t, &f, household, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSideQuests(t, out, SideQuests{
|
||||
Suggestions: []SideQuest{wantRagu},
|
||||
Dropped: []DroppedSideQuest{
|
||||
{Name: "Beef Stroganoff", Why: "reason has 196 characters, over 180"},
|
||||
{Name: "Chicken Satay", Why: "reason names none of its cited Regulars"},
|
||||
{Name: "Beef Tacos", Why: "reason names none of its cited Regulars"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsAllDroppedIsNotRepaired(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(batch(sideQuest("Pad Thai", "Like your Beef Chili.", "Beef Chili"))))
|
||||
out, _, err := suggest(t, &f, household, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSideQuests(t, out, SideQuests{
|
||||
Suggestions: []SideQuest{},
|
||||
Dropped: []DroppedSideQuest{{Name: "Pad Thai", Why: "repeats a Regular, Quest log dinner, pending or passed Side quest"}},
|
||||
})
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsMalformedRepairedOnce(t *testing.T) {
|
||||
for name, tc := range map[string]struct{ first, want string }{
|
||||
"truncated JSON": {
|
||||
`{"suggestions":[` + shawarma + `,{"name":"Beef`,
|
||||
"reply does not decode: unexpected EOF",
|
||||
},
|
||||
"omitted field": {
|
||||
batch(strings.Replace(shawarma, `"cuisine":"Fusion",`, ``, 1)),
|
||||
`suggestion has no "cuisine" field; give every field the schema requires`,
|
||||
},
|
||||
"unknown field": {
|
||||
batch(strings.Replace(shawarma, `"easy",`, `"easy","spice":"mild",`, 1)),
|
||||
`suggestion has unknown field "spice"`,
|
||||
},
|
||||
"bad method": {
|
||||
batch(strings.Replace(shawarma, `"stovetop"`, `"air fryer"`, 1)),
|
||||
`suggestion 1 method "air fryer" is not one of stovetop, oven, crockpot, grill, other`,
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(tc.first), reply(batch(shawarma, ragu)))
|
||||
out, reqs, err := suggest(t, &f, household, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSideQuests(t, out, SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu}})
|
||||
repair := reqs[1].Messages
|
||||
if len(repair) != 3 || repair[1].Content != tc.first {
|
||||
t.Fatalf("repair turn does not quote the first reply: %+v", repair)
|
||||
}
|
||||
if !strings.Contains(repair[2].Content, tc.want) {
|
||||
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsFillerNameRepaired(t *testing.T) {
|
||||
filler := sideQuest("The.", "Like your Beef Chili.", "Beef Chili")
|
||||
first := batch(filler, filler)
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(first), reply(batch(shawarma, ragu)))
|
||||
out, reqs, err := suggest(t, &f, household, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSideQuests(t, out, SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu}})
|
||||
repair := reqs[1].Messages
|
||||
if len(repair) != 3 || repair[1].Content != first {
|
||||
t.Fatalf("repair turn does not quote the first reply: %+v", repair)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`suggestion 1 name "The." has only filler words; name the dish`,
|
||||
`suggestion 2 name "The." has only filler words; name the dish`,
|
||||
} {
|
||||
if !strings.Contains(repair[2].Content, want) {
|
||||
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsMalformedTwiceFails(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(`{"suggestions":`), reply(`{"suggestions":[]}`))
|
||||
out, _, err := suggest(t, &f, household, 2)
|
||||
var bad *harness.BadOutputError
|
||||
if !errors.As(err, &bad) || !errors.Is(err, llm.ErrBadOutput) {
|
||||
t.Fatalf("err = %v, want a BadOutputError", err)
|
||||
}
|
||||
if !slices.Equal(bad.Repair, []string{"suggestions is empty; give 4 Side quests"}) {
|
||||
t.Fatalf("repair problems = %q", bad.Repair)
|
||||
}
|
||||
if out.Suggestions != nil {
|
||||
t.Fatalf("output = %+v, want none", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSideQuestsValidate(t *testing.T) {
|
||||
s := suggestSideQuests{in: household}
|
||||
if p := s.Validate(SideQuests{Suggestions: []SideQuest{wantShawarma}}); len(p) != 0 {
|
||||
t.Fatalf("valid output has problems: %q", p)
|
||||
}
|
||||
if p := s.Validate(SideQuests{Suggestions: []SideQuest{}, Dropped: []DroppedSideQuest{{Name: "x", Why: "y"}}}); len(p) != 0 {
|
||||
t.Fatalf("output whose Side quests were all dropped has problems: %q", p)
|
||||
}
|
||||
for name, tc := range map[string]struct {
|
||||
out SideQuests
|
||||
want []string
|
||||
}{
|
||||
"empty": {SideQuests{}, []string{"suggestions is empty; give 4 Side quests"}},
|
||||
"too many": {
|
||||
SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu, wantVerde, wantBulgogi, wantShawarma}},
|
||||
[]string{"suggestions has 5 entries; give at most 4 Side quests"},
|
||||
},
|
||||
"malformed": {
|
||||
SideQuests{Suggestions: []SideQuest{{Name: " ", Method: "air fryer", EstMinutes: 0, Difficulty: "trivial", CitedRegulars: []string{}}}},
|
||||
[]string{
|
||||
"suggestion 1 has an empty name; give one",
|
||||
"suggestion 1 has an empty cuisine; give one",
|
||||
"suggestion 1 has an empty reason; give one",
|
||||
"suggestion 1 has an empty search_query; give one",
|
||||
`suggestion 1 method "air fryer" is not one of stovetop, oven, crockpot, grill, other`,
|
||||
"suggestion 1 est_minutes 0 is not positive; give the minutes from prep to table",
|
||||
`suggestion 1 difficulty "trivial" is not one of easy, medium, hard`,
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got := s.Validate(tc.out); !slices.Equal(got, tc.want) {
|
||||
t.Fatalf("problems =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(tc.want, "\n"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSideQuestsPromptInputRejects(t *testing.T) {
|
||||
noRegulars := household
|
||||
noRegulars.Context.Regulars = nil
|
||||
none := household
|
||||
none.Needed = 0
|
||||
for name, tc := range map[string]struct {
|
||||
in SideQuestsRequest
|
||||
want string
|
||||
}{
|
||||
"no Regulars": {noRegulars, "household has no Regulars for Side quests to be based on"},
|
||||
"none needed": {none, "0 Side quests needed; the number must be positive"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := suggestSideQuests{in: tc.in}.PromptInput(tc.in)
|
||||
if err == nil || err.Error() != tc.want {
|
||||
t.Fatalf("err = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
## Instructions
|
||||
You suggest Side quests: new dinners for the household to try, based on the dinners it already makes.
|
||||
|
||||
Every Side quest must be a dinner the household has not had: never one of its Regulars, its Quest log, its pending Side quests or the dinners it has passed, and never one of those under another name. Base every Side quest on one or more of the household's Regulars, such as a familiar protein in a new cuisine or familiar flavours cooked a new way. Prefer the favorite Regulars, and vary the cuisines across the batch.
|
||||
|
||||
Give for each Side quest:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- cuisine: its cuisine, e.g. "Thai" or "Tex-Mex".
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- reason: one sentence of at most 180 characters saying why the household would like it. It must name at least one of the Regulars in cited_regulars.
|
||||
- cited_regulars: the Regulars the reason relies on, at least one, each written exactly as its name appears in the Regulars list of the household context, without the details in parentheses. Cite only Regulars from that list.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
|
||||
One valid Side quest, for a household with a Regular named "Chicken Fajitas" (an example only, not about this household):
|
||||
{"name": "Chicken Shawarma Bowls", "cuisine": "Middle Eastern", "method": "oven", "est_minutes": 40, "difficulty": "easy", "reason": "Spiced roast chicken and crisp toppings, like your Chicken Fajitas with a Middle Eastern twist.", "cited_regulars": ["Chicken Fajitas"], "search_query": "chicken shawarma bowls recipe"}
|
||||
|
||||
## Household context
|
||||
### Today
|
||||
- Tuesday 2026-10-06 (America/New_York)
|
||||
|
||||
### Regulars
|
||||
- Beef Chili (Tex-Mex, crockpot, 240 min)
|
||||
- Chicken Fajitas (Tex-Mex, stovetop, 30 min, favorite)
|
||||
- Spaghetti Bolognese (Italian, stovetop, 45 min)
|
||||
|
||||
### Quest log
|
||||
- Pad Thai
|
||||
|
||||
### Passed, oldest first
|
||||
- Chicken Tikka Masala
|
||||
|
||||
### Recent plans
|
||||
- 2026-10-05 Mon: Chicken Fajitas
|
||||
- 2026-10-06 Tue: Beef Chili
|
||||
|
||||
### Pending Side quests
|
||||
- Shrimp Scampi
|
||||
|
||||
## Output contract
|
||||
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "suggest_side_quests" below, and nothing else: no prose and no Markdown code fences.
|
||||
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suggestions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"cuisine": {
|
||||
"type": "string"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stovetop",
|
||||
"oven",
|
||||
"crockpot",
|
||||
"grill",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"est_minutes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"difficulty": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"easy",
|
||||
"medium",
|
||||
"hard"
|
||||
]
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"cited_regulars": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"search_query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"cuisine",
|
||||
"method",
|
||||
"est_minutes",
|
||||
"difficulty",
|
||||
"reason",
|
||||
"cited_regulars",
|
||||
"search_query"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"suggestions"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Suggest 4 Side quests.
|
||||
@@ -35,6 +35,8 @@ const (
|
||||
EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY"
|
||||
|
||||
EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ"
|
||||
|
||||
EnvSearXNGURL = "MEALQUEST_SEARXNG_URL"
|
||||
)
|
||||
|
||||
// Session lifetime defaults (D-004): long enough that the kitchen tablet
|
||||
@@ -73,6 +75,13 @@ type Config struct {
|
||||
// validates it only while the settings do not exist yet, and ignores it
|
||||
// once they do. Empty when unset.
|
||||
DefaultTZ string
|
||||
|
||||
// SearXNGURL is the deployment's SearXNG instance, which the Settings
|
||||
// screen suggests while no recipe-search URL is saved. It is optional
|
||||
// and validated by recipes.NewSearchSettingsService: the compose
|
||||
// network's http://searxng:8080 is neither https nor loopback, so the
|
||||
// secure-URL rule does not apply. Empty when unset.
|
||||
SearXNGURL string
|
||||
}
|
||||
|
||||
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||
@@ -107,6 +116,8 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
TrustedProxy: r.trustedProxy(EnvTrustedProxy),
|
||||
|
||||
DefaultTZ: r.optional(EnvDefaultTZ),
|
||||
|
||||
SearXNGURL: r.optional(EnvSearXNGURL),
|
||||
}
|
||||
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
|
||||
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
|
||||
|
||||
@@ -422,6 +422,8 @@ func TestLoadValues(t *testing.T) {
|
||||
EnvOIDCAllowedGroups: " mealquest-admin ,family",
|
||||
|
||||
EnvDefaultTZ: "Europe/London",
|
||||
|
||||
EnvSearXNGURL: "http://searxng:8080",
|
||||
}
|
||||
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||
if err != nil {
|
||||
@@ -480,6 +482,9 @@ func TestLoadValues(t *testing.T) {
|
||||
if cfg.DefaultTZ != "Europe/London" {
|
||||
t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ)
|
||||
}
|
||||
if cfg.SearXNGURL != "http://searxng:8080" {
|
||||
t.Errorf("SearXNGURL = %q, want http://searxng:8080", cfg.SearXNGURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRedacted(t *testing.T) {
|
||||
|
||||
+32
-1
@@ -106,13 +106,22 @@ func TestDinners(t *testing.T) {
|
||||
Cuisine: str("Mexican"),
|
||||
IsFavorite: 0,
|
||||
RecipeID: str(recipe.ID),
|
||||
RecipeUrl: str("https://example.com/tacos"),
|
||||
PhotoID: str(photo.ID),
|
||||
GroundingStatus: "grounded",
|
||||
UpdatedBy: str("alex@example.com"),
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
}
|
||||
want := db.Dinner(create)
|
||||
want := db.Dinner{
|
||||
ID: create.ID, Name: create.Name, NormalizedName: create.NormalizedName,
|
||||
Status: create.Status, Origin: create.Origin, Method: create.Method,
|
||||
MinutesTotal: create.MinutesTotal, Difficulty: create.Difficulty,
|
||||
Cuisine: create.Cuisine, IsFavorite: create.IsFavorite, RecipeID: create.RecipeID,
|
||||
PhotoID: create.PhotoID, GroundingStatus: create.GroundingStatus,
|
||||
UpdatedBy: create.UpdatedBy, CreatedAt: created, UpdatedAt: created,
|
||||
RecipeUrl: create.RecipeUrl,
|
||||
}
|
||||
got, err := q.CreateDinner(ctx, create)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDinner: %v", err)
|
||||
@@ -181,6 +190,7 @@ func TestDinners(t *testing.T) {
|
||||
Difficulty: str("medium"),
|
||||
Cuisine: sql.NullString{},
|
||||
RecipeID: sql.NullString{},
|
||||
RecipeUrl: str("https://example.com/tacos"),
|
||||
PhotoID: str(photo.ID),
|
||||
GroundingStatus: "failed",
|
||||
UpdatedBy: str("sam@example.com"),
|
||||
@@ -197,6 +207,7 @@ func TestDinners(t *testing.T) {
|
||||
Cuisine: update.Cuisine, IsFavorite: 0, RecipeID: update.RecipeID,
|
||||
PhotoID: update.PhotoID, GroundingStatus: update.GroundingStatus,
|
||||
UpdatedBy: update.UpdatedBy, CreatedAt: created, UpdatedAt: updated,
|
||||
RecipeUrl: create.RecipeUrl, // UpdateDinner leaves the recipe link
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("UpdateDinner returned %+v, want %+v", got, want)
|
||||
@@ -227,6 +238,17 @@ func TestDinners(t *testing.T) {
|
||||
_, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ID: "missing", IsFavorite: 1, UpdatedAt: updated})
|
||||
mustNoRows(t, "SetDinnerFavorite missing", err)
|
||||
|
||||
for _, wantFav := range []int64{1, 0} {
|
||||
got, err = q.ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{
|
||||
ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: updated,
|
||||
})
|
||||
if err != nil || got.IsFavorite != wantFav || got.UpdatedBy != str("alex@example.com") {
|
||||
t.Fatalf("ToggleDinnerFavorite = %+v, %v; want is_favorite %d", got, err, wantFav)
|
||||
}
|
||||
}
|
||||
_, err = q.ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{ID: "missing", UpdatedAt: updated})
|
||||
mustNoRows(t, "ToggleDinnerFavorite missing", err)
|
||||
|
||||
const archivedAt = "2026-10-05T12:00:00.000Z"
|
||||
got, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{
|
||||
ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: archivedAt,
|
||||
@@ -240,6 +262,15 @@ func TestDinners(t *testing.T) {
|
||||
}
|
||||
assertIDs(t, "archived", listIDs("archived"), create.ID)
|
||||
assertIDs(t, "tried after archive", listIDs("tried"))
|
||||
unarchived, err := q.ListUnarchivedDinners(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUnarchivedDinners: %v", err)
|
||||
}
|
||||
var unarchivedIDs []string
|
||||
for _, d := range unarchived {
|
||||
unarchivedIDs = append(unarchivedIDs, d.Dinner.ID)
|
||||
}
|
||||
assertIDs(t, "unarchived", unarchivedIDs, other.ID, third.ID)
|
||||
_, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt})
|
||||
mustNoRows(t, "ArchiveDinner missing", err)
|
||||
}
|
||||
|
||||
+381
-23
@@ -16,7 +16,7 @@ SET status = 'archived',
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
||||
`
|
||||
|
||||
type ArchiveDinnerParams struct {
|
||||
@@ -46,21 +46,37 @@ func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (D
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const countRegularDinners = `-- name: CountRegularDinners :one
|
||||
SELECT COUNT(*) FROM dinners
|
||||
WHERE status = 'regular'
|
||||
`
|
||||
|
||||
// How many Regulars the household has; archived dinners are not counted.
|
||||
func (q *Queries) CountRegularDinners(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countRegularDinners)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createDinner = `-- name: CreateDinner :one
|
||||
INSERT INTO dinners (
|
||||
id, name, normalized_name, status, origin, method, minutes_total,
|
||||
difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status,
|
||||
updated_by, created_at, updated_at
|
||||
difficulty, cuisine, is_favorite, recipe_id, recipe_url, photo_id,
|
||||
grounding_status, updated_by, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?
|
||||
?, ?, ?, ?
|
||||
)
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
||||
`
|
||||
|
||||
type CreateDinnerParams struct {
|
||||
@@ -75,6 +91,7 @@ type CreateDinnerParams struct {
|
||||
Cuisine sql.NullString
|
||||
IsFavorite int64
|
||||
RecipeID sql.NullString
|
||||
RecipeUrl sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
@@ -95,6 +112,7 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din
|
||||
arg.Cuisine,
|
||||
arg.IsFavorite,
|
||||
arg.RecipeID,
|
||||
arg.RecipeUrl,
|
||||
arg.PhotoID,
|
||||
arg.GroundingStatus,
|
||||
arg.UpdatedBy,
|
||||
@@ -119,12 +137,85 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const failOrphanedPendingDinnerGroundings = `-- name: FailOrphanedPendingDinnerGroundings :execrows
|
||||
UPDATE dinners
|
||||
SET grounding_status = 'failed',
|
||||
grounding_error = ?1,
|
||||
updated_at = ?2
|
||||
WHERE grounding_status = 'pending'
|
||||
AND status <> 'archived'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM jobs
|
||||
WHERE jobs.kind = ?3
|
||||
AND jobs.status IN ('queued', 'running')
|
||||
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
|
||||
AND json_extract(jobs.payload, '$.revision') = dinners.grounding_revision
|
||||
)
|
||||
`
|
||||
|
||||
type FailOrphanedPendingDinnerGroundingsParams struct {
|
||||
GroundingError sql.NullString
|
||||
UpdatedAt string
|
||||
Kind string
|
||||
}
|
||||
|
||||
// Fails every unarchived dinner still pending whose grounding job for its
|
||||
// current grounding_revision is no longer queued or running: the process
|
||||
// stopped before the job, or before its handler recorded a result. An
|
||||
// archived dinner is left as it is; archiving stopped its grounding. Run at
|
||||
// boot, after the job runner fails the jobs a previous process left running.
|
||||
func (q *Queries) FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, failOrphanedPendingDinnerGroundings, arg.GroundingError, arg.UpdatedAt, arg.Kind)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const failPendingDinnerGrounding = `-- name: FailPendingDinnerGrounding :execrows
|
||||
UPDATE dinners
|
||||
SET grounding_status = 'failed',
|
||||
grounding_error = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'pending'
|
||||
AND grounding_revision = ?
|
||||
`
|
||||
|
||||
type FailPendingDinnerGroundingParams struct {
|
||||
GroundingError sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
GroundingRevision int64
|
||||
}
|
||||
|
||||
// Records why the background grounding of an unarchived dinner still
|
||||
// pending at the grounding_revision it was grounded for failed; a dinner
|
||||
// grounded some other way, renamed or archived meanwhile is left as it is
|
||||
// (0 rows).
|
||||
func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, failPendingDinnerGrounding,
|
||||
arg.GroundingError,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.GroundingRevision,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getDinner = `-- name: GetDinner :one
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at FROM dinners
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
@@ -148,12 +239,162 @@ func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) {
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const groundDinner = `-- name: GroundDinner :one
|
||||
UPDATE dinners
|
||||
SET recipe_id = ?1,
|
||||
recipe_url = NULL,
|
||||
photo_id = ?2,
|
||||
method = CASE WHEN method IS ?3
|
||||
THEN ?4 ELSE method END,
|
||||
minutes_total = CASE WHEN minutes_total IS ?5
|
||||
THEN ?6 ELSE minutes_total END,
|
||||
difficulty = CASE WHEN difficulty IS ?7
|
||||
THEN ?8 ELSE difficulty END,
|
||||
grounding_status = ?9,
|
||||
grounding_error = NULL,
|
||||
updated_by = ?10,
|
||||
updated_at = ?11
|
||||
WHERE id = ?12
|
||||
AND status <> 'archived'
|
||||
AND name = ?13
|
||||
AND normalized_name = ?14
|
||||
AND grounding_status = ?15
|
||||
AND grounding_revision = ?16
|
||||
AND recipe_id IS ?17
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
||||
`
|
||||
|
||||
type GroundDinnerParams struct {
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
ObservedMethod sql.NullString
|
||||
Method sql.NullString
|
||||
ObservedMinutesTotal sql.NullInt64
|
||||
MinutesTotal sql.NullInt64
|
||||
ObservedDifficulty sql.NullString
|
||||
Difficulty sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
ObservedName string
|
||||
ObservedNormalizedName string
|
||||
ObservedGroundingStatus string
|
||||
ObservedGroundingRevision int64
|
||||
ObservedRecipeID sql.NullString
|
||||
}
|
||||
|
||||
// Records a dinner's grounding by hand (grounded or manual) and clears any
|
||||
// grounding_error and pasted recipe_url: the recipe chosen replaces the
|
||||
// link. It writes only an unarchived dinner still as it was observed
|
||||
// before grounding (same name, normalized_name, grounding_status,
|
||||
// grounding_revision and recipe_id), so a dinner renamed, archived or
|
||||
// grounded some other way meanwhile is left as it is. That dinner, or an
|
||||
// unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
|
||||
// no longer as observed is kept: a member edited it while it was grounding.
|
||||
func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error) {
|
||||
row := q.db.QueryRowContext(ctx, groundDinner,
|
||||
arg.RecipeID,
|
||||
arg.PhotoID,
|
||||
arg.ObservedMethod,
|
||||
arg.Method,
|
||||
arg.ObservedMinutesTotal,
|
||||
arg.MinutesTotal,
|
||||
arg.ObservedDifficulty,
|
||||
arg.Difficulty,
|
||||
arg.GroundingStatus,
|
||||
arg.UpdatedBy,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.ObservedName,
|
||||
arg.ObservedNormalizedName,
|
||||
arg.ObservedGroundingStatus,
|
||||
arg.ObservedGroundingRevision,
|
||||
arg.ObservedRecipeID,
|
||||
)
|
||||
var i Dinner
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NormalizedName,
|
||||
&i.Status,
|
||||
&i.Origin,
|
||||
&i.Method,
|
||||
&i.MinutesTotal,
|
||||
&i.Difficulty,
|
||||
&i.Cuisine,
|
||||
&i.IsFavorite,
|
||||
&i.RecipeID,
|
||||
&i.PhotoID,
|
||||
&i.GroundingStatus,
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
|
||||
UPDATE dinners
|
||||
SET recipe_id = ?1,
|
||||
photo_id = ?2,
|
||||
method = COALESCE(method, ?3),
|
||||
minutes_total = COALESCE(minutes_total, ?4),
|
||||
difficulty = COALESCE(difficulty, ?5),
|
||||
grounding_status = 'grounded',
|
||||
grounding_error = NULL,
|
||||
updated_at = ?6
|
||||
WHERE id = ?7
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'pending'
|
||||
AND grounding_revision = ?8
|
||||
`
|
||||
|
||||
type GroundPendingDinnerParams struct {
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
GroundingRevision int64
|
||||
}
|
||||
|
||||
// Records the background grounding of an unarchived dinner still pending at
|
||||
// the grounding_revision it was grounded for; a dinner grounded some other
|
||||
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
|
||||
// minutes_total or difficulty already set is kept: a pending dinner has
|
||||
// them only when a member edited them while it was grounding.
|
||||
func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, groundPendingDinner,
|
||||
arg.RecipeID,
|
||||
arg.PhotoID,
|
||||
arg.Method,
|
||||
arg.MinutesTotal,
|
||||
arg.Difficulty,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.GroundingRevision,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const listDinnersByStatus = `-- name: ListDinnersByStatus :many
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at FROM dinners
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
|
||||
WHERE status = ?
|
||||
ORDER BY normalized_name, id
|
||||
`
|
||||
@@ -184,6 +425,68 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listUnarchivedDinners = `-- name: ListUnarchivedDinners :many
|
||||
SELECT dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision, recipes.source_url AS recipe_source_url
|
||||
FROM dinners
|
||||
LEFT JOIN recipes ON recipes.id = dinners.recipe_id
|
||||
WHERE dinners.status <> 'archived'
|
||||
ORDER BY dinners.id
|
||||
`
|
||||
|
||||
type ListUnarchivedDinnersRow struct {
|
||||
Dinner Dinner
|
||||
RecipeSourceUrl sql.NullString
|
||||
}
|
||||
|
||||
// Every dinner a new or renamed dinner must not repeat, with the
|
||||
// source_url of the recipe it is grounded in (NULL while it has none).
|
||||
func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listUnarchivedDinners)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListUnarchivedDinnersRow
|
||||
for rows.Next() {
|
||||
var i ListUnarchivedDinnersRow
|
||||
if err := rows.Scan(
|
||||
&i.Dinner.ID,
|
||||
&i.Dinner.Name,
|
||||
&i.Dinner.NormalizedName,
|
||||
&i.Dinner.Status,
|
||||
&i.Dinner.Origin,
|
||||
&i.Dinner.Method,
|
||||
&i.Dinner.MinutesTotal,
|
||||
&i.Dinner.Difficulty,
|
||||
&i.Dinner.Cuisine,
|
||||
&i.Dinner.IsFavorite,
|
||||
&i.Dinner.RecipeID,
|
||||
&i.Dinner.PhotoID,
|
||||
&i.Dinner.GroundingStatus,
|
||||
&i.Dinner.UpdatedBy,
|
||||
&i.Dinner.CreatedAt,
|
||||
&i.Dinner.UpdatedAt,
|
||||
&i.Dinner.RecipeUrl,
|
||||
&i.Dinner.GroundingError,
|
||||
&i.Dinner.GroundingRevision,
|
||||
&i.RecipeSourceUrl,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -204,7 +507,7 @@ SET is_favorite = ?,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
||||
`
|
||||
|
||||
type SetDinnerFavoriteParams struct {
|
||||
@@ -240,6 +543,52 @@ func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoritePa
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const toggleDinnerFavorite = `-- name: ToggleDinnerFavorite :one
|
||||
UPDATE dinners
|
||||
SET is_favorite = 1 - is_favorite,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
||||
`
|
||||
|
||||
type ToggleDinnerFavoriteParams struct {
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
func (q *Queries) ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error) {
|
||||
row := q.db.QueryRowContext(ctx, toggleDinnerFavorite, arg.UpdatedBy, arg.UpdatedAt, arg.ID)
|
||||
var i Dinner
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NormalizedName,
|
||||
&i.Status,
|
||||
&i.Origin,
|
||||
&i.Method,
|
||||
&i.MinutesTotal,
|
||||
&i.Difficulty,
|
||||
&i.Cuisine,
|
||||
&i.IsFavorite,
|
||||
&i.RecipeID,
|
||||
&i.PhotoID,
|
||||
&i.GroundingStatus,
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -255,29 +604,33 @@ SET name = ?,
|
||||
difficulty = ?,
|
||||
cuisine = ?,
|
||||
recipe_id = ?,
|
||||
recipe_url = ?,
|
||||
photo_id = ?,
|
||||
grounding_status = ?,
|
||||
grounding_revision = ?,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
||||
`
|
||||
|
||||
type UpdateDinnerParams struct {
|
||||
Name string
|
||||
NormalizedName string
|
||||
Status string
|
||||
Origin string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
Cuisine sql.NullString
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
Name string
|
||||
NormalizedName string
|
||||
Status string
|
||||
Origin string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
Cuisine sql.NullString
|
||||
RecipeID sql.NullString
|
||||
RecipeUrl sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
GroundingRevision int64
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
@@ -292,8 +645,10 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
|
||||
arg.Difficulty,
|
||||
arg.Cuisine,
|
||||
arg.RecipeID,
|
||||
arg.RecipeUrl,
|
||||
arg.PhotoID,
|
||||
arg.GroundingStatus,
|
||||
arg.GroundingRevision,
|
||||
arg.UpdatedBy,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
@@ -316,6 +671,9 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -54,16 +54,16 @@ func (q *Queries) ListPlannedDinners(ctx context.Context, arg ListPlannedDinners
|
||||
|
||||
const listRecentDeclinedNames = `-- name: ListRecentDeclinedNames :many
|
||||
|
||||
SELECT dinners.name
|
||||
SELECT name
|
||||
FROM suggestions
|
||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
||||
WHERE suggestions.status = 'declined'
|
||||
ORDER BY suggestions.decided_at DESC, suggestions.id DESC
|
||||
WHERE status = 'declined'
|
||||
ORDER BY decided_at DESC, id DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
// Reads for the AI household context block (internal/ai/harness).
|
||||
// The names of the most recently declined suggestions, newest first.
|
||||
// The names the most recently declined suggestions were suggested under,
|
||||
// newest first; renaming their dinners does not change them.
|
||||
func (q *Queries) ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listRecentDeclinedNames, limit)
|
||||
if err != nil {
|
||||
|
||||
+20
-16
@@ -51,22 +51,25 @@ type ChatSetting struct {
|
||||
}
|
||||
|
||||
type Dinner struct {
|
||||
ID string
|
||||
Name string
|
||||
NormalizedName string
|
||||
Status string
|
||||
Origin string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
Cuisine sql.NullString
|
||||
IsFavorite int64
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
ID string
|
||||
Name string
|
||||
NormalizedName string
|
||||
Status string
|
||||
Origin string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
Cuisine sql.NullString
|
||||
IsFavorite int64
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
RecipeUrl sql.NullString
|
||||
GroundingError sql.NullString
|
||||
GroundingRevision int64
|
||||
}
|
||||
|
||||
type HouseholdSetting struct {
|
||||
@@ -286,6 +289,7 @@ type Suggestion struct {
|
||||
ID string
|
||||
DinnerID string
|
||||
BatchID string
|
||||
Name string
|
||||
Reason string
|
||||
CitedRegularIds string
|
||||
Status string
|
||||
|
||||
+74
-1
@@ -21,20 +21,55 @@ type Querier interface {
|
||||
// Drops references to the runs created before the cutoff, so the purge
|
||||
// can delete them.
|
||||
ClearWeekPlanAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
||||
// How many suggestions ListDeck returns.
|
||||
CountDeck(ctx context.Context) (int64, error)
|
||||
// How many Regulars the household has; archived dinners are not counted.
|
||||
CountRegularDinners(ctx context.Context) (int64, error)
|
||||
CreateAIRun(ctx context.Context, arg CreateAIRunParams) error
|
||||
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
|
||||
CreateJob(ctx context.Context, arg CreateJobParams) error
|
||||
// A recipe the household typed in. Its source_url is a mealquest:manual:
|
||||
// URN rather than a page, so it never matches a fetched page.
|
||||
CreateManualRecipe(ctx context.Context, arg CreateManualRecipeParams) (Recipe, error)
|
||||
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
|
||||
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
||||
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
||||
// name is the name the dish is suggested under, kept whatever its dinner is
|
||||
// later renamed.
|
||||
CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error
|
||||
CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error)
|
||||
// Records the household's decision on a pending suggestion; one already
|
||||
// decided is left as it is (0 rows).
|
||||
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
|
||||
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
||||
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
|
||||
// Fails every unarchived dinner still pending whose grounding job for its
|
||||
// current grounding_revision is no longer queued or running: the process
|
||||
// stopped before the job, or before its handler recorded a result. An
|
||||
// archived dinner is left as it is; archiving stopped its grounding. Run at
|
||||
// boot, after the job runner fails the jobs a previous process left running.
|
||||
FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error)
|
||||
// Fails every batch still pending or running whose job is no longer queued
|
||||
// or running: the process stopped, or recording the batch's end failed,
|
||||
// before its handler recorded a result. Run before reading the active batch.
|
||||
FailOrphanedSuggestionBatches(ctx context.Context, arg FailOrphanedSuggestionBatchesParams) (int64, error)
|
||||
// Records why the background grounding of an unarchived dinner still
|
||||
// pending at the grounding_revision it was grounded for failed; a dinner
|
||||
// grounded some other way, renamed or archived meanwhile is left as it is
|
||||
// (0 rows).
|
||||
FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error)
|
||||
// Fails every job left running, at boot before any worker starts.
|
||||
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
|
||||
// Records a pending or running batch as done or failed; a batch already
|
||||
// finished is left as it is (0 rows).
|
||||
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetAISettings(ctx context.Context) (AiSetting, error)
|
||||
// The batch still pending or running, oldest first. Returns sql.ErrNoRows
|
||||
// when there is none.
|
||||
GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetChatSettings(ctx context.Context) (ChatSetting, error)
|
||||
GetDinner(ctx context.Context, id string) (Dinner, error)
|
||||
@@ -51,21 +86,59 @@ type Querier interface {
|
||||
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
|
||||
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
|
||||
GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error)
|
||||
// The id of the job that runs batch batch_id.
|
||||
GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error)
|
||||
// Records a dinner's grounding by hand (grounded or manual) and clears any
|
||||
// grounding_error and pasted recipe_url: the recipe chosen replaces the
|
||||
// link. It writes only an unarchived dinner still as it was observed
|
||||
// before grounding (same name, normalized_name, grounding_status,
|
||||
// grounding_revision and recipe_id), so a dinner renamed, archived or
|
||||
// grounded some other way meanwhile is left as it is. That dinner, or an
|
||||
// unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
|
||||
// no longer as observed is kept: a member edited it while it was grounding.
|
||||
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
|
||||
// Records the background grounding of an unarchived dinner still pending at
|
||||
// the grounding_revision it was grounded for; a dinner grounded some other
|
||||
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
|
||||
// minutes_total or difficulty already set is kept: a pending dinner has
|
||||
// them only when a member edited them while it was grounding.
|
||||
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
|
||||
// The pending suggestions whose dinner is still suggested, oldest first,
|
||||
// with their dinners.
|
||||
ListDeck(ctx context.Context) ([]ListDeckRow, error)
|
||||
// The name every declined suggestion was suggested under, oldest decision
|
||||
// first; renaming its dinner does not change it.
|
||||
ListDeclinedSuggestionNames(ctx context.Context) ([]string, error)
|
||||
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
||||
// The planned dinners dated from from_date up to but not including
|
||||
// to_date, oldest first. Days with no dinner are left out.
|
||||
ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error)
|
||||
// Reads for the AI household context block (internal/ai/harness).
|
||||
// The names of the most recently declined suggestions, newest first.
|
||||
// The names the most recently declined suggestions were suggested under,
|
||||
// newest first; renaming their dinners does not change them.
|
||||
ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error)
|
||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||
// Every dinner a new or renamed dinner must not repeat, with the
|
||||
// source_url of the recipe it is grounded in (NULL while it has none).
|
||||
ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error)
|
||||
// Moves a dinner still suggested to status (quest_log or archived),
|
||||
// recording updated_by; a dinner no longer suggested is left as it is
|
||||
// (0 rows).
|
||||
MoveSuggestedDinner(ctx context.Context, arg MoveSuggestedDinnerParams) (int64, error)
|
||||
// Stores a partial result of a running job.
|
||||
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
||||
// Marks a pending batch running; a batch in any other status is left as
|
||||
// it is (0 rows).
|
||||
StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error)
|
||||
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
|
||||
// Creates the single row (id 1) or replaces every column except id and
|
||||
// created_at.
|
||||
|
||||
@@ -10,6 +10,67 @@ import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const createManualRecipe = `-- name: CreateManualRecipe :one
|
||||
INSERT INTO recipes (
|
||||
id, source_url, title, total_minutes, raw_jsonld, method,
|
||||
minutes_total, fetched_at, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?
|
||||
)
|
||||
RETURNING id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at, photo_id, method, difficulty, minutes_total
|
||||
`
|
||||
|
||||
type CreateManualRecipeParams struct {
|
||||
ID string
|
||||
SourceUrl string
|
||||
Title string
|
||||
TotalMinutes sql.NullInt64
|
||||
RawJsonld string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
FetchedAt string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// A recipe the household typed in. Its source_url is a mealquest:manual:
|
||||
// URN rather than a page, so it never matches a fetched page.
|
||||
func (q *Queries) CreateManualRecipe(ctx context.Context, arg CreateManualRecipeParams) (Recipe, error) {
|
||||
row := q.db.QueryRowContext(ctx, createManualRecipe,
|
||||
arg.ID,
|
||||
arg.SourceUrl,
|
||||
arg.Title,
|
||||
arg.TotalMinutes,
|
||||
arg.RawJsonld,
|
||||
arg.Method,
|
||||
arg.MinutesTotal,
|
||||
arg.FetchedAt,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
var i Recipe
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SourceUrl,
|
||||
&i.SiteName,
|
||||
&i.Title,
|
||||
&i.TotalMinutes,
|
||||
&i.PrepMinutes,
|
||||
&i.CookMinutes,
|
||||
&i.Yield,
|
||||
&i.RawJsonld,
|
||||
&i.FetchedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.PhotoID,
|
||||
&i.Method,
|
||||
&i.Difficulty,
|
||||
&i.MinutesTotal,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createRecipeIngredient = `-- name: CreateRecipeIngredient :exec
|
||||
INSERT INTO recipe_ingredients (
|
||||
id, recipe_id, position, raw_text, name, qty, unit, note,
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: suggestions.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const countDeck = `-- name: CountDeck :one
|
||||
SELECT COUNT(*)
|
||||
FROM suggestions
|
||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
||||
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested'
|
||||
`
|
||||
|
||||
// How many suggestions ListDeck returns.
|
||||
func (q *Queries) CountDeck(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countDeck)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createSuggestion = `-- name: CreateSuggestion :exec
|
||||
INSERT INTO suggestions (
|
||||
id, dinner_id, batch_id, name, reason, cited_regular_ids, status, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, 'pending', ?, ?
|
||||
)
|
||||
`
|
||||
|
||||
type CreateSuggestionParams struct {
|
||||
ID string
|
||||
DinnerID string
|
||||
BatchID string
|
||||
Name string
|
||||
Reason string
|
||||
CitedRegularIds string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// name is the name the dish is suggested under, kept whatever its dinner is
|
||||
// later renamed.
|
||||
func (q *Queries) CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createSuggestion,
|
||||
arg.ID,
|
||||
arg.DinnerID,
|
||||
arg.BatchID,
|
||||
arg.Name,
|
||||
arg.Reason,
|
||||
arg.CitedRegularIds,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const createSuggestionBatch = `-- name: CreateSuggestionBatch :one
|
||||
INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
|
||||
VALUES (?, ?, 'pending', ?, ?)
|
||||
RETURNING id, "trigger", status, ai_run_id, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateSuggestionBatchParams struct {
|
||||
ID string
|
||||
Trigger string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error) {
|
||||
row := q.db.QueryRowContext(ctx, createSuggestionBatch,
|
||||
arg.ID,
|
||||
arg.Trigger,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
var i SuggestionBatch
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Trigger,
|
||||
&i.Status,
|
||||
&i.AiRunID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const decideSuggestion = `-- name: DecideSuggestion :execrows
|
||||
UPDATE suggestions
|
||||
SET status = ?1, decided_at = ?2, updated_at = ?2
|
||||
WHERE id = ?3 AND status = 'pending'
|
||||
`
|
||||
|
||||
type DecideSuggestionParams struct {
|
||||
Status string
|
||||
Now sql.NullString
|
||||
ID string
|
||||
}
|
||||
|
||||
// Records the household's decision on a pending suggestion; one already
|
||||
// decided is left as it is (0 rows).
|
||||
func (q *Queries) DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, decideSuggestion, arg.Status, arg.Now, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const failOrphanedSuggestionBatches = `-- name: FailOrphanedSuggestionBatches :execrows
|
||||
UPDATE suggestion_batches
|
||||
SET status = 'failed', updated_at = ?1
|
||||
WHERE status IN ('pending', 'running')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM jobs
|
||||
WHERE jobs.kind = ?2
|
||||
AND jobs.status IN ('queued', 'running')
|
||||
AND json_extract(jobs.payload, '$.batch_id') = suggestion_batches.id
|
||||
)
|
||||
`
|
||||
|
||||
type FailOrphanedSuggestionBatchesParams struct {
|
||||
UpdatedAt string
|
||||
Kind string
|
||||
}
|
||||
|
||||
// Fails every batch still pending or running whose job is no longer queued
|
||||
// or running: the process stopped, or recording the batch's end failed,
|
||||
// before its handler recorded a result. Run before reading the active batch.
|
||||
func (q *Queries) FailOrphanedSuggestionBatches(ctx context.Context, arg FailOrphanedSuggestionBatchesParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, failOrphanedSuggestionBatches, arg.UpdatedAt, arg.Kind)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const finishSuggestionBatch = `-- name: FinishSuggestionBatch :execrows
|
||||
UPDATE suggestion_batches
|
||||
SET status = ?, updated_at = ?
|
||||
WHERE id = ? AND status IN ('pending', 'running')
|
||||
`
|
||||
|
||||
type FinishSuggestionBatchParams struct {
|
||||
Status string
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Records a pending or running batch as done or failed; a batch already
|
||||
// finished is left as it is (0 rows).
|
||||
func (q *Queries) FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, finishSuggestionBatch, arg.Status, arg.UpdatedAt, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getActiveSuggestionBatch = `-- name: GetActiveSuggestionBatch :one
|
||||
SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches
|
||||
WHERE status IN ('pending', 'running')
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
// The batch still pending or running, oldest first. Returns sql.ErrNoRows
|
||||
// when there is none.
|
||||
func (q *Queries) GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error) {
|
||||
row := q.db.QueryRowContext(ctx, getActiveSuggestionBatch)
|
||||
var i SuggestionBatch
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Trigger,
|
||||
&i.Status,
|
||||
&i.AiRunID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getSuggestion = `-- name: GetSuggestion :one
|
||||
SELECT suggestions.id, suggestions.dinner_id, suggestions.batch_id, suggestions.name, suggestions.reason, suggestions.cited_regular_ids, suggestions.status, suggestions.decided_at, suggestions.created_at, suggestions.updated_at, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision
|
||||
FROM suggestions
|
||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
||||
WHERE suggestions.id = ?
|
||||
`
|
||||
|
||||
type GetSuggestionRow struct {
|
||||
Suggestion Suggestion
|
||||
Dinner Dinner
|
||||
}
|
||||
|
||||
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
|
||||
func (q *Queries) GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getSuggestion, id)
|
||||
var i GetSuggestionRow
|
||||
err := row.Scan(
|
||||
&i.Suggestion.ID,
|
||||
&i.Suggestion.DinnerID,
|
||||
&i.Suggestion.BatchID,
|
||||
&i.Suggestion.Name,
|
||||
&i.Suggestion.Reason,
|
||||
&i.Suggestion.CitedRegularIds,
|
||||
&i.Suggestion.Status,
|
||||
&i.Suggestion.DecidedAt,
|
||||
&i.Suggestion.CreatedAt,
|
||||
&i.Suggestion.UpdatedAt,
|
||||
&i.Dinner.ID,
|
||||
&i.Dinner.Name,
|
||||
&i.Dinner.NormalizedName,
|
||||
&i.Dinner.Status,
|
||||
&i.Dinner.Origin,
|
||||
&i.Dinner.Method,
|
||||
&i.Dinner.MinutesTotal,
|
||||
&i.Dinner.Difficulty,
|
||||
&i.Dinner.Cuisine,
|
||||
&i.Dinner.IsFavorite,
|
||||
&i.Dinner.RecipeID,
|
||||
&i.Dinner.PhotoID,
|
||||
&i.Dinner.GroundingStatus,
|
||||
&i.Dinner.UpdatedBy,
|
||||
&i.Dinner.CreatedAt,
|
||||
&i.Dinner.UpdatedAt,
|
||||
&i.Dinner.RecipeUrl,
|
||||
&i.Dinner.GroundingError,
|
||||
&i.Dinner.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getSuggestionBatchJobID = `-- name: GetSuggestionBatchJobID :one
|
||||
SELECT id FROM jobs
|
||||
WHERE kind = ?1
|
||||
AND json_extract(payload, '$.batch_id') = ?2
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetSuggestionBatchJobIDParams struct {
|
||||
Kind string
|
||||
BatchID string
|
||||
}
|
||||
|
||||
// The id of the job that runs batch batch_id.
|
||||
func (q *Queries) GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getSuggestionBatchJobID, arg.Kind, arg.BatchID)
|
||||
var id string
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const listDeck = `-- name: ListDeck :many
|
||||
SELECT suggestions.id, suggestions.dinner_id, suggestions.batch_id, suggestions.name, suggestions.reason, suggestions.cited_regular_ids, suggestions.status, suggestions.decided_at, suggestions.created_at, suggestions.updated_at, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision
|
||||
FROM suggestions
|
||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
||||
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested'
|
||||
ORDER BY suggestions.created_at, suggestions.id
|
||||
`
|
||||
|
||||
type ListDeckRow struct {
|
||||
Suggestion Suggestion
|
||||
Dinner Dinner
|
||||
}
|
||||
|
||||
// The pending suggestions whose dinner is still suggested, oldest first,
|
||||
// with their dinners.
|
||||
func (q *Queries) ListDeck(ctx context.Context) ([]ListDeckRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDeck)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListDeckRow
|
||||
for rows.Next() {
|
||||
var i ListDeckRow
|
||||
if err := rows.Scan(
|
||||
&i.Suggestion.ID,
|
||||
&i.Suggestion.DinnerID,
|
||||
&i.Suggestion.BatchID,
|
||||
&i.Suggestion.Name,
|
||||
&i.Suggestion.Reason,
|
||||
&i.Suggestion.CitedRegularIds,
|
||||
&i.Suggestion.Status,
|
||||
&i.Suggestion.DecidedAt,
|
||||
&i.Suggestion.CreatedAt,
|
||||
&i.Suggestion.UpdatedAt,
|
||||
&i.Dinner.ID,
|
||||
&i.Dinner.Name,
|
||||
&i.Dinner.NormalizedName,
|
||||
&i.Dinner.Status,
|
||||
&i.Dinner.Origin,
|
||||
&i.Dinner.Method,
|
||||
&i.Dinner.MinutesTotal,
|
||||
&i.Dinner.Difficulty,
|
||||
&i.Dinner.Cuisine,
|
||||
&i.Dinner.IsFavorite,
|
||||
&i.Dinner.RecipeID,
|
||||
&i.Dinner.PhotoID,
|
||||
&i.Dinner.GroundingStatus,
|
||||
&i.Dinner.UpdatedBy,
|
||||
&i.Dinner.CreatedAt,
|
||||
&i.Dinner.UpdatedAt,
|
||||
&i.Dinner.RecipeUrl,
|
||||
&i.Dinner.GroundingError,
|
||||
&i.Dinner.GroundingRevision,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listDeclinedSuggestionNames = `-- name: ListDeclinedSuggestionNames :many
|
||||
SELECT name
|
||||
FROM suggestions
|
||||
WHERE status = 'declined'
|
||||
ORDER BY decided_at, id
|
||||
`
|
||||
|
||||
// The name every declined suggestion was suggested under, oldest decision
|
||||
// first; renaming its dinner does not change it.
|
||||
func (q *Queries) ListDeclinedSuggestionNames(ctx context.Context) ([]string, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDeclinedSuggestionNames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, name)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const moveSuggestedDinner = `-- name: MoveSuggestedDinner :execrows
|
||||
UPDATE dinners
|
||||
SET status = ?1, updated_by = ?2, updated_at = ?3
|
||||
WHERE id = ?4 AND status = 'suggested'
|
||||
`
|
||||
|
||||
type MoveSuggestedDinnerParams struct {
|
||||
Status string
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Moves a dinner still suggested to status (quest_log or archived),
|
||||
// recording updated_by; a dinner no longer suggested is left as it is
|
||||
// (0 rows).
|
||||
func (q *Queries) MoveSuggestedDinner(ctx context.Context, arg MoveSuggestedDinnerParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, moveSuggestedDinner,
|
||||
arg.Status,
|
||||
arg.UpdatedBy,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const startSuggestionBatch = `-- name: StartSuggestionBatch :execrows
|
||||
UPDATE suggestion_batches
|
||||
SET status = 'running', updated_at = ?
|
||||
WHERE id = ? AND status = 'pending'
|
||||
`
|
||||
|
||||
type StartSuggestionBatchParams struct {
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Marks a pending batch running; a batch in any other status is left as
|
||||
// it is (0 rows).
|
||||
func (q *Queries) StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, startSuggestionBatch, arg.UpdatedAt, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
// Package dinners keeps the household's dinners: adding Regulars by name or
|
||||
// recipe link, listing Regulars and the Quest log, editing, archiving and
|
||||
// favoriting, and grounding each dinner in a recipe. Names are deduplicated with the harness's dinner-name rules,
|
||||
// so the Regulars the household types and the Side quests the AI suggests
|
||||
// are compared the same way.
|
||||
package dinners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Dinner statuses (the dinners.status column).
|
||||
const (
|
||||
StatusSuggested = "suggested"
|
||||
StatusQuestLog = "quest_log"
|
||||
StatusTried = "tried"
|
||||
StatusRegular = "regular"
|
||||
StatusArchived = "archived"
|
||||
)
|
||||
|
||||
// Limits on what a member may enter.
|
||||
const (
|
||||
MaxNameChars = 120
|
||||
MaxRecipeURL = 2048
|
||||
MaxMinutes = 1440 // a day; a crock-pot dinner fits
|
||||
photoListWidth = 400 // the stored photo size lists show
|
||||
)
|
||||
|
||||
// Methods and Difficulties are the values the dinners table allows.
|
||||
var (
|
||||
Methods = []string{"stovetop", "oven", "crockpot", "grill", "other"}
|
||||
Difficulties = []string{"easy", "medium", "hard"}
|
||||
)
|
||||
|
||||
// listable are the statuses List answers for.
|
||||
var listable = []string{StatusRegular, StatusQuestLog}
|
||||
|
||||
// ErrNotFound is returned for an unknown dinner id.
|
||||
var ErrNotFound = errors.New("dinner not found")
|
||||
|
||||
// Dinner is a dinner as the API shows it. It has no recipe_url: a pasted
|
||||
// link may carry a personal token, so it stays in the database for
|
||||
// grounding and never leaves the server.
|
||||
type Dinner struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Origin string `json:"origin"`
|
||||
Method *string `json:"method"`
|
||||
MinutesTotal *int64 `json:"minutes_total"`
|
||||
Difficulty *string `json:"difficulty"`
|
||||
Cuisine *string `json:"cuisine"`
|
||||
IsFavorite bool `json:"is_favorite"`
|
||||
RecipeID *string `json:"recipe_id"`
|
||||
PhotoURL *string `json:"photo_url"`
|
||||
GroundingStatus string `json:"grounding_status"`
|
||||
// GroundingError is why grounding failed, one of the Reason codes;
|
||||
// null unless GroundingStatus is failed.
|
||||
GroundingError *string `json:"grounding_error"`
|
||||
UpdatedBy *string `json:"updated_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NewRegular is a Regular to add: its Name, which the household always
|
||||
// gives, and optionally the RecipeURL to ground it in. The name is never
|
||||
// taken from the link: a pasted link may carry a personal token anywhere in
|
||||
// it, and the name is shown in every dinner response.
|
||||
type NewRegular struct {
|
||||
Name *string
|
||||
RecipeURL *string
|
||||
}
|
||||
|
||||
// Patch edits a dinner. Nil fields are left as they are.
|
||||
type Patch struct {
|
||||
Name *string
|
||||
Method *string
|
||||
MinutesTotal *int
|
||||
Difficulty *string
|
||||
}
|
||||
|
||||
// Problem is one invalid field, named by its JSON key.
|
||||
type Problem struct {
|
||||
Field string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ValidationError lists every invalid field of a request.
|
||||
type ValidationError struct {
|
||||
Problems []Problem
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
parts := make([]string, len(e.Problems))
|
||||
for i, p := range e.Problems {
|
||||
parts[i] = p.Field + ": " + p.Reason
|
||||
}
|
||||
return "invalid dinner: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func invalid(field, reason string) *ValidationError {
|
||||
return &ValidationError{Problems: []Problem{{Field: field, Reason: reason}}}
|
||||
}
|
||||
|
||||
// DuplicateError is returned when a new or renamed dinner repeats an
|
||||
// unarchived one: its name is harness.Similar, or it has the same recipe
|
||||
// link.
|
||||
type DuplicateError struct {
|
||||
Existing Dinner
|
||||
}
|
||||
|
||||
func (e *DuplicateError) Error() string {
|
||||
return fmt.Sprintf("%q is already a dinner (%s)", e.Existing.Name, e.Existing.Status)
|
||||
}
|
||||
|
||||
// Service reads and changes dinners.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
grounder *recipes.Grounder
|
||||
logger *slog.Logger
|
||||
groundJob *jobs.Kind[GroundJob]
|
||||
|
||||
// groundingsMu guards groundings: the KindGroundDinner jobs running,
|
||||
// by dinner id, so archiving or renaming a dinner can stop them.
|
||||
groundingsMu sync.Mutex
|
||||
groundings map[string]map[*runningGrounding]struct{}
|
||||
}
|
||||
|
||||
// NewService returns a service over st that grounds dinners with grounder
|
||||
// and registers the KindGroundDinner job on runner, which must not have
|
||||
// started yet.
|
||||
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("dinners: nil store")
|
||||
case grounder == nil:
|
||||
return nil, errors.New("dinners: nil grounder")
|
||||
case runner == nil:
|
||||
return nil, errors.New("dinners: nil job runner")
|
||||
case logger == nil:
|
||||
return nil, errors.New("dinners: nil logger")
|
||||
}
|
||||
s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{})}
|
||||
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.groundJob = kind
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Created is a new Regular with the id of the job grounding it, which is
|
||||
// nil when the job could not be enqueued and the dinner's grounding is
|
||||
// failed instead.
|
||||
type Created struct {
|
||||
Dinner
|
||||
GroundingJobID *string `json:"grounding_job_id"`
|
||||
}
|
||||
|
||||
// CreateRegular adds a Regular named r.Name, keeping r.RecipeURL for
|
||||
// grounding when given, with status and origin regular and grounding_status
|
||||
// pending, recording updatedBy, and enqueues the KindGroundDinner job that grounds it. It returns without
|
||||
// waiting for the job. A name repeating an unarchived dinner is a
|
||||
// *DuplicateError. When the job cannot be enqueued, the stored dinner is
|
||||
// returned with grounding_status failed (ReasonGroundingError) and no job
|
||||
// id, so the household can fix its recipe by hand.
|
||||
func (s *Service) CreateRegular(ctx context.Context, r NewRegular, updatedBy string) (Created, error) {
|
||||
d, err := s.createRegular(ctx, r, updatedBy)
|
||||
if err != nil {
|
||||
return Created{}, err
|
||||
}
|
||||
jobID, err := s.enqueueGrounding(ctx, d.ID, d.GroundingRevision)
|
||||
if err != nil {
|
||||
return Created{}, err
|
||||
}
|
||||
if jobID == nil {
|
||||
row, err := db.New(s.st.Reader).GetDinner(context.WithoutCancel(ctx), d.ID)
|
||||
if err != nil {
|
||||
return Created{}, fmt.Errorf("dinners: read %s: %w", d.ID, err)
|
||||
}
|
||||
return Created{Dinner: fromRow(row)}, nil
|
||||
}
|
||||
return Created{Dinner: fromRow(d), GroundingJobID: jobID}, nil
|
||||
}
|
||||
|
||||
// enqueueGrounding enqueues the KindGroundDinner job for pending dinner id
|
||||
// at grounding_revision revision and returns its id. When the job cannot be
|
||||
// enqueued, the dinner's grounding is failed with ReasonGroundingError, so
|
||||
// it is not left pending with no job, and the id is nil.
|
||||
func (s *Service) enqueueGrounding(ctx context.Context, id string, revision int64) (*string, error) {
|
||||
jobID, err := s.groundJob.Enqueue(ctx, GroundJob{DinnerID: id, Revision: revision})
|
||||
if err == nil {
|
||||
return &jobID, nil
|
||||
}
|
||||
// The error is a database error, never a dependency's text.
|
||||
s.logger.ErrorContext(ctx, "dinner grounding not enqueued", "dinner_id", id, "error", err)
|
||||
if _, err := db.New(s.st.Writer).FailPendingDinnerGrounding(context.WithoutCancel(ctx), db.FailPendingDinnerGroundingParams{
|
||||
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: id,
|
||||
GroundingRevision: revision,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("dinners: record failed grounding of %s: %w", id, err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy string) (db.Dinner, error) {
|
||||
if updatedBy == "" {
|
||||
return db.Dinner{}, errors.New("dinners: create: no member to record as updated_by")
|
||||
}
|
||||
if r.Name == nil {
|
||||
return db.Dinner{}, invalid("name", "give the dinner's name, with or without a recipe_url")
|
||||
}
|
||||
name, err := checkName(*r.Name)
|
||||
if err != nil {
|
||||
return db.Dinner{}, err
|
||||
}
|
||||
var recipeURL sql.NullString
|
||||
if r.RecipeURL != nil {
|
||||
u, err := checkRecipeURL(*r.RecipeURL)
|
||||
if err != nil {
|
||||
return db.Dinner{}, err
|
||||
}
|
||||
recipeURL = sql.NullString{String: u.String(), Valid: true}
|
||||
}
|
||||
|
||||
var row db.Dinner
|
||||
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
if err := checkUnique(ctx, q, "", name, recipeURL); err != nil {
|
||||
return err
|
||||
}
|
||||
now := store.Now().String()
|
||||
var err error
|
||||
row, err = q.CreateDinner(ctx, db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: StatusRegular,
|
||||
Origin: "regular",
|
||||
RecipeUrl: recipeURL,
|
||||
GroundingStatus: "pending",
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: create: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return db.Dinner{}, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// List returns the dinners with status, which must be regular or
|
||||
// quest_log, ordered by normalized name.
|
||||
func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
|
||||
if !slices.Contains(listable, status) {
|
||||
return nil, invalid("status", "must be one of "+strings.Join(listable, ", "))
|
||||
}
|
||||
rows, err := db.New(s.st.Reader).ListDinnersByStatus(ctx, status)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dinners: list %s: %w", status, err)
|
||||
}
|
||||
out := make([]Dinner, len(rows))
|
||||
for i, row := range rows {
|
||||
out[i] = fromRow(row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Update applies p to dinner id, recording updatedBy. An invalid patch is a
|
||||
// *ValidationError and a new name repeating another unarchived dinner a
|
||||
// *DuplicateError; either changes nothing. Renaming a dinner still pending
|
||||
// clears its recipe_url, raises its grounding_revision and enqueues a fresh
|
||||
// KindGroundDinner job that grounds the new name: a job queued or running
|
||||
// for the old name or link carries the old revision, so it can no longer
|
||||
// write the dinner, and one running is stopped.
|
||||
func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy string) (Dinner, error) {
|
||||
if updatedBy == "" {
|
||||
return Dinner{}, errors.New("dinners: update: no member to record as updated_by")
|
||||
}
|
||||
name, err := p.validate()
|
||||
if err != nil {
|
||||
return Dinner{}, err
|
||||
}
|
||||
|
||||
var row db.Dinner
|
||||
var regrounding bool
|
||||
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
cur, err := q.GetDinner(ctx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: read %s: %w", id, err)
|
||||
}
|
||||
params := db.UpdateDinnerParams{
|
||||
ID: cur.ID,
|
||||
Name: cur.Name,
|
||||
NormalizedName: cur.NormalizedName,
|
||||
Status: cur.Status,
|
||||
Origin: cur.Origin,
|
||||
Method: cur.Method,
|
||||
MinutesTotal: cur.MinutesTotal,
|
||||
Difficulty: cur.Difficulty,
|
||||
Cuisine: cur.Cuisine,
|
||||
RecipeID: cur.RecipeID,
|
||||
RecipeUrl: cur.RecipeUrl,
|
||||
PhotoID: cur.PhotoID,
|
||||
GroundingStatus: cur.GroundingStatus,
|
||||
GroundingRevision: cur.GroundingRevision,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
}
|
||||
if p.Name != nil {
|
||||
if err := checkUnique(ctx, q, cur.ID, name, sql.NullString{}); err != nil {
|
||||
return err
|
||||
}
|
||||
params.Name = name
|
||||
params.NormalizedName = harness.Normalize(name)
|
||||
if cur.GroundingStatus == GroundingPending && params.NormalizedName != cur.NormalizedName {
|
||||
// The link was the old dinner's recipe; the new name is
|
||||
// grounded by name, and a link can be given by hand.
|
||||
params.RecipeUrl = sql.NullString{}
|
||||
params.GroundingRevision++
|
||||
}
|
||||
}
|
||||
if p.Method != nil {
|
||||
params.Method = sql.NullString{String: *p.Method, Valid: true}
|
||||
}
|
||||
if p.MinutesTotal != nil {
|
||||
params.MinutesTotal = sql.NullInt64{Int64: int64(*p.MinutesTotal), Valid: true}
|
||||
}
|
||||
if p.Difficulty != nil {
|
||||
params.Difficulty = sql.NullString{String: *p.Difficulty, Valid: true}
|
||||
}
|
||||
row, err = q.UpdateDinner(ctx, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: update %s: %w", id, err)
|
||||
}
|
||||
regrounding = row.GroundingRevision != cur.GroundingRevision
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Dinner{}, err
|
||||
}
|
||||
if regrounding {
|
||||
s.stopGroundings(row.ID, row.GroundingRevision)
|
||||
jobID, err := s.enqueueGrounding(ctx, row.ID, row.GroundingRevision)
|
||||
if err != nil {
|
||||
return Dinner{}, err
|
||||
}
|
||||
if jobID == nil {
|
||||
row, err = db.New(s.st.Reader).GetDinner(context.WithoutCancel(ctx), row.ID)
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("dinners: read %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
// Archive sets dinner id's status to archived, recording updatedBy. The row
|
||||
// is kept, so its name still counts for Side quest dedupe. A
|
||||
// KindGroundDinner job running for it is stopped.
|
||||
func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, error) {
|
||||
if updatedBy == "" {
|
||||
return Dinner{}, errors.New("dinners: archive: no member to record as updated_by")
|
||||
}
|
||||
row, err := db.New(s.st.Writer).ArchiveDinner(ctx, db.ArchiveDinnerParams{
|
||||
ID: id,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Dinner{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err)
|
||||
}
|
||||
s.stopGroundings(row.ID, math.MaxInt64)
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
// ToggleFavorite flips dinner id's is_favorite, recording updatedBy.
|
||||
func (s *Service) ToggleFavorite(ctx context.Context, id, updatedBy string) (Dinner, error) {
|
||||
if updatedBy == "" {
|
||||
return Dinner{}, errors.New("dinners: favorite: no member to record as updated_by")
|
||||
}
|
||||
row, err := db.New(s.st.Writer).ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{
|
||||
ID: id,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Dinner{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("dinners: favorite %s: %w", id, err)
|
||||
}
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
// validate checks every field of p and returns its trimmed name.
|
||||
func (p Patch) validate() (string, error) {
|
||||
var problems []Problem
|
||||
var name string
|
||||
if p.Name == nil && p.Method == nil && p.MinutesTotal == nil && p.Difficulty == nil {
|
||||
return "", invalid("patch", "no fields to change")
|
||||
}
|
||||
if p.Name != nil {
|
||||
n, err := checkName(*p.Name)
|
||||
var verr *ValidationError
|
||||
if errors.As(err, &verr) {
|
||||
problems = append(problems, verr.Problems...)
|
||||
}
|
||||
name = n
|
||||
}
|
||||
if p.Method != nil && !slices.Contains(Methods, *p.Method) {
|
||||
problems = append(problems, Problem{"method", "must be one of " + strings.Join(Methods, ", ")})
|
||||
}
|
||||
if p.MinutesTotal != nil && (*p.MinutesTotal < 1 || *p.MinutesTotal > MaxMinutes) {
|
||||
problems = append(problems, Problem{"minutes_total", fmt.Sprintf("must be 1 to %d", MaxMinutes)})
|
||||
}
|
||||
if p.Difficulty != nil && !slices.Contains(Difficulties, *p.Difficulty) {
|
||||
problems = append(problems, Problem{"difficulty", "must be one of " + strings.Join(Difficulties, ", ")})
|
||||
}
|
||||
if problems != nil {
|
||||
return "", &ValidationError{Problems: problems}
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// checkName returns raw trimmed, refusing a name that is empty, too long or
|
||||
// has no words left once normalized (so it could not be deduplicated).
|
||||
func checkName(raw string) (string, error) {
|
||||
name := strings.TrimSpace(raw)
|
||||
switch {
|
||||
case name == "":
|
||||
return "", invalid("name", "must not be empty")
|
||||
case utf8.RuneCountInString(name) > MaxNameChars:
|
||||
return "", invalid("name", fmt.Sprintf("must be at most %d characters", MaxNameChars))
|
||||
case harness.Normalize(name) == "":
|
||||
return "", invalid("name", "must name a dish, not only filler words")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// checkRecipeURL parses raw as an absolute https:// link to a recipe page,
|
||||
// the only kind recipe grounding fetches.
|
||||
func checkRecipeURL(raw string) (*url.URL, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if len(raw) > MaxRecipeURL {
|
||||
return nil, invalid("recipe_url", fmt.Sprintf("must be at most %d bytes", MaxRecipeURL))
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, invalid("recipe_url", "is not a URL")
|
||||
case u.Scheme != "https" || u.Hostname() == "":
|
||||
return nil, invalid("recipe_url", "must be an https:// link")
|
||||
case u.User != nil:
|
||||
return nil, invalid("recipe_url", "must not carry a user name or password")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// checkUnique returns a *DuplicateError when name is harness.Similar to an
|
||||
// unarchived dinner other than self, or recipeURL is one's recipe link: the
|
||||
// source_url of the recipe it is grounded in, else the recipe_url still
|
||||
// awaiting grounding.
|
||||
func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) error {
|
||||
rows, err := q.ListUnarchivedDinners(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: list for dedupe: %w", err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Dinner.ID == self {
|
||||
continue
|
||||
}
|
||||
link := row.Dinner.RecipeUrl
|
||||
if row.RecipeSourceUrl.Valid {
|
||||
link = row.RecipeSourceUrl
|
||||
}
|
||||
if harness.Similar(name, row.Dinner.Name) || (recipeURL.Valid && link == recipeURL) {
|
||||
return &DuplicateError{Existing: fromRow(row.Dinner)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PhotoURL is where the API serves a list-sized copy of photo id.
|
||||
func PhotoURL(id string) string {
|
||||
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, photoListWidth)
|
||||
}
|
||||
|
||||
func fromRow(r db.Dinner) Dinner {
|
||||
d := Dinner{
|
||||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
Status: r.Status,
|
||||
Origin: r.Origin,
|
||||
Method: nullString(r.Method),
|
||||
Difficulty: nullString(r.Difficulty),
|
||||
Cuisine: nullString(r.Cuisine),
|
||||
IsFavorite: r.IsFavorite == 1,
|
||||
RecipeID: nullString(r.RecipeID),
|
||||
GroundingStatus: r.GroundingStatus,
|
||||
GroundingError: nullString(r.GroundingError),
|
||||
UpdatedBy: nullString(r.UpdatedBy),
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
if r.MinutesTotal.Valid {
|
||||
d.MinutesTotal = &r.MinutesTotal.Int64
|
||||
}
|
||||
if r.PhotoID.Valid {
|
||||
u := PhotoURL(r.PhotoID.String)
|
||||
d.PhotoURL = &u
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func nullString(s sql.NullString) *string {
|
||||
if !s.Valid {
|
||||
return nil
|
||||
}
|
||||
return &s.String
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package dinners_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
)
|
||||
|
||||
// TestCreateRegularNeverNamesFromLink: a Regular added with a recipe link
|
||||
// is named only by the household, so no part of the link — host, path,
|
||||
// query or fragment — is stored as its name or shown in a dinner or a
|
||||
// duplicate error, even a token of plain letters in the path.
|
||||
func TestCreateRegularNeverNamesFromLink(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newUnstartedEnv(t)
|
||||
const link = "https://c.example/stew-key-abcdefghijklmno?sig=zyxwvut#frag"
|
||||
leaks := []string{"c.example", "key", "abcdefghijklmno", "sig", "zyxwvut", "frag"}
|
||||
check := func(what, text string) {
|
||||
t.Helper()
|
||||
lower := strings.ToLower(text)
|
||||
for _, leak := range leaks {
|
||||
if strings.Contains(lower, leak) {
|
||||
t.Errorf("%s contains %q: %s", what, leak, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(link)}, member)
|
||||
var verr *dinners.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("link without a name: err = %v, want a *ValidationError", err)
|
||||
}
|
||||
check("validation error", verr.Error())
|
||||
|
||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr(" Sunday stew "), RecipeURL: ptr(link)}, member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Name != "Sunday stew" {
|
||||
t.Errorf("name = %q, want the household's %q", c.Name, "Sunday stew")
|
||||
}
|
||||
created, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check("create response", string(created))
|
||||
row := e.dinner(t, c.ID)
|
||||
check("stored name", row.Name+" "+row.NormalizedName)
|
||||
if row.RecipeUrl.String != link {
|
||||
t.Errorf("stored recipe_url = %q, want %q kept for grounding", row.RecipeUrl.String, link)
|
||||
}
|
||||
|
||||
list, err := e.svc.List(t.Context(), dinners.StatusRegular)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listed, err := json.Marshal(list)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check("list response", string(listed))
|
||||
|
||||
// The same link under another name is still a duplicate, and its error
|
||||
// names the existing dinner by the household's name.
|
||||
_, err = e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Weeknight chili"), RecipeURL: ptr(link)}, member)
|
||||
var dup *dinners.DuplicateError
|
||||
if !errors.As(err, &dup) || dup.Existing.ID != c.ID {
|
||||
t.Fatalf("same link again: err = %v, want a duplicate of %s", err, c.ID)
|
||||
}
|
||||
check("duplicate error", dup.Error())
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package dinners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SetGroundTimeout sets the deadline of grounding by hand until t ends. A
|
||||
// test calling it must not run in parallel.
|
||||
func SetGroundTimeout(t *testing.T, d time.Duration) {
|
||||
old := groundTimeout
|
||||
groundTimeout = d
|
||||
t.Cleanup(func() { groundTimeout = old })
|
||||
}
|
||||
|
||||
// RunGroundJob runs the KindGroundDinner handler for p in the caller's
|
||||
// goroutine, as the job runner would, and returns the grounding status it
|
||||
// reports.
|
||||
func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error) {
|
||||
r, err := s.runGroundJob(ctx, p, nil)
|
||||
return r.GroundingStatus, err
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
package dinners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// KindGroundDinner is the job that grounds a new Regular in a recipe.
|
||||
const KindGroundDinner = "ground_dinner"
|
||||
|
||||
// Grounding statuses (the dinners.grounding_status column).
|
||||
const (
|
||||
GroundingPending = "pending"
|
||||
GroundingGrounded = "grounded"
|
||||
GroundingFailed = "failed"
|
||||
GroundingManual = "manual"
|
||||
)
|
||||
|
||||
// Reasons a dinner's background grounding failed, stored in
|
||||
// dinners.grounding_error and as the KindGroundDinner job's failure code.
|
||||
const (
|
||||
// ReasonNoRecipe: no page the search found holds a usable recipe.
|
||||
ReasonNoRecipe = "no_recipe_found"
|
||||
// ReasonRecipeUnusable: the dinner's recipe_url holds no usable recipe.
|
||||
ReasonRecipeUnusable = "recipe_unusable"
|
||||
// ReasonSearchNotConfigured: recipe search is not set up.
|
||||
ReasonSearchNotConfigured = "search_not_configured"
|
||||
// ReasonSearchFailed: the search provider failed.
|
||||
ReasonSearchFailed = "search_failed"
|
||||
// ReasonAIFailed: normalize_recipe's model provider could not be built
|
||||
// or called.
|
||||
ReasonAIFailed = "ai_failed"
|
||||
// ReasonInterrupted: the server stopped while grounding.
|
||||
ReasonInterrupted = "interrupted"
|
||||
// ReasonGroundingError: any other failure, such as the database's.
|
||||
ReasonGroundingError = "grounding_error"
|
||||
)
|
||||
|
||||
// Reasons are every grounding failure reason.
|
||||
var Reasons = []string{
|
||||
ReasonNoRecipe, ReasonRecipeUnusable, ReasonSearchNotConfigured, ReasonSearchFailed,
|
||||
ReasonAIFailed, ReasonInterrupted, ReasonGroundingError,
|
||||
}
|
||||
|
||||
// Limits on candidates and on a recipe entered by hand.
|
||||
const (
|
||||
MaxCandidates = 3
|
||||
MaxManualLines = 100
|
||||
MaxManualLineChar = 500
|
||||
// manualSourcePrefix starts the source_url of a recipe entered by hand,
|
||||
// followed by the recipe's id; it is never a fetchable page.
|
||||
manualSourcePrefix = "mealquest:manual:"
|
||||
)
|
||||
|
||||
// GroundTimeout bounds grounding a candidate or link by hand, which runs in
|
||||
// the request: the page fetch (recipes.Timeout), its photo download and
|
||||
// normalize_recipe (harness.DefaultTimeout), with some slack.
|
||||
const GroundTimeout = 90 * time.Second
|
||||
|
||||
// groundTimeout is GroundTimeout; tests shorten it.
|
||||
var groundTimeout = GroundTimeout
|
||||
|
||||
// ErrGroundTimeout is returned by Ground when grounding a candidate or link
|
||||
// runs past GroundTimeout; the dinner is left as it was.
|
||||
var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long")
|
||||
|
||||
// ErrGroundFailed wraps any unexpected failure grounding a dinner by hand.
|
||||
// Its text may quote the link and dependency errors, so it is never
|
||||
// logged.
|
||||
var ErrGroundFailed = errors.New("dinners: grounding the recipe failed")
|
||||
|
||||
// ErrArchived is grounding an archived dinner by hand.
|
||||
var ErrArchived = errors.New("dinner archived")
|
||||
|
||||
// ErrGroundConflict is a candidate or link grounded for a dinner that was
|
||||
// renamed or grounded some other way while it was grounding.
|
||||
var ErrGroundConflict = errors.New("dinner changed while its recipe was grounding")
|
||||
|
||||
// GroundJob is the KindGroundDinner payload: the dinner to ground and its
|
||||
// grounding_revision when the job was enqueued.
|
||||
type GroundJob struct {
|
||||
DinnerID string `json:"dinner_id"`
|
||||
Revision int64 `json:"revision"`
|
||||
}
|
||||
|
||||
// groundResult is the KindGroundDinner result: the dinner's grounding
|
||||
// status once the job ends. It is not grounded by this job when the dinner
|
||||
// was grounded by hand before the job finished, renamed (a later job
|
||||
// grounds it) or archived; the job then reports the status it found.
|
||||
type groundResult struct {
|
||||
DinnerID string `json:"dinner_id"`
|
||||
GroundingStatus string `json:"grounding_status"`
|
||||
}
|
||||
|
||||
func (r groundResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
|
||||
// runGroundJob grounds a pending dinner as its creation asked: its
|
||||
// recipe_url with GroundURL, else its name with Ground. Success records
|
||||
// the recipe and photo with grounding_status grounded, and the recipe's
|
||||
// method, minutes and difficulty where a member has not set them; failure
|
||||
// records grounding_status failed with its Reason, which is also the job's
|
||||
// failure code. A dinner archived, no longer pending, or at a
|
||||
// grounding_revision other than p.Revision is not grounded at all, and
|
||||
// either result is recorded only while the dinner is still unarchived,
|
||||
// pending and at p.Revision: a dinner grounded by hand meanwhile is left as
|
||||
// it is, and one renamed or archived meanwhile is left pending — for the
|
||||
// job its rename enqueued, or for none. Archiving or renaming the dinner
|
||||
// stops the grounding where it is, so no further search, fetch, photo
|
||||
// download or model call is made for it.
|
||||
func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundResult) error) (groundResult, error) {
|
||||
// Tracked before the dinner is read: an archive or rename committed
|
||||
// after the read finds this job to stop.
|
||||
groundCtx, done := s.trackGrounding(ctx, p.DinnerID, p.Revision)
|
||||
defer done()
|
||||
row, err := db.New(s.st.Reader).GetDinner(ctx, p.DinnerID)
|
||||
if err != nil {
|
||||
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", p.DinnerID, err)
|
||||
}
|
||||
if row.Status == StatusArchived || row.GroundingStatus != GroundingPending || row.GroundingRevision != p.Revision {
|
||||
return groundResult{DinnerID: row.ID, GroundingStatus: row.GroundingStatus}, nil
|
||||
}
|
||||
|
||||
var gr recipes.Grounded
|
||||
if row.RecipeUrl.Valid {
|
||||
gr, err = s.grounder.GroundURL(groundCtx, row.RecipeUrl.String)
|
||||
} else {
|
||||
gr, err = s.grounder.Ground(groundCtx, row.Name, row.Name)
|
||||
}
|
||||
if err != nil && ctx.Err() == nil && errors.Is(context.Cause(groundCtx), errGroundingStopped) {
|
||||
return s.currentGrounding(ctx, row.ID)
|
||||
}
|
||||
if err != nil {
|
||||
reason := reasonFor(err, row.RecipeUrl.Valid)
|
||||
if ctx.Err() != nil {
|
||||
reason = ReasonInterrupted
|
||||
}
|
||||
// Only the reason is logged: the error may quote a dependency's
|
||||
// response, which jobs never store, return or log.
|
||||
s.logger.InfoContext(ctx, "dinner grounding failed", "dinner_id", row.ID, "reason", reason)
|
||||
write := context.WithoutCancel(ctx)
|
||||
if _, failErr := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{
|
||||
GroundingError: sql.NullString{String: reason, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: row.ID,
|
||||
GroundingRevision: p.Revision,
|
||||
}); failErr != nil {
|
||||
return groundResult{}, fmt.Errorf("dinners: ground: record failure of %s: %w", row.ID, failErr)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return groundResult{}, ctx.Err()
|
||||
}
|
||||
return groundResult{}, &jobs.Failure{Code: reason}
|
||||
}
|
||||
|
||||
n, err := db.New(s.st.Writer).GroundPendingDinner(ctx, db.GroundPendingDinnerParams{
|
||||
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
||||
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
||||
Method: sql.NullString{String: gr.Method, Valid: true},
|
||||
MinutesTotal: nullInt(gr.MinutesTotal),
|
||||
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: row.ID,
|
||||
GroundingRevision: p.Revision,
|
||||
})
|
||||
if err != nil {
|
||||
return groundResult{}, fmt.Errorf("dinners: ground: record %s: %w", row.ID, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return s.currentGrounding(ctx, row.ID)
|
||||
}
|
||||
return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil
|
||||
}
|
||||
|
||||
// currentGrounding is the KindGroundDinner result of a job that did not
|
||||
// ground dinner id: the grounding status the dinner has now.
|
||||
func (s *Service) currentGrounding(ctx context.Context, id string) (groundResult, error) {
|
||||
cur, err := db.New(s.st.Reader).GetDinner(ctx, id)
|
||||
if err != nil {
|
||||
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", id, err)
|
||||
}
|
||||
return groundResult{DinnerID: cur.ID, GroundingStatus: cur.GroundingStatus}, nil
|
||||
}
|
||||
|
||||
// errGroundingStopped is the cause a running KindGroundDinner job's
|
||||
// grounding is cancelled with when its dinner is archived or renamed.
|
||||
var errGroundingStopped = errors.New("dinners: grounding stopped: dinner archived or renamed")
|
||||
|
||||
// runningGrounding is a KindGroundDinner job running for a dinner at
|
||||
// revision, with the cancel of its grounding.
|
||||
type runningGrounding struct {
|
||||
revision int64
|
||||
cancel context.CancelCauseFunc
|
||||
}
|
||||
|
||||
// trackGrounding returns a context for grounding dinnerID at revision that
|
||||
// stopGroundings cancels, and the func to call when the job ends.
|
||||
func (s *Service) trackGrounding(ctx context.Context, dinnerID string, revision int64) (context.Context, func()) {
|
||||
groundCtx, cancel := context.WithCancelCause(ctx)
|
||||
r := &runningGrounding{revision: revision, cancel: cancel}
|
||||
s.groundingsMu.Lock()
|
||||
if s.groundings[dinnerID] == nil {
|
||||
s.groundings[dinnerID] = make(map[*runningGrounding]struct{})
|
||||
}
|
||||
s.groundings[dinnerID][r] = struct{}{}
|
||||
s.groundingsMu.Unlock()
|
||||
return groundCtx, func() {
|
||||
s.groundingsMu.Lock()
|
||||
delete(s.groundings[dinnerID], r)
|
||||
if len(s.groundings[dinnerID]) == 0 {
|
||||
delete(s.groundings, dinnerID)
|
||||
}
|
||||
s.groundingsMu.Unlock()
|
||||
cancel(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// stopGroundings cancels, with errGroundingStopped, the grounding of every
|
||||
// KindGroundDinner job running for dinnerID at a revision below below. Call
|
||||
// it once the change that makes those jobs stale is committed.
|
||||
func (s *Service) stopGroundings(dinnerID string, below int64) {
|
||||
s.groundingsMu.Lock()
|
||||
defer s.groundingsMu.Unlock()
|
||||
for r := range s.groundings[dinnerID] {
|
||||
if r.revision < below {
|
||||
r.cancel(errGroundingStopped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FailInterruptedGroundings fails, with ReasonInterrupted, every unarchived
|
||||
// dinner still pending whose KindGroundDinner job is no longer queued or
|
||||
// running: a previous process stopped before enqueuing the job or before
|
||||
// its handler recorded a result. An archived dinner is left pending, since
|
||||
// archiving, not a restart, stopped its grounding. Call it once at boot, after the job runner's
|
||||
// Start has failed the jobs that process left running, so no dinner stays
|
||||
// pending with nothing left to ground it. It returns how many it failed.
|
||||
func (s *Service) FailInterruptedGroundings(ctx context.Context) (int64, error) {
|
||||
n, err := db.New(s.st.Writer).FailOrphanedPendingDinnerGroundings(ctx, db.FailOrphanedPendingDinnerGroundingsParams{
|
||||
GroundingError: sql.NullString{String: ReasonInterrupted, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
Kind: KindGroundDinner,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("dinners: fail interrupted groundings: %w", err)
|
||||
}
|
||||
if n > 0 {
|
||||
s.logger.WarnContext(ctx, "failed dinner groundings interrupted by restart", "count", n)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// reasonFor is the Reason for a grounding error; fromURL says whether the
|
||||
// dinner's recipe_url was grounded rather than its name.
|
||||
func reasonFor(err error, fromURL bool) string {
|
||||
var ungroundable *recipes.ErrUngroundable
|
||||
switch {
|
||||
case errors.As(err, &ungroundable) && fromURL:
|
||||
return ReasonRecipeUnusable
|
||||
case errors.As(err, &ungroundable):
|
||||
return ReasonNoRecipe
|
||||
case errors.Is(err, recipes.ErrSearchNotConfigured):
|
||||
return ReasonSearchNotConfigured
|
||||
case errors.Is(err, recipes.ErrSearchFailed):
|
||||
return ReasonSearchFailed
|
||||
case errors.Is(err, recipes.ErrNormalizeFailed):
|
||||
return ReasonAIFailed
|
||||
}
|
||||
return ReasonGroundingError
|
||||
}
|
||||
|
||||
// Candidate is a recipe page offered to ground a dinner in.
|
||||
type Candidate struct {
|
||||
// CandidateID is the page's URL; Fix.CandidateID grounds it.
|
||||
CandidateID string `json:"candidate_id"`
|
||||
Title string `json:"title"`
|
||||
SiteName string `json:"site_name"`
|
||||
// PhotoURL is the cached photo of a page already grounded, else null.
|
||||
PhotoURL *string `json:"photo_url"`
|
||||
Minutes *int `json:"minutes"`
|
||||
}
|
||||
|
||||
// Candidates searches for dinner id's name and returns up to MaxCandidates
|
||||
// recipe pages to ground it in, best first; the list may be empty. A
|
||||
// search that fails wraps recipes.ErrSearchFailed.
|
||||
func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error) {
|
||||
row, err := s.get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
offers, err := s.grounder.Candidates(ctx, row.Name, row.Name, MaxCandidates)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Candidate, len(offers))
|
||||
for i, o := range offers {
|
||||
out[i] = Candidate{CandidateID: o.URL, Title: o.Title, SiteName: o.SiteName, Minutes: o.Minutes}
|
||||
if o.PhotoID != "" {
|
||||
u := PhotoURL(o.PhotoID)
|
||||
out[i].PhotoURL = &u
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Fix grounds a dinner by hand: exactly one of a candidate from
|
||||
// Candidates, a pasted recipe link or a recipe typed in.
|
||||
type Fix struct {
|
||||
CandidateID *string
|
||||
RecipeURL *string
|
||||
Manual *ManualRecipe
|
||||
}
|
||||
|
||||
// ManualRecipe is a recipe typed in by the household.
|
||||
type ManualRecipe struct {
|
||||
// Ingredients are the ingredient lines, at least one.
|
||||
Ingredients []string
|
||||
// Steps are the instructions, in order; there may be none.
|
||||
Steps []string
|
||||
Minutes *int
|
||||
Method *string
|
||||
}
|
||||
|
||||
// Ground grounds dinner id as f says, recording updatedBy. A candidate or
|
||||
// link is grounded with recipes.Grounder.GroundURL and sets
|
||||
// grounding_status grounded, with the recipe's photo, method, minutes and
|
||||
// difficulty; one a member changed while it was grounding is kept. A
|
||||
// manual recipe is stored as typed — no model runs — and sets
|
||||
// grounding_status manual with its method and minutes; it has no photo,
|
||||
// and the dinner keeps its difficulty. An invalid f is a *ValidationError
|
||||
// and an archived dinner ErrArchived. A failure grounding a candidate or
|
||||
// link, or recording it on the dinner, wraps ErrGroundFailed; a page that
|
||||
// cannot ground the dinner is also a *recipes.ErrUngroundable. A dinner
|
||||
// renamed, archived or grounded some other way while a candidate or link
|
||||
// was grounding is ErrGroundConflict or ErrArchived. A missing dinner is
|
||||
// ErrNotFound; any other failure, by candidate, link or manual recipe,
|
||||
// wraps ErrGroundFailed. None of these changes the dinner.
|
||||
func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string) (Dinner, error) {
|
||||
if updatedBy == "" {
|
||||
return Dinner{}, errors.New("dinners: ground: no member to record as updated_by")
|
||||
}
|
||||
link, manual, err := f.validate()
|
||||
if err != nil {
|
||||
return Dinner{}, err
|
||||
}
|
||||
if manual != nil {
|
||||
return s.groundManual(ctx, id, *manual, updatedBy)
|
||||
}
|
||||
cur, err := s.get(ctx, id)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return Dinner{}, err
|
||||
}
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
|
||||
}
|
||||
if cur.Status == StatusArchived {
|
||||
return Dinner{}, ErrArchived
|
||||
}
|
||||
groundCtx, cancel := context.WithTimeout(ctx, groundTimeout)
|
||||
defer cancel()
|
||||
gr, err := s.grounder.GroundURL(groundCtx, link)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil && errors.Is(groundCtx.Err(), context.DeadlineExceeded) {
|
||||
return Dinner{}, ErrGroundTimeout
|
||||
}
|
||||
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
|
||||
}
|
||||
row, err := db.New(s.st.Writer).GroundDinner(ctx, db.GroundDinnerParams{
|
||||
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
||||
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
||||
Method: sql.NullString{String: gr.Method, Valid: true},
|
||||
MinutesTotal: nullInt(gr.MinutesTotal),
|
||||
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
||||
GroundingStatus: GroundingGrounded,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: cur.ID,
|
||||
ObservedName: cur.Name,
|
||||
ObservedNormalizedName: cur.NormalizedName,
|
||||
ObservedGroundingStatus: cur.GroundingStatus,
|
||||
ObservedGroundingRevision: cur.GroundingRevision,
|
||||
ObservedRecipeID: cur.RecipeID,
|
||||
ObservedMethod: cur.Method,
|
||||
ObservedMinutesTotal: cur.MinutesTotal,
|
||||
ObservedDifficulty: cur.Difficulty,
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Dinner{}, s.groundRejected(ctx, id)
|
||||
}
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
|
||||
}
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
// groundRejected is the error for dinner id when it was not as observed
|
||||
// before grounding: ErrNotFound, ErrArchived or ErrGroundConflict. Failing
|
||||
// to read it wraps ErrGroundFailed.
|
||||
func (s *Service) groundRejected(ctx context.Context, id string) error {
|
||||
cur, err := s.get(ctx, id)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
|
||||
}
|
||||
if cur.Status == StatusArchived {
|
||||
return ErrArchived
|
||||
}
|
||||
return ErrGroundConflict
|
||||
}
|
||||
|
||||
// groundManual stores m as dinner id's recipe, with its ingredients and
|
||||
// steps, and records it on the dinner, all in one transaction. A missing
|
||||
// or archived dinner is ErrNotFound or ErrArchived; any other failure
|
||||
// wraps ErrGroundFailed.
|
||||
func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, updatedBy string) (Dinner, error) {
|
||||
var row db.Dinner
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
cur, err := q.GetDinner(ctx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: read %s: %w", id, err)
|
||||
}
|
||||
if cur.Status == StatusArchived {
|
||||
return ErrArchived
|
||||
}
|
||||
ld, err := json.Marshal(manualJSONLD{
|
||||
Context: "https://schema.org",
|
||||
Type: "Recipe",
|
||||
Name: cur.Name,
|
||||
Ingredients: m.Ingredients,
|
||||
Instructions: m.Steps,
|
||||
TotalTime: fmt.Sprintf("PT%dM", *m.Minutes),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: encode manual recipe: %w", err)
|
||||
}
|
||||
now := store.Now().String()
|
||||
recipeID := store.NewID()
|
||||
minutes := sql.NullInt64{Int64: int64(*m.Minutes), Valid: true}
|
||||
method := sql.NullString{String: *m.Method, Valid: true}
|
||||
rec, err := q.CreateManualRecipe(ctx, db.CreateManualRecipeParams{
|
||||
ID: recipeID,
|
||||
SourceUrl: manualSourcePrefix + recipeID,
|
||||
Title: cur.Name,
|
||||
TotalMinutes: minutes,
|
||||
RawJsonld: string(ld),
|
||||
Method: method,
|
||||
MinutesTotal: minutes,
|
||||
FetchedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: store manual recipe: %w", err)
|
||||
}
|
||||
for i, line := range m.Ingredients {
|
||||
if err := q.CreateRecipeIngredient(ctx, db.CreateRecipeIngredientParams{
|
||||
ID: store.NewID(),
|
||||
RecipeID: rec.ID,
|
||||
Position: int64(i),
|
||||
RawText: line,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("dinners: store manual ingredient %d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
for i, text := range m.Steps {
|
||||
if err := q.CreateRecipeStep(ctx, db.CreateRecipeStepParams{
|
||||
ID: store.NewID(),
|
||||
RecipeID: rec.ID,
|
||||
Position: int64(i),
|
||||
Text: text,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("dinners: store manual step %d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
row, err = q.GroundDinner(ctx, db.GroundDinnerParams{
|
||||
RecipeID: sql.NullString{String: rec.ID, Valid: true},
|
||||
Method: method,
|
||||
MinutesTotal: minutes,
|
||||
Difficulty: cur.Difficulty,
|
||||
GroundingStatus: GroundingManual,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: now,
|
||||
ID: cur.ID,
|
||||
ObservedName: cur.Name,
|
||||
ObservedNormalizedName: cur.NormalizedName,
|
||||
ObservedGroundingStatus: cur.GroundingStatus,
|
||||
ObservedGroundingRevision: cur.GroundingRevision,
|
||||
ObservedRecipeID: cur.RecipeID,
|
||||
ObservedMethod: cur.Method,
|
||||
ObservedMinutesTotal: cur.MinutesTotal,
|
||||
ObservedDifficulty: cur.Difficulty,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: ground %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrArchived) {
|
||||
return Dinner{}, err
|
||||
}
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
|
||||
}
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
// manualJSONLD is the schema.org Recipe stored as a manual recipe's
|
||||
// raw_jsonld, so it reads like a fetched page's.
|
||||
type manualJSONLD struct {
|
||||
Context string `json:"@context"`
|
||||
Type string `json:"@type"`
|
||||
Name string `json:"name"`
|
||||
Ingredients []string `json:"recipeIngredient"`
|
||||
Instructions []string `json:"recipeInstructions"`
|
||||
TotalTime string `json:"totalTime"`
|
||||
}
|
||||
|
||||
// validate checks f and returns the link to ground, or the manual recipe
|
||||
// with its lines trimmed.
|
||||
func (f Fix) validate() (link string, manual *ManualRecipe, err error) {
|
||||
given := 0
|
||||
for _, set := range []bool{f.CandidateID != nil, f.RecipeURL != nil, f.Manual != nil} {
|
||||
if set {
|
||||
given++
|
||||
}
|
||||
}
|
||||
if given != 1 {
|
||||
return "", nil, invalid("ground", "give exactly one of candidate_id, recipe_url and manual")
|
||||
}
|
||||
switch {
|
||||
case f.CandidateID != nil:
|
||||
u, err := checkRecipeURL(*f.CandidateID)
|
||||
if err != nil {
|
||||
return "", nil, invalid("candidate_id", "is not a candidate from recipe-candidates")
|
||||
}
|
||||
return u.String(), nil, nil
|
||||
case f.RecipeURL != nil:
|
||||
u, err := checkRecipeURL(*f.RecipeURL)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return u.String(), nil, nil
|
||||
}
|
||||
m, err := f.Manual.validate()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return "", &m, nil
|
||||
}
|
||||
|
||||
// validate checks every field of m and returns it with its lines trimmed.
|
||||
func (m ManualRecipe) validate() (ManualRecipe, error) {
|
||||
var problems []Problem
|
||||
ings, ingProblem := checkLines(m.Ingredients, 1)
|
||||
if ingProblem != "" {
|
||||
problems = append(problems, Problem{"manual.ingredients", ingProblem})
|
||||
}
|
||||
steps, stepProblem := checkLines(m.Steps, 0)
|
||||
if stepProblem != "" {
|
||||
problems = append(problems, Problem{"manual.steps", stepProblem})
|
||||
}
|
||||
if m.Minutes == nil || *m.Minutes < 1 || *m.Minutes > MaxMinutes {
|
||||
problems = append(problems, Problem{"manual.minutes", fmt.Sprintf("must be 1 to %d", MaxMinutes)})
|
||||
}
|
||||
if m.Method == nil || !slices.Contains(Methods, *m.Method) {
|
||||
problems = append(problems, Problem{"manual.method", "must be one of " + strings.Join(Methods, ", ")})
|
||||
}
|
||||
if problems != nil {
|
||||
return ManualRecipe{}, &ValidationError{Problems: problems}
|
||||
}
|
||||
return ManualRecipe{Ingredients: ings, Steps: steps, Minutes: m.Minutes, Method: m.Method}, nil
|
||||
}
|
||||
|
||||
// checkLines returns lines trimmed, or why they are refused: fewer than
|
||||
// least, more than MaxManualLines, or one blank or over MaxManualLineChar.
|
||||
func checkLines(lines []string, least int) ([]string, string) {
|
||||
if len(lines) < least {
|
||||
return nil, fmt.Sprintf("must have at least %d line", least)
|
||||
}
|
||||
if len(lines) > MaxManualLines {
|
||||
return nil, fmt.Sprintf("must have at most %d lines", MaxManualLines)
|
||||
}
|
||||
out := make([]string, len(lines))
|
||||
for i, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case line == "":
|
||||
return nil, fmt.Sprintf("line %d is blank", i+1)
|
||||
case utf8.RuneCountInString(line) > MaxManualLineChar:
|
||||
return nil, fmt.Sprintf("line %d is over %d characters", i+1, MaxManualLineChar)
|
||||
}
|
||||
out[i] = line
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
|
||||
// get reads dinner id, or ErrNotFound.
|
||||
func (s *Service) get(ctx context.Context, id string) (db.Dinner, error) {
|
||||
row, err := db.New(s.st.Reader).GetDinner(ctx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return db.Dinner{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return db.Dinner{}, fmt.Errorf("dinners: read %s: %w", id, err)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func nullInt(v *int) sql.NullInt64 {
|
||||
if v == nil {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
return sql.NullInt64{Int64: int64(*v), Valid: true}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -68,8 +68,18 @@ type SettingsPatch struct {
|
||||
SuggestionsTime *string
|
||||
ChatAutosendDow *int
|
||||
ChatAutosendTime *string
|
||||
// OnboardingCompleted, when true, records that the household finished
|
||||
// onboarding: onboarding_completed_at becomes the time of the update,
|
||||
// unless it is already set, when the first time is kept. Onboarding is
|
||||
// never undone, so false is invalid, and it cannot first be completed
|
||||
// with fewer than MinRegulars Regulars.
|
||||
OnboardingCompleted *bool
|
||||
}
|
||||
|
||||
// MinRegulars is how many Regulars the household must have to complete
|
||||
// onboarding.
|
||||
const MinRegulars = 3
|
||||
|
||||
// Problem is one invalid field of a SettingsPatch, named by its JSON key.
|
||||
type Problem struct {
|
||||
Field string
|
||||
@@ -162,7 +172,8 @@ func (s *SettingsService) Get(ctx context.Context) (Settings, error) {
|
||||
// Update validates patch, applies it, records updatedBy (the signed-in
|
||||
// member's subject, which must not be empty) and publishes the new settings
|
||||
// to every subscriber. An invalid patch is a *ValidationError and changes
|
||||
// nothing.
|
||||
// nothing, as does completing onboarding with fewer than MinRegulars
|
||||
// Regulars.
|
||||
func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updatedBy string) (Settings, error) {
|
||||
if updatedBy == "" {
|
||||
return Settings{}, errors.New("household: update settings: no member to record as updated_by")
|
||||
@@ -208,6 +219,19 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
|
||||
setString(&p.SuggestionsTime, patch.SuggestionsTime)
|
||||
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
|
||||
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
|
||||
if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid {
|
||||
n, err := q.CountRegularDinners(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: count Regulars: %w", err)
|
||||
}
|
||||
if n < MinRegulars {
|
||||
return &ValidationError{Problems: []Problem{{
|
||||
Field: "onboarding_completed",
|
||||
Reason: fmt.Sprintf("add at least %d Regulars first (have %d)", MinRegulars, n),
|
||||
}}}
|
||||
}
|
||||
p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true}
|
||||
}
|
||||
row, err = q.UpsertHouseholdSettings(ctx, p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: update settings: %w", err)
|
||||
@@ -291,6 +315,9 @@ func (p SettingsPatch) validate() error {
|
||||
fail(d.field, fmt.Sprintf("invalid day of the week %d (want 0-6, Sunday = 0)", *d.value))
|
||||
}
|
||||
}
|
||||
if p.OnboardingCompleted != nil && !*p.OnboardingCompleted {
|
||||
fail("onboarding_completed", "onboarding cannot be undone (want true)")
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return &ValidationError{Problems: problems}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@ package household
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
@@ -34,12 +37,34 @@ func migratedStore(t *testing.T, dir string) *store.Store {
|
||||
// seededService returns a settings service over a migrated temp database
|
||||
// seeded with zone America/New_York.
|
||||
func seededService(t *testing.T) *SettingsService {
|
||||
t.Helper()
|
||||
svc, _ := seededServiceStore(t)
|
||||
return svc
|
||||
}
|
||||
|
||||
// seededServiceStore is seededService with its store.
|
||||
func seededServiceStore(t *testing.T) (*SettingsService, *store.Store) {
|
||||
t.Helper()
|
||||
st := migratedStore(t, t.TempDir())
|
||||
if err := SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewSettingsService(st)
|
||||
return NewSettingsService(st), st
|
||||
}
|
||||
|
||||
// addDinners stores n dinners with status.
|
||||
func addDinners(t *testing.T, st *store.Store, status string, n int) {
|
||||
t.Helper()
|
||||
for range n {
|
||||
id := store.NewID()
|
||||
now := store.Now().String()
|
||||
if _, err := db.New(st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
|
||||
ID: id, Name: "Dinner " + id, NormalizedName: "dinner " + strings.ToLower(id),
|
||||
Status: status, Origin: "regular", GroundingStatus: "pending", CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
@@ -217,6 +242,70 @@ func TestUpdateAppliesPatchAndRecordsUpdatedBy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCompletesOnboardingOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
svc, st := seededServiceStore(t)
|
||||
addDinners(t, st, "regular", MinRegulars)
|
||||
|
||||
got, err := svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "sam")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != got.UpdatedAt {
|
||||
t.Fatalf("onboarding_completed_at = %v, want the update time %s", got.OnboardingCompletedAt, got.UpdatedAt)
|
||||
}
|
||||
first := *got.OnboardingCompletedAt
|
||||
|
||||
// Completing again keeps the first time.
|
||||
got, err = svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "alex")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != first {
|
||||
t.Errorf("second completion: onboarding_completed_at = %v, want %s", got.OnboardingCompletedAt, first)
|
||||
}
|
||||
|
||||
// Other changes leave it alone.
|
||||
got, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, "sam")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != first {
|
||||
t.Errorf("after another change: onboarding_completed_at = %v, want %s", got.OnboardingCompletedAt, first)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateOnboardingNeedsMinRegulars: onboarding cannot be completed
|
||||
// with fewer than MinRegulars Regulars; archived and Quest log dinners do
|
||||
// not count.
|
||||
func TestUpdateOnboardingNeedsMinRegulars(t *testing.T) {
|
||||
t.Parallel()
|
||||
for regulars := range MinRegulars {
|
||||
t.Run(fmt.Sprintf("%d Regulars", regulars), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
svc, st := seededServiceStore(t)
|
||||
addDinners(t, st, "regular", regulars)
|
||||
addDinners(t, st, "archived", MinRegulars)
|
||||
addDinners(t, st, "quest_log", MinRegulars)
|
||||
|
||||
_, err := svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "sam")
|
||||
var verr *ValidationError
|
||||
if !errors.As(err, &verr) || len(verr.Problems) != 1 || verr.Problems[0].Field != "onboarding_completed" {
|
||||
t.Fatalf("Update = %v, want a ValidationError on onboarding_completed", err)
|
||||
}
|
||||
got, err := svc.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.OnboardingCompletedAt != nil {
|
||||
t.Errorf("onboarding_completed_at = %s after a refused completion, want null", *got.OnboardingCompletedAt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRequiresMember(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -259,6 +348,7 @@ func TestUpdateValidation(t *testing.T) {
|
||||
{"dow 7", SettingsPatch{PlanDraftDow: ptr(7)}, []string{"plan_draft_dow"}},
|
||||
{"dow -1", SettingsPatch{SuggestionsDow: ptr(-1)}, []string{"suggestions_dow"}},
|
||||
{"dow 100", SettingsPatch{ChatAutosendDow: ptr(100)}, []string{"chat_autosend_dow"}},
|
||||
{"onboarding undone", SettingsPatch{OnboardingCompleted: ptr(false)}, []string{"onboarding_completed"}},
|
||||
{
|
||||
"every problem is reported",
|
||||
SettingsPatch{Timezone: ptr("Nowhere"), DinnerTime: ptr("5pm"), PlanDraftDow: ptr(9)},
|
||||
|
||||
+105
-34
@@ -54,14 +54,30 @@ const (
|
||||
CodeUnknownKind = "unknown_kind"
|
||||
)
|
||||
|
||||
// Claim retry backoff. A worker whose claim fails with a database error
|
||||
// retries after claimRetryMin, doubling up to claimRetryMax, so a job queued
|
||||
// when the error clears is claimed without waiting for a wake token.
|
||||
// Retry backoff. A worker whose claim or final job write fails with a
|
||||
// database error retries after retryMin, doubling up to retryMax, so a job
|
||||
// queued when the error clears is claimed without waiting for a wake token,
|
||||
// and a job the worker has finished is never left running.
|
||||
const (
|
||||
claimRetryMin = 50 * time.Millisecond
|
||||
claimRetryMax = 5 * time.Second
|
||||
retryMin = 50 * time.Millisecond
|
||||
retryMax = 5 * time.Second
|
||||
)
|
||||
|
||||
// FinalWriteTimeout bounds each attempt at a job's final write, and at a
|
||||
// handler's own last write, so a writer connection that stays busy cannot
|
||||
// hold a stopping worker, and with it shutdown, open.
|
||||
const FinalWriteTimeout = 5 * time.Second
|
||||
|
||||
// FinalWriteContext returns the context for one attempt at a final write
|
||||
// of a job: it is not cancelled with ctx, so a job that finishes during
|
||||
// shutdown is still recorded, but it ends after FinalWriteTimeout. A
|
||||
// handler makes its own last write, the one recording what the job did,
|
||||
// with it too, and returns the error when the attempt times out, so its
|
||||
// worker can stop; the next Start fails the job as interrupted.
|
||||
func FinalWriteContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), FinalWriteTimeout)
|
||||
}
|
||||
|
||||
// codePattern is the form of every failure code: lower-case snake case.
|
||||
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
||||
|
||||
@@ -259,6 +275,21 @@ func (k *Kind[P]) Enqueue(ctx context.Context, payload P) (string, error) {
|
||||
return k.r.enqueue(ctx, k.name, data)
|
||||
}
|
||||
|
||||
// EnqueueTx stores a queued job with payload in tx and returns its id, so
|
||||
// the job exists exactly when what else tx writes does. Once tx has
|
||||
// committed, call Wake so an idle worker runs it.
|
||||
func (k *Kind[P]) EnqueueTx(ctx context.Context, tx *sql.Tx, payload P) (string, error) {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err)
|
||||
}
|
||||
return createJob(ctx, db.New(tx), k.name, data)
|
||||
}
|
||||
|
||||
// Wake tells an idle worker to look for a job, as Enqueue does itself:
|
||||
// call it after committing the transaction a job was enqueued in.
|
||||
func (k *Kind[P]) Wake() { k.r.wakeOne() }
|
||||
|
||||
// Enqueue stores a queued job of kind with its JSON payload and returns its
|
||||
// id, as Kind.Enqueue does for a kind known at compile time. It returns
|
||||
// ErrUnknownKind for a kind with no registered handler and
|
||||
@@ -280,9 +311,20 @@ func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessa
|
||||
// enqueue stores a queued job of kind with its encoded payload and wakes a
|
||||
// worker.
|
||||
func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (string, error) {
|
||||
id, err := createJob(ctx, db.New(r.st.Writer), kind, payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
r.wakeOne()
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// createJob stores a queued job of kind with its encoded payload through q
|
||||
// and returns its id.
|
||||
func createJob(ctx context.Context, q *db.Queries, kind string, payload []byte) (string, error) {
|
||||
id := store.NewID()
|
||||
now := store.Now().String()
|
||||
if err := db.New(r.st.Writer).CreateJob(ctx, db.CreateJobParams{
|
||||
if err := q.CreateJob(ctx, db.CreateJobParams{
|
||||
ID: id,
|
||||
Kind: kind,
|
||||
Payload: string(payload),
|
||||
@@ -291,11 +333,15 @@ func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (stri
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("jobs: enqueue %s: %w", kind, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// wakeOne gives an idle worker a token to look for a queued job.
|
||||
func (r *Runner) wakeOne() {
|
||||
select {
|
||||
case r.wake <- struct{}{}:
|
||||
default: // every worker already has a token and will look again
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Start fails every job a previous process left running with
|
||||
@@ -357,16 +403,16 @@ func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
|
||||
// already queued is not stranded by a transient error.
|
||||
func (r *Runner) work(ctx context.Context) {
|
||||
defer r.wg.Done()
|
||||
backoff := claimRetryMin
|
||||
backoff := retryMin
|
||||
for ctx.Err() == nil {
|
||||
job, err := r.claim(ctx, sql.NullString{String: store.Now().String(), Valid: true})
|
||||
switch {
|
||||
case err == nil:
|
||||
backoff = claimRetryMin
|
||||
backoff = retryMin
|
||||
r.run(ctx, job)
|
||||
continue
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
backoff = claimRetryMin
|
||||
backoff = retryMin
|
||||
select {
|
||||
case <-r.wake:
|
||||
case <-ctx.Done():
|
||||
@@ -382,7 +428,7 @@ func (r *Runner) work(ctx context.Context) {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
}
|
||||
backoff = min(2*backoff, claimRetryMax)
|
||||
backoff = min(2*backoff, retryMax)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,14 +436,13 @@ func (r *Runner) work(ctx context.Context) {
|
||||
// ctx has been cancelled, so a handler that finishes during shutdown is
|
||||
// recorded.
|
||||
func (r *Runner) run(ctx context.Context, job db.Job) {
|
||||
write := context.WithoutCancel(ctx)
|
||||
logger := r.logger.With("job_id", job.ID, "kind", job.Kind)
|
||||
q := db.New(r.st.Writer)
|
||||
|
||||
h, ok := r.handlers[job.Kind] // read-only once started
|
||||
if !ok {
|
||||
logger.ErrorContext(ctx, "job failed", "code", CodeUnknownKind, "reason", "no handler registered for the kind")
|
||||
r.fail(write, logger, job.ID, CodeUnknownKind)
|
||||
r.fail(ctx, logger, job.ID, CodeUnknownKind)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -419,7 +464,7 @@ func (r *Runner) run(ctx context.Context, job db.Job) {
|
||||
result, code, reason := h.run(ctx, job.Payload, publish)
|
||||
if code != "" {
|
||||
if ctx.Err() != nil {
|
||||
logger.WarnContext(write, "job interrupted by shutdown; it fails at the next start")
|
||||
logger.WarnContext(context.WithoutCancel(ctx), "job interrupted by shutdown; it fails at the next start")
|
||||
return
|
||||
}
|
||||
level := slog.LevelInfo
|
||||
@@ -427,35 +472,61 @@ func (r *Runner) run(ctx context.Context, job db.Job) {
|
||||
level = slog.LevelError
|
||||
}
|
||||
logger.Log(ctx, level, "job failed", "code", code, "reason", reason)
|
||||
r.fail(write, logger, job.ID, code)
|
||||
r.fail(ctx, logger, job.ID, code)
|
||||
return
|
||||
}
|
||||
|
||||
n, err := q.SucceedJob(write, db.SucceedJobParams{
|
||||
Result: sql.NullString{String: string(result), Valid: true},
|
||||
Now: sql.NullString{String: store.Now().String(), Valid: true},
|
||||
ID: job.ID,
|
||||
r.record(ctx, logger, "record job success", func(write context.Context) (int64, error) {
|
||||
return q.SucceedJob(write, db.SucceedJobParams{
|
||||
Result: sql.NullString{String: string(result), Valid: true},
|
||||
Now: sql.NullString{String: store.Now().String(), Valid: true},
|
||||
ID: job.ID,
|
||||
})
|
||||
})
|
||||
if err == nil && n != 1 {
|
||||
err = errors.New("job is not running")
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(write, "record job success", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fail records the job as failed with code. The record errors it logs come
|
||||
// from the database, never from a handler.
|
||||
func (r *Runner) fail(ctx context.Context, logger *slog.Logger, id, code string) {
|
||||
n, err := db.New(r.st.Writer).FailJob(ctx, db.FailJobParams{
|
||||
Error: sql.NullString{String: code, Valid: true},
|
||||
Now: sql.NullString{String: store.Now().String(), Valid: true},
|
||||
ID: id,
|
||||
r.record(ctx, logger, "record job failure", func(write context.Context) (int64, error) {
|
||||
return db.New(r.st.Writer).FailJob(write, db.FailJobParams{
|
||||
Error: sql.NullString{String: code, Valid: true},
|
||||
Now: sql.NullString{String: store.Now().String(), Valid: true},
|
||||
ID: id,
|
||||
})
|
||||
})
|
||||
if err == nil && n != 1 {
|
||||
err = errors.New("job is not running")
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "record job failure", "error", err)
|
||||
}
|
||||
|
||||
// record makes a job's final write, which must change the one running job.
|
||||
// A database error is retried with backoff until the write lands, so a
|
||||
// worker never moves on from a job it has finished while leaving it
|
||||
// running. Each attempt is made with FinalWriteContext, so even when ctx
|
||||
// has been cancelled, and is bounded by FinalWriteTimeout; once ctx is
|
||||
// cancelled, a failed or timed-out attempt is not retried and the job
|
||||
// stays running, so the next Start fails it as interrupted.
|
||||
func (r *Runner) record(ctx context.Context, logger *slog.Logger, what string, write func(context.Context) (int64, error)) {
|
||||
backoff := retryMin
|
||||
for {
|
||||
attempt, cancel := FinalWriteContext(ctx)
|
||||
n, err := write(attempt)
|
||||
cancel()
|
||||
if err == nil {
|
||||
if n != 1 {
|
||||
logger.ErrorContext(ctx, what, "error", "job is not running")
|
||||
}
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
logger.ErrorContext(context.WithoutCancel(ctx), what+" failed during shutdown; it fails at the next start", "error", err)
|
||||
return
|
||||
}
|
||||
logger.ErrorContext(ctx, what+" failed; retrying", "error", err, "retry_in", backoff)
|
||||
timer := time.NewTimer(backoff)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
}
|
||||
backoff = min(2*backoff, retryMax)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +187,46 @@ func TestJobSucceeds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnqueueTxStoresJobOnlyWithItsTransaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 1)
|
||||
count := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
start(t, r)
|
||||
ctx := context.Background()
|
||||
|
||||
var rolledBack string
|
||||
refused := errors.New("refused")
|
||||
if err := st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
var err error
|
||||
rolledBack, err = count.EnqueueTx(ctx, tx, countPayload{Steps: 1})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return refused
|
||||
}); !errors.Is(err, refused) {
|
||||
t.Fatalf("WithTx = %v, want the refusal", err)
|
||||
}
|
||||
if _, err := r.Get(ctx, rolledBack); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("job of a rolled-back transaction: Get = %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
var id string
|
||||
if err := st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
var err error
|
||||
id, err = count.EnqueueTx(ctx, tx, countPayload{Steps: 2})
|
||||
return err
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count.Wake()
|
||||
if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded || string(job.Result) != `{"done":2}` {
|
||||
t.Errorf("job = %+v (result %s), want succeeded with {\"done\":2}", job, job.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobFailsWithFailureCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
@@ -569,6 +609,98 @@ func TestClaimErrorIsRetried(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalWriteErrorIsRetried(t *testing.T) {
|
||||
st := migratedStore(t)
|
||||
logs := &logBuffer{}
|
||||
r := newRunnerLogging(t, st, 1, logs)
|
||||
k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
if _, err := st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_end BEFORE UPDATE OF status ON jobs WHEN NEW.status IN ('succeeded', 'failed') BEGIN SELECT RAISE(ABORT, 'end refused'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start(t, r)
|
||||
id := enqueue(t, k, countPayload{Steps: 3})
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !strings.Contains(logs.String(), "record job success") {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("job success never recorded")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if _, err := st.Writer.ExecContext(t.Context(), `DROP TRIGGER refuse_end`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
job := waitFor(t, r, id, finished)
|
||||
if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
|
||||
t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
||||
}
|
||||
if out := logs.String(); !strings.Contains(out, "record job success failed; retrying") {
|
||||
t.Fatalf("final write error not logged as retried: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockedFinalWriteDoesNotHoldShutdown(t *testing.T) {
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 1)
|
||||
started, finish := make(chan struct{}), make(chan struct{})
|
||||
k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
|
||||
close(started)
|
||||
<-finish
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
stop := start(t, r)
|
||||
id := enqueue(t, k, countPayload{Steps: 3})
|
||||
<-started
|
||||
|
||||
// Hold the only writer connection, so the job's final write waits for it.
|
||||
conn, err := st.Writer.Conn(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waits := st.Writer.Stats().WaitCount
|
||||
close(finish)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for st.Writer.Stats().WaitCount == waits {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("final write never waited for the writer")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
stopped := make(chan struct{})
|
||||
go func() {
|
||||
stop()
|
||||
close(stopped)
|
||||
}()
|
||||
// The final write's attempt ends after 5 seconds; well past that, the
|
||||
// worker is waiting for the writer with no bound.
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(15 * time.Second):
|
||||
conn.Close()
|
||||
<-stopped
|
||||
t.Fatal("worker waited for a held writer through shutdown")
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if job := waitFor(t, r, id, func(Job) bool { return true }); job.Status != StatusRunning {
|
||||
t.Fatalf("after shutdown job = %+v, want left running", job)
|
||||
}
|
||||
second := newRunner(t, st, 1)
|
||||
register(t, second, "count", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
return countResult{}, nil
|
||||
})
|
||||
start(t, second)
|
||||
if job := waitFor(t, second, id, finished); job.Status != StatusFailed || job.Error == nil || *job.Error != CodeInterrupted {
|
||||
t.Errorf("interrupted job = %+v, want failed with %q", job, CodeInterrupted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerEnqueueByKindName(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
|
||||
@@ -564,9 +564,11 @@ func TestFetchCacheIsNotSharedWithCallers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchCanceledWaitHoldsNoSlot covers a burst of fetches whose
|
||||
// TestFetchCanceledWaitHoldsNoSlot covers a burst of requests whose
|
||||
// rate-limit waits end with their context: they must not push back the
|
||||
// next request that is allowed through.
|
||||
// next request that is allowed through. The burst calls waitTurn, the wait
|
||||
// turnConn's first Write makes, directly: through Fetch, net/http races the
|
||||
// failed write against its read loop, so the error it returns varies.
|
||||
func TestFetchCanceledWaitHoldsNoSlot(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -577,14 +579,24 @@ func TestFetchCanceledWaitHoldsNoSlot(t *testing.T) {
|
||||
if _, err := env.f.Fetch(t.Context(), "https://example.com/first"); err != nil {
|
||||
t.Fatalf("first Fetch: %v", err)
|
||||
}
|
||||
firstSent := clock.now()
|
||||
env.f.sleep = func(context.Context, time.Duration) error { return context.DeadlineExceeded }
|
||||
for i := range 5 {
|
||||
if _, err := env.f.Fetch(t.Context(), fmt.Sprintf("https://example.com/burst/%d", i)); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("burst Fetch %d error = %v, want context.DeadlineExceeded", i, err)
|
||||
// Each request comes later in the interval, so a slot it took
|
||||
// would delay the request after the burst.
|
||||
clock.advance(HostInterval / 10)
|
||||
if _, err := env.f.waitTurn(t.Context(), "example.com"); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("burst waitTurn %d error = %v, want context.DeadlineExceeded", i, err)
|
||||
}
|
||||
}
|
||||
env.f.mu.Lock()
|
||||
last := env.f.lastSent["example.com"]
|
||||
env.f.mu.Unlock()
|
||||
if !last.Equal(firstSent) {
|
||||
t.Errorf("host slot taken at %s after the burst, want the first send at %s", last, firstSent)
|
||||
}
|
||||
env.f.sleep = clock.sleep
|
||||
clock.advance(HostInterval)
|
||||
clock.advance(firstSent.Add(HostInterval).Sub(clock.now()))
|
||||
if _, err := env.f.Fetch(t.Context(), "https://example.com/after"); err != nil {
|
||||
t.Fatalf("Fetch after the burst: %v", err)
|
||||
}
|
||||
|
||||
+104
-34
@@ -25,6 +25,13 @@ var (
|
||||
// ErrTotalTimeNotPositive rejects a recipe whose page gives a total
|
||||
// time of zero minutes, which normalize_recipe refuses.
|
||||
ErrTotalTimeNotPositive = errors.New("recipes: recipe total time is not positive")
|
||||
// ErrSearchFailed wraps every failure to search: the search provider
|
||||
// could not be built (ErrSearchNotConfigured among them) or its search
|
||||
// failed.
|
||||
ErrSearchFailed = errors.New("recipes: recipe search failed")
|
||||
// ErrNormalizeFailed wraps a normalize_recipe failure that is not the
|
||||
// recipe's: the model provider could not be built or called.
|
||||
ErrNormalizeFailed = errors.New("recipes: normalize_recipe failed")
|
||||
)
|
||||
|
||||
// ErrUngroundable is returned when no recipe page could ground a dish or a
|
||||
@@ -118,43 +125,16 @@ func (g *Grounder) Ground(ctx context.Context, dishName, query string) (Grounded
|
||||
if strings.TrimSpace(dishName) == "" {
|
||||
return Grounded{}, errors.New("recipes: ground: dish name is empty")
|
||||
}
|
||||
provider, err := g.search.Provider(ctx)
|
||||
found, err := g.searchRanked(ctx, dishName, query)
|
||||
if err != nil {
|
||||
return Grounded{}, fmt.Errorf("recipes: ground %q: %w", dishName, err)
|
||||
}
|
||||
cands, err := provider.Search(ctx, query, RankDepth)
|
||||
if err != nil {
|
||||
return Grounded{}, fmt.Errorf("recipes: ground %q: search: %w", dishName, err)
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
return Grounded{}, &ErrUngroundable{Target: dishName, Reasons: []error{ErrNoCandidates}}
|
||||
}
|
||||
cands = cands[:min(len(cands), RankDepth)]
|
||||
stored := make(map[string]storedRecipe)
|
||||
for _, c := range cands {
|
||||
sr, ok, err := g.storedRecipe(ctx, c.URL)
|
||||
if err != nil {
|
||||
return Grounded{}, fmt.Errorf("recipes: ground %q: %w", dishName, err)
|
||||
}
|
||||
if ok {
|
||||
stored[c.URL] = sr
|
||||
}
|
||||
}
|
||||
ranked, rankErr := rank(ctx, dishName, cands, func(ctx context.Context, c Candidate) (Recipe, error) {
|
||||
if sr, ok := stored[c.URL]; ok {
|
||||
return sr.recipe, nil
|
||||
}
|
||||
return fetchRecipe(ctx, g.pages, c.URL)
|
||||
})
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Grounded{}, err
|
||||
}
|
||||
var reasons []error
|
||||
if rankErr != nil {
|
||||
reasons = append(reasons, rankErr)
|
||||
if found.rankErr != nil {
|
||||
reasons = append(reasons, found.rankErr)
|
||||
}
|
||||
for _, r := range ranked {
|
||||
if sr, ok := stored[r.Candidate.URL]; ok {
|
||||
for _, r := range found.ranked {
|
||||
if sr, ok := found.stored[r.Candidate.URL]; ok {
|
||||
return sr.grounded, nil
|
||||
}
|
||||
gr, reason, err := g.ground(ctx, r.Candidate.URL, r.Recipe)
|
||||
@@ -169,6 +149,96 @@ func (g *Grounder) Ground(ctx context.Context, dishName, query string) (Grounded
|
||||
return Grounded{}, &ErrUngroundable{Target: dishName, Reasons: reasons}
|
||||
}
|
||||
|
||||
// rankedSearch is a search's candidates ranked: the ranked ones best first,
|
||||
// those already stored by URL, and why any were not ranked.
|
||||
type rankedSearch struct {
|
||||
ranked []Ranked
|
||||
stored map[string]storedRecipe
|
||||
// rankErr is Rank's error, or ErrNoCandidates when the search found
|
||||
// nothing.
|
||||
rankErr error
|
||||
}
|
||||
|
||||
// searchRanked searches for query and ranks the top RankDepth candidates
|
||||
// for dishName as Ground describes. A search, database or context failure
|
||||
// is its error; candidates that do not rank are only rankErr's.
|
||||
func (g *Grounder) searchRanked(ctx context.Context, dishName, query string) (rankedSearch, error) {
|
||||
provider, err := g.search.Provider(ctx)
|
||||
if err != nil {
|
||||
return rankedSearch{}, fmt.Errorf("%w: %w", ErrSearchFailed, err)
|
||||
}
|
||||
cands, err := provider.Search(ctx, query, RankDepth)
|
||||
if err != nil {
|
||||
return rankedSearch{}, fmt.Errorf("%w: %w", ErrSearchFailed, err)
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
return rankedSearch{rankErr: ErrNoCandidates}, nil
|
||||
}
|
||||
cands = cands[:min(len(cands), RankDepth)]
|
||||
stored := make(map[string]storedRecipe)
|
||||
for _, c := range cands {
|
||||
sr, ok, err := g.storedRecipe(ctx, c.URL)
|
||||
if err != nil {
|
||||
return rankedSearch{}, err
|
||||
}
|
||||
if ok {
|
||||
stored[c.URL] = sr
|
||||
}
|
||||
}
|
||||
ranked, rankErr := rank(ctx, dishName, cands, func(ctx context.Context, c Candidate) (Recipe, error) {
|
||||
if sr, ok := stored[c.URL]; ok {
|
||||
return sr.recipe, nil
|
||||
}
|
||||
return fetchRecipe(ctx, g.pages, c.URL)
|
||||
})
|
||||
if err := ctx.Err(); err != nil {
|
||||
return rankedSearch{}, err
|
||||
}
|
||||
return rankedSearch{ranked: ranked, stored: stored, rankErr: rankErr}, nil
|
||||
}
|
||||
|
||||
// Offer is a ranked candidate offered for the household to ground a dish
|
||||
// in, with GroundURL on its URL.
|
||||
type Offer struct {
|
||||
URL string
|
||||
Title string
|
||||
SiteName string
|
||||
// PhotoID is the cached photo of a candidate already stored, "" for
|
||||
// one that is not: a candidate's photo is cached only when it grounds.
|
||||
PhotoID string
|
||||
// Minutes is a stored candidate's normalized minutes_total, else its
|
||||
// page's total time; nil when unknown.
|
||||
Minutes *int
|
||||
}
|
||||
|
||||
// Candidates searches for query and returns up to n of the candidates
|
||||
// Ground would try for dishName, best first. It fetches and extracts pages
|
||||
// but downloads no photo and runs no model. Candidates that hold no usable
|
||||
// recipe are not offered, so the list may be empty; a search that fails is
|
||||
// an error wrapping ErrSearchFailed.
|
||||
func (g *Grounder) Candidates(ctx context.Context, dishName, query string, n int) ([]Offer, error) {
|
||||
if strings.TrimSpace(dishName) == "" {
|
||||
return nil, errors.New("recipes: candidates: dish name is empty")
|
||||
}
|
||||
if n < 1 {
|
||||
return nil, fmt.Errorf("recipes: candidates: n is %d, want at least 1", n)
|
||||
}
|
||||
found, err := g.searchRanked(ctx, dishName, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recipes: candidates for %q: %w", dishName, err)
|
||||
}
|
||||
offers := make([]Offer, 0, min(n, len(found.ranked)))
|
||||
for _, r := range found.ranked[:min(n, len(found.ranked))] {
|
||||
o := Offer{URL: r.Candidate.URL, Title: r.Recipe.Title, SiteName: r.Recipe.SiteName, Minutes: r.Recipe.TotalMinutes}
|
||||
if sr, ok := found.stored[r.Candidate.URL]; ok {
|
||||
o.PhotoID = sr.grounded.PhotoID
|
||||
o.Minutes = sr.grounded.MinutesTotal
|
||||
}
|
||||
offers = append(offers, o)
|
||||
}
|
||||
return offers, nil
|
||||
}
|
||||
|
||||
// GroundURL grounds the recipe on the page at rawURL, a pasted URL, as
|
||||
// Ground does a candidate. An already stored rawURL is returned without
|
||||
// fetching it. A page that cannot be fetched, holds no usable recipe, or
|
||||
@@ -196,7 +266,7 @@ func (g *Grounder) GroundURL(ctx context.Context, rawURL string) (Grounded, erro
|
||||
}
|
||||
gr, reason, err := g.ground(ctx, rawURL, r)
|
||||
if err != nil {
|
||||
return Grounded{}, fmt.Errorf("recipes: ground %s: %w", rawURL, err)
|
||||
return Grounded{}, fmt.Errorf("recipes: ground pasted link: %w", err)
|
||||
}
|
||||
if reason != nil {
|
||||
return ungroundable(reason)
|
||||
@@ -344,7 +414,7 @@ func (g *Grounder) ground(ctx context.Context, sourceURL string, r Recipe) (gr G
|
||||
return Grounded{}, fmt.Errorf("normalize: %w", err), nil
|
||||
}
|
||||
if err != nil {
|
||||
return Grounded{}, nil, fmt.Errorf("normalize: %w", err)
|
||||
return Grounded{}, nil, fmt.Errorf("%w: %w", ErrNormalizeFailed, err)
|
||||
}
|
||||
if len(norm.Ingredients) != len(r.Ingredients) {
|
||||
return Grounded{}, nil, fmt.Errorf("normalize: %d ingredients for %d lines", len(norm.Ingredients), len(r.Ingredients))
|
||||
|
||||
@@ -516,3 +516,38 @@ func TestGroundManyLimitsConcurrency(t *testing.T) {
|
||||
t.Errorf("max in flight %d of %d calls, want %d of 5", search.maxActive, search.callCount(), GroundConcurrency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesOffersBestFirstUpToN(t *testing.T) {
|
||||
t.Parallel()
|
||||
var cands []Candidate
|
||||
pages := map[string][]byte{}
|
||||
for i, title := range []string{"Fish Tacos", "Fish Taco Bowls", "Baja Fish Tacos", "Tacos"} {
|
||||
u := fmt.Sprintf("https://site%d.example/tacos", i)
|
||||
cands = append(cands, Candidate{URL: u})
|
||||
pages[u] = groundPage(title, "/img/tacos.jpg")
|
||||
}
|
||||
search := &fakeSearch{results: map[string][]Candidate{"fish tacos": cands}}
|
||||
env := newGroundEnv(t, pages, nil, search)
|
||||
|
||||
got, err := env.g.Candidates(t.Context(), "Fish Tacos", "fish tacos", 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 3 || got[0].URL != cands[0].URL || got[0].Title != "Fish Tacos" || got[0].SiteName != "Example Kitchen" ||
|
||||
got[0].PhotoID != "" || got[0].Minutes == nil || *got[0].Minutes != 40 {
|
||||
t.Fatalf("Candidates = %+v, want 3 with Fish Tacos first", got)
|
||||
}
|
||||
if env.images.requests.Load() != 0 || len(env.llm.Requests()) != 0 {
|
||||
t.Errorf("Candidates downloaded a photo or ran the model")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesSearchFailureWrapsErrSearchFailed(t *testing.T) {
|
||||
t.Parallel()
|
||||
search := &fakeSearch{errs: map[string]error{"stew": ErrSearchRateLimited}}
|
||||
env := newGroundEnv(t, nil, nil, search)
|
||||
_, err := env.g.Candidates(t.Context(), "Stew", "stew", 3)
|
||||
if !errors.Is(err, ErrSearchFailed) || !errors.Is(err, ErrSearchRateLimited) {
|
||||
t.Errorf("err = %v, want ErrSearchFailed wrapping ErrSearchRateLimited", err)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-10
@@ -65,22 +65,28 @@ var photoDecoders = map[string]func([]byte) (image.Image, error){
|
||||
"webp": func(b []byte) (image.Image, error) { return webp.Decode(bytes.NewReader(b)) },
|
||||
}
|
||||
|
||||
// PhotoCache downloads photos through a Fetcher and stores each at every
|
||||
// ImageFetcher fetches an image; *Fetcher satisfies it.
|
||||
type ImageFetcher interface {
|
||||
FetchImage(ctx context.Context, rawURL string) (Page, error)
|
||||
}
|
||||
|
||||
// PhotoCache downloads photos through an ImageFetcher and stores each at every
|
||||
// PhotoSizes length as {id}-{size}.jpg under PhotoDir, with a photos row.
|
||||
// Photos are deduplicated by the SHA-256 of the downloaded bytes. A
|
||||
// PhotoCache is safe for concurrent use.
|
||||
type PhotoCache struct {
|
||||
fetcher *Fetcher
|
||||
store *store.Store
|
||||
images ImageFetcher
|
||||
store *store.Store
|
||||
// dir is the absolute photo directory.
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewPhotoCache returns a PhotoCache storing photos under dataDir, creating
|
||||
// the photo directory if it is absent.
|
||||
func NewPhotoCache(fetcher *Fetcher, st *store.Store, dataDir string) (*PhotoCache, error) {
|
||||
if fetcher == nil {
|
||||
return nil, errors.New("recipes: nil fetcher")
|
||||
// NewPhotoCache returns a PhotoCache downloading images with images and
|
||||
// storing photos under dataDir, creating the photo directory if it is
|
||||
// absent.
|
||||
func NewPhotoCache(images ImageFetcher, st *store.Store, dataDir string) (*PhotoCache, error) {
|
||||
if images == nil {
|
||||
return nil, errors.New("recipes: nil image fetcher")
|
||||
}
|
||||
if st == nil {
|
||||
return nil, errors.New("recipes: nil store")
|
||||
@@ -92,7 +98,7 @@ func NewPhotoCache(fetcher *Fetcher, st *store.Store, dataDir string) (*PhotoCac
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("recipes: create photo directory: %w", err)
|
||||
}
|
||||
return &PhotoCache{fetcher: fetcher, store: st, dir: dir}, nil
|
||||
return &PhotoCache{images: images, store: st, dir: dir}, nil
|
||||
}
|
||||
|
||||
// DownloadedPhoto is an image DownloadPhoto has downloaded and decoded,
|
||||
@@ -108,7 +114,7 @@ type DownloadedPhoto struct {
|
||||
// whose shorter side is under PhotoMinShortSide. It records nothing, so a
|
||||
// caller can try another image before opening a transaction.
|
||||
func (c *PhotoCache) DownloadPhoto(ctx context.Context, rawURL string) (DownloadedPhoto, error) {
|
||||
page, err := c.fetcher.FetchImage(ctx, rawURL)
|
||||
page, err := c.images.FetchImage(ctx, rawURL)
|
||||
if err != nil {
|
||||
return DownloadedPhoto{}, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Package recipestest provides fakes for tests that ground dinners outside
|
||||
// package recipes: a search, page and image fetchers serving fixtures, and
|
||||
// a Grounder over them that normalizes through the real harness and
|
||||
// normalize_recipe skill with a scripted model.
|
||||
package recipestest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Search is a recipes.SearchSource and recipes.SearchProvider answering
|
||||
// each query from Results. Err, when set, fails every search. Gate, when
|
||||
// set, makes every search wait for it to close.
|
||||
type Search struct {
|
||||
Results map[string][]recipes.Candidate
|
||||
Err error
|
||||
Gate chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
queries []string
|
||||
}
|
||||
|
||||
func (s *Search) Provider(context.Context) (recipes.SearchProvider, error) { return s, nil }
|
||||
|
||||
func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Candidate, error) {
|
||||
s.mu.Lock()
|
||||
s.queries = append(s.queries, query)
|
||||
s.mu.Unlock()
|
||||
if s.Gate != nil {
|
||||
select {
|
||||
case <-s.Gate:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
if s.Err != nil {
|
||||
return nil, s.Err
|
||||
}
|
||||
c := s.Results[query]
|
||||
return c[:min(n, len(c))], nil
|
||||
}
|
||||
|
||||
// Queries returns every query searched, in order.
|
||||
func (s *Search) Queries() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string(nil), s.queries...)
|
||||
}
|
||||
|
||||
// Pages is a recipes.PageFetcher serving Bodies by URL; any other URL is a
|
||||
// 404. Gate, when set, makes every fetch wait for it to close.
|
||||
type Pages struct {
|
||||
Bodies map[string][]byte
|
||||
Gate chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
fetched []string
|
||||
}
|
||||
|
||||
func (p *Pages) Fetch(ctx context.Context, rawURL string) (recipes.Page, error) {
|
||||
p.mu.Lock()
|
||||
p.fetched = append(p.fetched, rawURL)
|
||||
p.mu.Unlock()
|
||||
if p.Gate != nil {
|
||||
select {
|
||||
case <-p.Gate:
|
||||
case <-ctx.Done():
|
||||
return recipes.Page{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
body, ok := p.Bodies[rawURL]
|
||||
if !ok {
|
||||
return recipes.Page{}, &recipes.StatusError{URL: rawURL, Status: 404}
|
||||
}
|
||||
return recipes.Page{URL: rawURL, ContentType: "text/html", Body: body}, nil
|
||||
}
|
||||
|
||||
// Fetched returns every URL fetched, in order.
|
||||
func (p *Pages) Fetched() []string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return append([]string(nil), p.fetched...)
|
||||
}
|
||||
|
||||
// Images is a recipes.ImageFetcher serving JPEGs by URL; any other URL is
|
||||
// a 404.
|
||||
type Images struct {
|
||||
JPEGs map[string][]byte
|
||||
}
|
||||
|
||||
func (i *Images) FetchImage(_ context.Context, rawURL string) (recipes.Page, error) {
|
||||
body, ok := i.JPEGs[rawURL]
|
||||
if !ok {
|
||||
return recipes.Page{}, &recipes.StatusError{URL: rawURL, Status: 404}
|
||||
}
|
||||
return recipes.Page{URL: rawURL, ContentType: "image/jpeg", Body: body}, nil
|
||||
}
|
||||
|
||||
// JPEG is a w×h JPEG whose pixels depend on seed, so different seeds give
|
||||
// different photos.
|
||||
func JPEG(t testing.TB, w, h int, seed uint8) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
img.Set(x, y, color.RGBA{uint8(x), uint8(y), seed, 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// RecipePage is a page whose schema.org Recipe is title, from site, with
|
||||
// two ingredients, two steps, a total time of minutes and the image at
|
||||
// imageURL.
|
||||
func RecipePage(title, site, imageURL string, minutes int) []byte {
|
||||
return fmt.Appendf(nil, `<!DOCTYPE html><html><head>
|
||||
<meta property="og:site_name" content=%q>
|
||||
<script type="application/ld+json">{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Recipe",
|
||||
"name": %q,
|
||||
"image": %q,
|
||||
"recipeIngredient": ["1 onion", "2 cloves garlic, minced"],
|
||||
"recipeInstructions": ["Chop the onion.", "Fry with the garlic."],
|
||||
"totalTime": "PT%dM"
|
||||
}</script></head><body></body></html>`, site, title, imageURL, minutes)
|
||||
}
|
||||
|
||||
// NormalizedReply is a usable normalize_recipe reply for a RecipePage with
|
||||
// method and minutes.
|
||||
func NormalizedReply(method string, minutes int) llm.GenerateResult {
|
||||
js := fmt.Sprintf(`{"ingredients":[
|
||||
{"name":"onion","qty":"1","unit":"","note":""},
|
||||
{"name":"garlic","qty":"2","unit":"cloves","note":"minced"}
|
||||
],"method":%q,"difficulty":null,"minutes_total":%d}`, method, minutes)
|
||||
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
|
||||
}
|
||||
|
||||
// NewGrounder returns a Grounder storing in st, with photos cached under
|
||||
// dataDir, normalizing through the real harness with model.
|
||||
func NewGrounder(t testing.TB, st *store.Store, dataDir string, search recipes.SearchSource, pages recipes.PageFetcher, images recipes.ImageFetcher, model llm.Provider) *recipes.Grounder {
|
||||
t.Helper()
|
||||
photos, err := recipes.NewPhotoCache(images, st, dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test"},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return model, nil },
|
||||
Store: st,
|
||||
Guard: guard,
|
||||
}
|
||||
normalize := func(ctx context.Context, scope string, in skills.RecipeText) (skills.NormalizedRecipe, error) {
|
||||
return skills.NormalizeRecipe(ctx, cfg, scope, in)
|
||||
}
|
||||
g, err := recipes.NewGrounder(st, search, pages, photos, normalize)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
@@ -30,10 +30,15 @@ var ErrSearchNotConfigured = errors.New("recipes: recipe search is not configure
|
||||
type SearchSettings struct {
|
||||
// Provider is "searxng" or "brave", nil until first chosen.
|
||||
Provider *string `json:"provider"`
|
||||
// BaseURL is the SearXNG instance's URL, nil when not set.
|
||||
BaseURL *string `json:"base_url"`
|
||||
APIKeySet bool `json:"api_key_set"`
|
||||
APIKeyLast4 *string `json:"api_key_last4"`
|
||||
// BaseURL is the saved SearXNG instance's URL, nil when not set.
|
||||
BaseURL *string `json:"base_url"`
|
||||
// DefaultBaseURL is the deployment's SearXNG URL
|
||||
// (MEALQUEST_SEARXNG_URL), for the client to suggest while BaseURL is
|
||||
// not set. It is never saved by itself: only a PATCH of base_url saves
|
||||
// a URL. Nil when the deployment names none.
|
||||
DefaultBaseURL *string `json:"default_base_url"`
|
||||
APIKeySet bool `json:"api_key_set"`
|
||||
APIKeyLast4 *string `json:"api_key_last4"`
|
||||
// UpdatedAt is nil until the settings are first saved.
|
||||
UpdatedAt *string `json:"updated_at"`
|
||||
}
|
||||
@@ -75,32 +80,49 @@ func (e *SearchSettingsError) Error() string {
|
||||
type SearchSettingsService struct {
|
||||
st *store.Store
|
||||
box *secretbox.Box
|
||||
// defaultBaseURL is SearchSettings.DefaultBaseURL, "" for none.
|
||||
defaultBaseURL string
|
||||
// newProvider is NewSearchProvider; tests point Brave elsewhere.
|
||||
newProvider func(db.RecipeSearchSetting, *secretbox.Box) (SearchProvider, error)
|
||||
}
|
||||
|
||||
// NewSearchSettingsService returns the service over st, sealing keys with
|
||||
// box.
|
||||
func NewSearchSettingsService(st *store.Store, box *secretbox.Box) (*SearchSettingsService, error) {
|
||||
// box. defaultBaseURL is the deployment's SearXNG URL, "" for none; a set
|
||||
// one must be a valid SearXNG URL.
|
||||
func NewSearchSettingsService(st *store.Store, box *secretbox.Box, defaultBaseURL string) (*SearchSettingsService, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("recipes: nil store")
|
||||
}
|
||||
if box == nil {
|
||||
return nil, errors.New("recipes: nil secret box")
|
||||
}
|
||||
return &SearchSettingsService{st: st, box: box, newProvider: NewSearchProvider}, nil
|
||||
if defaultBaseURL != "" {
|
||||
if _, err := NewSearXNG(defaultBaseURL); err != nil {
|
||||
return nil, fmt.Errorf("recipes: default SearXNG URL: %s", strings.TrimPrefix(err.Error(), "recipes: "))
|
||||
}
|
||||
}
|
||||
return &SearchSettingsService{st: st, box: box, defaultBaseURL: defaultBaseURL, newProvider: NewSearchProvider}, nil
|
||||
}
|
||||
|
||||
// Get returns the settings; before any are saved every field is unset.
|
||||
// Get returns the settings; before any are saved every field but
|
||||
// DefaultBaseURL is unset.
|
||||
func (s *SearchSettingsService) Get(ctx context.Context) (SearchSettings, error) {
|
||||
row, err := db.New(s.st.Reader).GetRecipeSearchSettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return SearchSettings{}, nil
|
||||
return SearchSettings{DefaultBaseURL: s.defaultBase()}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return SearchSettings{}, fmt.Errorf("recipes: read recipe search settings: %w", err)
|
||||
}
|
||||
return searchSettingsFrom(row), nil
|
||||
return s.settingsFrom(row), nil
|
||||
}
|
||||
|
||||
func (s *SearchSettingsService) defaultBase() *string {
|
||||
if s.defaultBaseURL == "" {
|
||||
return nil
|
||||
}
|
||||
u := s.defaultBaseURL
|
||||
return &u
|
||||
}
|
||||
|
||||
// Update applies patch and returns the settings it leaves. The first update
|
||||
@@ -141,7 +163,7 @@ func (s *SearchSettingsService) Update(ctx context.Context, patch SearchSettings
|
||||
if err != nil {
|
||||
return SearchSettings{}, err
|
||||
}
|
||||
return searchSettingsFrom(saved), nil
|
||||
return s.settingsFrom(saved), nil
|
||||
}
|
||||
|
||||
// apply returns row with patch applied, validated.
|
||||
@@ -226,14 +248,15 @@ func (s *SearchSettingsService) TestSearch(ctx context.Context) (int, error) {
|
||||
return len(cands), nil
|
||||
}
|
||||
|
||||
func searchSettingsFrom(row db.RecipeSearchSetting) SearchSettings {
|
||||
func (s *SearchSettingsService) settingsFrom(row db.RecipeSearchSetting) SearchSettings {
|
||||
provider, updated := row.Provider, row.UpdatedAt
|
||||
return SearchSettings{
|
||||
Provider: &provider,
|
||||
BaseURL: nullString(row.BaseUrl),
|
||||
APIKeySet: len(row.ApiKeyCt) > 0,
|
||||
APIKeyLast4: nullString(row.ApiKeyLast4),
|
||||
UpdatedAt: &updated,
|
||||
Provider: &provider,
|
||||
BaseURL: nullString(row.BaseUrl),
|
||||
DefaultBaseURL: s.defaultBase(),
|
||||
APIKeySet: len(row.ApiKeyCt) > 0,
|
||||
APIKeyLast4: nullString(row.ApiKeyLast4),
|
||||
UpdatedAt: &updated,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,13 @@ import (
|
||||
const testBraveKey = "BSA-secret-brave-key-9f3c"
|
||||
|
||||
func newTestSearchSettings(t *testing.T) *SearchSettingsService {
|
||||
t.Helper()
|
||||
return newTestSearchSettingsWithDefault(t, "")
|
||||
}
|
||||
|
||||
// newTestSearchSettingsWithDefault is newTestSearchSettings with
|
||||
// defaultBaseURL as the deployment's SearXNG URL.
|
||||
func newTestSearchSettingsWithDefault(t *testing.T, defaultBaseURL string) *SearchSettingsService {
|
||||
t.Helper()
|
||||
st, err := store.Open(t.Context(), t.TempDir())
|
||||
if err != nil {
|
||||
@@ -38,7 +45,7 @@ func newTestSearchSettings(t *testing.T) *SearchSettingsService {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := NewSearchSettingsService(st, box)
|
||||
svc, err := NewSearchSettingsService(st, box, defaultBaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -124,6 +131,74 @@ func TestSearchSettingsUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchSettingsDefaultBaseURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := newTestSearchSettingsWithDefault(t, "http://searxng:8080")
|
||||
got, err := svc.Get(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" || got.BaseURL != nil || got.Provider != nil {
|
||||
t.Errorf("Get before any update = %+v, want only default_base_url set", got)
|
||||
}
|
||||
if _, err := svc.Provider(t.Context()); !errors.Is(err, ErrSearchNotConfigured) {
|
||||
t.Errorf("Provider with only a default error = %v, want ErrSearchNotConfigured", err)
|
||||
}
|
||||
|
||||
// The default is a suggestion: SearXNG still needs a saved URL.
|
||||
if _, err := svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng")}); err == nil {
|
||||
t.Error("choosing SearXNG without a URL succeeded with a default set")
|
||||
}
|
||||
|
||||
got, err = svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng"), BaseURL: ptr("https://search.example")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *got.BaseURL != "https://search.example" || got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" {
|
||||
t.Errorf("Update result = %+v, want the saved URL beside the default", got)
|
||||
}
|
||||
got, err = svc.Get(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *got.BaseURL != "https://search.example" {
|
||||
t.Errorf("Get base_url = %q, want the saved URL, not the default", *got.BaseURL)
|
||||
}
|
||||
row, err := db.New(svc.st.Reader).GetRecipeSearchSettings(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.BaseUrl.String != "https://search.example" {
|
||||
t.Errorf("stored base_url = %q", row.BaseUrl.String)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSearchSettingsServiceRejectsBadDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, err := store.Open(t.Context(), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, u := range []string{"searxng:8080", "ftp://searxng", "http://user:pw@searxng:8080"} {
|
||||
if _, err := NewSearchSettingsService(st, box, u); err == nil {
|
||||
t.Errorf("NewSearchSettingsService(%q) succeeded", u)
|
||||
} else if strings.Contains(err.Error(), "pw") {
|
||||
t.Errorf("error for %q echoes the URL: %v", u, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchSettingsTestSearch(t *testing.T) {
|
||||
t.Parallel()
|
||||
searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// apiKeyBody is the PUT /settings/ai/api-key body. An empty api_key removes
|
||||
// the stored key.
|
||||
type apiKeyBody struct {
|
||||
APIKey *string `json:"api_key"`
|
||||
}
|
||||
|
||||
// modelsResponse is the GET /settings/ai/models body.
|
||||
type modelsResponse struct {
|
||||
Models []aisettings.Model `json:"models"`
|
||||
}
|
||||
|
||||
// No response from these handlers holds the API key: the settings carry
|
||||
// only its last four characters, and provider errors and model lists are
|
||||
// redacted by package aisettings.
|
||||
|
||||
func getAISettings(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := svc.Get(r.Context())
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "get AI settings", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
func patchAISettings(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
patch, err := decodePatch[aisettings.Patch](http.MaxBytesReader(w, r.Body, maxAISettingsBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
writeAISettings(w, r, logger, "update AI settings")(svc.Update(r.Context(), patch))
|
||||
}
|
||||
}
|
||||
|
||||
// putAIKey takes the API key in api_key; the response, like GET's, holds
|
||||
// only its last four characters.
|
||||
func putAIKey(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := decodePatch[apiKeyBody](http.MaxBytesReader(w, r.Body, maxSettingsBody))
|
||||
if err == nil && body.APIKey == nil {
|
||||
err = errors.New("api_key: is required")
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
writeAISettings(w, r, logger, "store AI API key")(svc.SetAPIKey(r.Context(), *body.APIKey))
|
||||
}
|
||||
}
|
||||
|
||||
func postAIResetPrompt(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
writeAISettings(w, r, logger, "reset AI system prompt")(svc.ResetPrompt(r.Context()))
|
||||
}
|
||||
}
|
||||
|
||||
// postAIVerify checks the stored key with the provider and returns the
|
||||
// settings with key_verified_at, or the failure (see writeAIFailure).
|
||||
func postAIVerify(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := svc.Verify(r.Context())
|
||||
if err != nil {
|
||||
writeAIFailure(w, r, logger, "verify AI key", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
// getAIModels returns the provider's live model list, or the failure (see
|
||||
// writeAIFailure).
|
||||
func getAIModels(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
models, err := svc.Models(r.Context())
|
||||
if err != nil {
|
||||
writeAIFailure(w, r, logger, "list AI models", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, modelsResponse{Models: models})
|
||||
}
|
||||
}
|
||||
|
||||
// writeAISettings returns a func writing a Service result: the settings,
|
||||
// 400 invalid_settings for a *aisettings.ValidationError, or 500.
|
||||
func writeAISettings(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string) func(aisettings.Settings, error) {
|
||||
return func(settings aisettings.Settings, err error) {
|
||||
var invalid *aisettings.ValidationError
|
||||
switch {
|
||||
case errors.As(err, &invalid):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_settings", invalid.Error())
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), action, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
default:
|
||||
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeAIFailure writes a Verify or Models error: 409 not_configured when
|
||||
// the settings cannot build a provider, 409 settings_changed when they
|
||||
// changed during verification, and for a failed provider call a 502 (504
|
||||
// for a timeout) whose code names the llm typed error: auth_failed,
|
||||
// rate_limited, timeout, bad_output or provider_error. Rate limits and
|
||||
// timeouts are retryable. Anything else is 500.
|
||||
func writeAIFailure(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||
switch {
|
||||
case errors.Is(err, aisettings.ErrNotConfigured):
|
||||
writeError(w, r, logger, http.StatusConflict, "not_configured", err.Error())
|
||||
case errors.Is(err, aisettings.ErrChanged):
|
||||
writeError(w, r, logger, http.StatusConflict, "settings_changed", err.Error())
|
||||
case errors.Is(err, aisettings.ErrProviderFailed):
|
||||
logger.WarnContext(r.Context(), action+" failed", "error", err)
|
||||
status, e := http.StatusBadGateway, apiError{Code: "provider_error", Message: err.Error()}
|
||||
switch {
|
||||
case errors.Is(err, llm.ErrAuth):
|
||||
e.Code = "auth_failed"
|
||||
case errors.Is(err, llm.ErrRateLimited):
|
||||
e.Code, e.Retryable = "rate_limited", true
|
||||
case errors.Is(err, llm.ErrTimeout):
|
||||
status, e.Code, e.Retryable = http.StatusGatewayTimeout, "timeout", true
|
||||
case errors.Is(err, llm.ErrBadOutput):
|
||||
e.Code = "bad_output"
|
||||
}
|
||||
writeAPIError(w, r, logger, status, e)
|
||||
default:
|
||||
logger.ErrorContext(r.Context(), action, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
)
|
||||
|
||||
const (
|
||||
aiPath = "/api/v1/settings/ai"
|
||||
aiKeyPath = "/api/v1/settings/ai/api-key"
|
||||
aiVerifyPath = "/api/v1/settings/ai/verify"
|
||||
aiModelsPath = "/api/v1/settings/ai/models"
|
||||
aiResetPromptPath = "/api/v1/settings/ai/reset-prompt"
|
||||
testAIKey = "sk-test-secret-ai-key-7d21"
|
||||
testWrongAIKey = "sk-test-wrong-ai-key-0b9e"
|
||||
)
|
||||
|
||||
// newAIRouter returns a router with a fresh, seeded AI settings service.
|
||||
func newAIRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t),
|
||||
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// newModelsServer is an OpenAI-compatible server whose GET /models accepts
|
||||
// only testAIKey. While echo is set it answers 500 with a body quoting the
|
||||
// Authorization header it received, so a response that passed the vendor
|
||||
// body through unredacted would leak the key. Its model list holds a model
|
||||
// whose ID and name quote the key.
|
||||
func newModelsServer(t *testing.T, echo *atomic.Bool) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
switch {
|
||||
case r.URL.Path != "/v1/models":
|
||||
http.NotFound(w, r)
|
||||
case echo.Load():
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
io.WriteString(w, `{"error":{"message":"upstream failed for `+auth+`"}}`)
|
||||
case auth != "Bearer "+testAIKey:
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
io.WriteString(w, `{"error":{"message":"invalid key `+auth+`"}}`)
|
||||
default:
|
||||
io.WriteString(w, `{"data":[{"id":"gpt-test"},{"id":"gpt-test-mini","name":"GPT Test Mini"},`+
|
||||
`{"id":"gpt-`+testAIKey+`","name":"Leaky `+testAIKey+`"}]}`)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func errorCode(body map[string]any) any {
|
||||
e, _ := body["error"].(map[string]any)
|
||||
return e["code"]
|
||||
}
|
||||
|
||||
// TestAISettingsAPI drives every /settings/ai route and scans every
|
||||
// response body for the API keys.
|
||||
func TestAISettingsAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newAIRouter(t)
|
||||
var echo atomic.Bool
|
||||
vendor := newModelsServer(t, &echo)
|
||||
var bodies []string
|
||||
call := func(method, target, body string) (*httptest.ResponseRecorder, map[string]any) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != "" {
|
||||
r = strings.NewReader(body)
|
||||
}
|
||||
rec, decoded := serve(t, router, signedIn(method, target, r))
|
||||
bodies = append(bodies, rec.Body.String())
|
||||
return rec, decoded
|
||||
}
|
||||
|
||||
// The seed: anthropic, the default persona, no key.
|
||||
rec, body := call(http.MethodGet, aiPath, "")
|
||||
if rec.Code != http.StatusOK || body["provider"] != "anthropic" || body["system_prompt"] != household.DefaultPersonaPrompt ||
|
||||
body["temperature"] != 0.7 || body["api_key_last4"] != nil || body["key_verified_at"] != nil {
|
||||
t.Errorf("GET seed = %d %v", rec.Code, body)
|
||||
}
|
||||
for _, field := range []string{"provider", "base_url", "model", "temperature", "system_prompt", "api_key_last4", "key_verified_at"} {
|
||||
if _, ok := body[field]; !ok {
|
||||
t.Errorf("GET has no %s field: %v", field, body)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{aiVerifyPath, aiModelsPath} {
|
||||
method := http.MethodPost
|
||||
if path == aiModelsPath {
|
||||
method = http.MethodGet
|
||||
}
|
||||
if rec, body := call(method, path, ""); rec.Code != http.StatusConflict || errorCode(body) != "not_configured" {
|
||||
t.Errorf("%s %s without a key = %d %v, want 409 not_configured", method, path, rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
for _, bad := range []string{
|
||||
`{}`,
|
||||
`{"provider":"google"}`,
|
||||
`{"provider":"openai_compatible","base_url":"not a url"}`,
|
||||
`{"base_url":"https://evil.example"}`,
|
||||
`{"temperature":1.1}`,
|
||||
`{"temperature":-0.1}`,
|
||||
`{"temperature":0.25}`,
|
||||
`{"temperature":"0.5"}`,
|
||||
`{"model":""}`,
|
||||
`{"model":null}`,
|
||||
`{"system_prompt":"` + strings.Repeat("é", 8001) + `"}`,
|
||||
`{"system_prompt":"` + strings.Repeat("€", 8001) + `"}`,
|
||||
`{"system_prompt":"` + strings.Repeat("a", maxAISettingsBody) + `"}`,
|
||||
`{"api_key":"` + testAIKey + `"}`,
|
||||
} {
|
||||
if rec, body := call(http.MethodPatch, aiPath, bad); rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("PATCH %.80s = %d %v, want 400", bad, rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// The longest prompt fits the body limit however its 8,000 characters
|
||||
// are written: three-byte, four-byte, or as \u-escaped surrogate pairs.
|
||||
for _, char := range []string{"€", "🍲", `\ud83c\udf72`} {
|
||||
rec, body := call(http.MethodPatch, aiPath, `{"system_prompt":"`+strings.Repeat(char, 8000)+`"}`)
|
||||
if prompt, _ := body["system_prompt"].(string); rec.Code != http.StatusOK || utf8.RuneCountInString(prompt) != 8000 {
|
||||
t.Errorf("PATCH 8,000 × %s = %d, %d characters", char, rec.Code, utf8.RuneCountInString(prompt))
|
||||
}
|
||||
}
|
||||
|
||||
rec, body = call(http.MethodPatch, aiPath, `{"provider":"openai_compatible","base_url":"`+vendor.URL+`/v1","model":"gpt-test","temperature":0.3,"system_prompt":"`+strings.Repeat("é", 8000)+`"}`)
|
||||
if rec.Code != http.StatusOK || body["provider"] != "openai_compatible" || body["base_url"] != vendor.URL+"/v1" ||
|
||||
body["model"] != "gpt-test" || body["temperature"] != 0.3 {
|
||||
t.Errorf("PATCH openai_compatible = %d %v", rec.Code, body)
|
||||
}
|
||||
|
||||
for _, bad := range []string{`{}`, `{"api_key":"short"}`, `{"api_key":"sk-line\nbreak"}`, `{"api_key":null}`, `{"key":"` + testAIKey + `"}`} {
|
||||
if rec, body := call(http.MethodPut, aiKeyPath, bad); rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("PUT api-key %s = %d %v, want 400", bad, rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// A rejected key is a typed auth failure and records nothing.
|
||||
rec, body = call(http.MethodPut, aiKeyPath, `{"api_key":"`+testWrongAIKey+`"}`)
|
||||
if rec.Code != http.StatusOK || body["api_key_last4"] != "0b9e" || body["key_verified_at"] != nil {
|
||||
t.Errorf("PUT wrong key = %d %v", rec.Code, body)
|
||||
}
|
||||
if rec, body := call(http.MethodPost, aiVerifyPath, ""); rec.Code != http.StatusBadGateway || errorCode(body) != "auth_failed" {
|
||||
t.Errorf("POST verify with the wrong key = %d %v, want 502 auth_failed", rec.Code, body)
|
||||
}
|
||||
if rec, body := call(http.MethodGet, aiModelsPath, ""); rec.Code != http.StatusBadGateway || errorCode(body) != "auth_failed" {
|
||||
t.Errorf("GET models with the wrong key = %d %v, want 502 auth_failed", rec.Code, body)
|
||||
}
|
||||
|
||||
rec, body = call(http.MethodPut, aiKeyPath, `{"api_key":"`+testAIKey+`"}`)
|
||||
if rec.Code != http.StatusOK || body["api_key_last4"] != "7d21" {
|
||||
t.Errorf("PUT key = %d %v", rec.Code, body)
|
||||
}
|
||||
if _, ok := body["api_key"]; ok {
|
||||
t.Errorf("PUT response has an api_key field: %v", body)
|
||||
}
|
||||
|
||||
// The vendor quoting the key in an error body: the body excerpt
|
||||
// reaches the response redacted.
|
||||
echo.Store(true)
|
||||
for _, c := range []struct{ method, path string }{{http.MethodPost, aiVerifyPath}, {http.MethodGet, aiModelsPath}} {
|
||||
rec, body := call(c.method, c.path, "")
|
||||
if rec.Code != http.StatusBadGateway || errorCode(body) != "provider_error" || !strings.Contains(rec.Body.String(), "upstream failed for Bearer [REDACTED]") {
|
||||
t.Errorf("%s %s against an echoing vendor = %d %v, want 502 provider_error", c.method, c.path, rec.Code, body)
|
||||
}
|
||||
}
|
||||
echo.Store(false)
|
||||
|
||||
rec, body = call(http.MethodPost, aiVerifyPath, "")
|
||||
if rec.Code != http.StatusOK || body["key_verified_at"] == nil || body["api_key_last4"] != "7d21" {
|
||||
t.Errorf("POST verify = %d %v", rec.Code, body)
|
||||
}
|
||||
rec, body = call(http.MethodGet, aiPath, "")
|
||||
if rec.Code != http.StatusOK || body["key_verified_at"] == nil || body["model"] != "gpt-test" || body["temperature"] != 0.3 {
|
||||
t.Errorf("GET after verify = %d %v", rec.Code, body)
|
||||
}
|
||||
|
||||
rec, body = call(http.MethodGet, aiModelsPath, "")
|
||||
models, _ := body["models"].([]any)
|
||||
if rec.Code != http.StatusOK || len(models) != 3 {
|
||||
t.Fatalf("GET models = %d %v", rec.Code, body)
|
||||
}
|
||||
if m := models[1].(map[string]any); m["id"] != "gpt-test-mini" || m["display_name"] != "GPT Test Mini" {
|
||||
t.Errorf("GET models[1] = %v", m)
|
||||
}
|
||||
if m := models[2].(map[string]any); m["id"] != "gpt-[REDACTED]" || m["display_name"] != "Leaky [REDACTED]" {
|
||||
t.Errorf("GET models[2] = %v, want the key redacted", m)
|
||||
}
|
||||
|
||||
rec, body = call(http.MethodPatch, aiPath, `{"system_prompt":"Be brief."}`)
|
||||
if rec.Code != http.StatusOK || body["system_prompt"] != "Be brief." || body["key_verified_at"] == nil {
|
||||
t.Errorf("PATCH system_prompt = %d %v", rec.Code, body)
|
||||
}
|
||||
rec, body = call(http.MethodPost, aiResetPromptPath, "")
|
||||
if rec.Code != http.StatusOK || body["system_prompt"] != household.DefaultPersonaPrompt {
|
||||
t.Errorf("POST reset-prompt = %d %v", rec.Code, body)
|
||||
}
|
||||
|
||||
// The stored key is never pointed at another server or provider.
|
||||
for _, patch := range []string{`{"base_url":"https://attacker.example/v1"}`, `{"provider":"openai","base_url":""}`} {
|
||||
if rec, body := call(http.MethodPatch, aiPath, patch); rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_settings" {
|
||||
t.Errorf("PATCH %s with a key stored = %d %v, want 400 invalid_settings", patch, rec.Code, body)
|
||||
}
|
||||
}
|
||||
if rec, body := call(http.MethodPut, aiKeyPath, `{"api_key":""}`); rec.Code != http.StatusOK || body["api_key_last4"] != nil || body["key_verified_at"] != nil {
|
||||
t.Errorf("PUT empty key = %d %v", rec.Code, body)
|
||||
}
|
||||
rec, body = call(http.MethodPatch, aiPath, `{"base_url":"https://llm.example/v1"}`)
|
||||
if rec.Code != http.StatusOK || body["base_url"] != "https://llm.example/v1" {
|
||||
t.Errorf("PATCH base_url without a key = %d %v", rec.Code, body)
|
||||
}
|
||||
|
||||
if len(bodies) < 30 {
|
||||
t.Fatalf("scanned only %d response bodies", len(bodies))
|
||||
}
|
||||
for _, b := range bodies {
|
||||
for _, key := range []string{testAIKey, testWrongAIKey} {
|
||||
if strings.Contains(b, key) {
|
||||
t.Errorf("response holds an API key: %s", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAISettingsWritesNeedCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newAIRouter(t)
|
||||
for _, c := range []struct{ method, path, body string }{
|
||||
{http.MethodPatch, aiPath, `{"temperature":0.5}`},
|
||||
{http.MethodPut, aiKeyPath, `{"api_key":"` + testAIKey + `"}`},
|
||||
{http.MethodPost, aiVerifyPath, ""},
|
||||
{http.MethodPost, aiResetPromptPath, ""},
|
||||
} {
|
||||
req := httptest.NewRequest(c.method, c.path, strings.NewReader(c.body))
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("%s %s without the CSRF header = %d, want 403", c.method, c.path, rec.Code)
|
||||
}
|
||||
}
|
||||
rec, body := serve(t, router, signedIn(http.MethodGet, aiPath, nil))
|
||||
if rec.Code != http.StatusOK || body["api_key_last4"] != nil || body["temperature"] != 0.7 {
|
||||
t.Errorf("GET after refused writes = %d %v, want the seed", rec.Code, body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
)
|
||||
|
||||
// maxDinnerBody bounds a POST or PATCH /dinners request body.
|
||||
const maxDinnerBody = 16 << 10
|
||||
|
||||
// maxGroundBody bounds a POST /dinners/{id}/ground body, which may carry a
|
||||
// manual recipe of dinners.MaxManualLines lines of each kind.
|
||||
const maxGroundBody = 512 << 10
|
||||
|
||||
// dinnerCreate is the POST /dinners body: a name, with or without a
|
||||
// recipe_url.
|
||||
type dinnerCreate struct {
|
||||
Name *string `json:"name"`
|
||||
RecipeURL *string `json:"recipe_url"`
|
||||
}
|
||||
|
||||
// dinnerPatch is the PATCH /dinners/{id} body. Every field is optional;
|
||||
// unknown fields and null values are refused.
|
||||
type dinnerPatch struct {
|
||||
Name *string `json:"name"`
|
||||
Method *string `json:"method"`
|
||||
MinutesTotal *int `json:"minutes_total"`
|
||||
Difficulty *string `json:"difficulty"`
|
||||
}
|
||||
|
||||
type dinnerList struct {
|
||||
Dinners []dinners.Dinner `json:"dinners"`
|
||||
}
|
||||
|
||||
// dinnerGround is the POST /dinners/{id}/ground body: exactly one of
|
||||
// candidate_id, recipe_url and manual.
|
||||
type dinnerGround struct {
|
||||
CandidateID *string `json:"candidate_id"`
|
||||
RecipeURL *string `json:"recipe_url"`
|
||||
Manual *dinnerManualRecipe `json:"manual"`
|
||||
}
|
||||
|
||||
type dinnerManualRecipe struct {
|
||||
Ingredients []string `json:"ingredients"`
|
||||
Steps []string `json:"steps"`
|
||||
Minutes *int `json:"minutes"`
|
||||
Method *string `json:"method"`
|
||||
}
|
||||
|
||||
type candidateList struct {
|
||||
Candidates []dinners.Candidate `json:"candidates"`
|
||||
}
|
||||
|
||||
// getDinners answers GET /dinners?status=regular|quest_log.
|
||||
func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := svc.List(r.Context(), r.URL.Query().Get("status"))
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "list dinners", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, dinnerList{Dinners: list})
|
||||
}
|
||||
}
|
||||
|
||||
// postDinner adds a Regular, recording the session's actor as updated_by,
|
||||
// and answers 201 with it and its grounding_job_id without waiting for the
|
||||
// job. When the job cannot be enqueued it still answers 201, with the dinner
|
||||
// grounding_status failed and grounding_job_id null.
|
||||
func postDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := decodePatch[dinnerCreate](http.MaxBytesReader(w, r.Body, maxDinnerBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
d, err := svc.CreateRegular(r.Context(), dinners.NewRegular(body), actor.Subject)
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "create dinner", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusCreated, d)
|
||||
}
|
||||
}
|
||||
|
||||
// patchDinner edits a dinner, recording the session's actor as updated_by.
|
||||
func patchDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
patch, err := decodePatch[dinnerPatch](http.MaxBytesReader(w, r.Body, maxDinnerBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
d, err := svc.Update(r.Context(), chi.URLParam(r, "id"), dinners.Patch(patch), actor.Subject)
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "update dinner", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, d)
|
||||
}
|
||||
}
|
||||
|
||||
// getDinnerCandidates answers GET /dinners/{id}/recipe-candidates with up
|
||||
// to dinners.MaxCandidates recipe pages for the dinner's name.
|
||||
func getDinnerCandidates(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := svc.Candidates(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "list recipe candidates", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, candidateList{Candidates: list})
|
||||
}
|
||||
}
|
||||
|
||||
// postDinnerGround grounds a dinner by hand in a candidate, a pasted link
|
||||
// or a recipe typed in, recording the session's actor as updated_by.
|
||||
func postDinnerGround(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := decodePatch[dinnerGround](http.MaxBytesReader(w, r.Body, maxGroundBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
fix := dinners.Fix{CandidateID: body.CandidateID, RecipeURL: body.RecipeURL}
|
||||
if body.Manual != nil {
|
||||
m := dinners.ManualRecipe(*body.Manual)
|
||||
fix.Manual = &m
|
||||
}
|
||||
d, err := svc.Ground(r.Context(), chi.URLParam(r, "id"), fix, actor.Subject)
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "ground dinner", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, d)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteDinner archives a dinner; the row is never deleted.
|
||||
func deleteDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d, err := svc.Archive(r.Context(), chi.URLParam(r, "id"), actor.Subject)
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "archive dinner", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, d)
|
||||
}
|
||||
}
|
||||
|
||||
// postDinnerFavorite toggles a dinner's is_favorite.
|
||||
func postDinnerFavorite(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d, err := svc.ToggleFavorite(r.Context(), chi.URLParam(r, "id"), actor.Subject)
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "toggle dinner favorite", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, d)
|
||||
}
|
||||
}
|
||||
|
||||
// actorOrFail returns the session's actor, or answers 500 when the request
|
||||
// has none, which behind requireSession is a wiring bug.
|
||||
func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (auth.Actor, bool) {
|
||||
actor, ok := auth.ActorFrom(r.Context())
|
||||
if !ok {
|
||||
logger.ErrorContext(r.Context(), r.Method+" "+r.URL.Path+" without a session")
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
return actor, ok
|
||||
}
|
||||
|
||||
// writeDinnerError maps a dinners.Service error to its API error. A page
|
||||
// that cannot ground the dinner is 422 ungroundable; recipe search not set
|
||||
// up is 409 not_configured; a search or model failure is 502; grounding by
|
||||
// hand that runs past dinners.GroundTimeout is 504 ground_timeout; grounding
|
||||
// an archived dinner is 409 dinner_archived and one changed while grounding
|
||||
// 409 ground_conflict. Any other grounding failure, by candidate, link or
|
||||
// manual recipe, wraps dinners.ErrGroundFailed and is 500, logged by reason
|
||||
// and dinner id only.
|
||||
func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||
var invalid *dinners.ValidationError
|
||||
var dup *dinners.DuplicateError
|
||||
var ungroundable *recipes.ErrUngroundable
|
||||
switch {
|
||||
case errors.As(err, &ungroundable):
|
||||
// The reasons quote dependency errors, so they are neither returned
|
||||
// nor logged.
|
||||
writeError(w, r, logger, http.StatusUnprocessableEntity, "ungroundable", "no usable recipe was found at that link")
|
||||
case errors.Is(err, recipes.ErrSearchNotConfigured):
|
||||
writeError(w, r, logger, http.StatusConflict, "not_configured", "recipe search is not configured")
|
||||
case errors.Is(err, recipes.ErrSearchFailed):
|
||||
// The error may quote the search provider's response, so only the
|
||||
// reason is logged.
|
||||
logger.WarnContext(r.Context(), action, "reason", "search_failed")
|
||||
writeError(w, r, logger, http.StatusBadGateway, "search_failed", "recipe search failed")
|
||||
case errors.Is(err, recipes.ErrNormalizeFailed):
|
||||
logger.WarnContext(r.Context(), action, "reason", "normalize_recipe failed")
|
||||
writeError(w, r, logger, http.StatusBadGateway, "ai_failed", "the AI provider failed to read the recipe")
|
||||
case errors.Is(err, dinners.ErrGroundFailed):
|
||||
// The error may quote the pasted link, which can carry a personal
|
||||
// token, and dependency errors, so only the reason and dinner are
|
||||
// logged.
|
||||
logger.ErrorContext(r.Context(), action, "reason", "ground_failed", "dinner_id", chi.URLParam(r, "id"))
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
case errors.Is(err, dinners.ErrArchived):
|
||||
writeError(w, r, logger, http.StatusConflict, "dinner_archived", "the dinner is archived")
|
||||
case errors.Is(err, dinners.ErrGroundConflict):
|
||||
writeError(w, r, logger, http.StatusConflict, "ground_conflict", "the dinner changed while its recipe was grounding; reload and try again")
|
||||
case errors.Is(err, dinners.ErrGroundTimeout):
|
||||
logger.WarnContext(r.Context(), action, "reason", "ground_timeout")
|
||||
writeError(w, r, logger, http.StatusGatewayTimeout, "ground_timeout", "grounding the recipe took too long; try again")
|
||||
case errors.As(err, &invalid):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_dinner", invalid.Error())
|
||||
case errors.As(err, &dup):
|
||||
writeError(w, r, logger, http.StatusConflict, "duplicate_dinner", dup.Error())
|
||||
case errors.Is(err, dinners.ErrNotFound):
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
|
||||
default:
|
||||
logger.ErrorContext(r.Context(), action, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
const dinnersPath = "/api/v1/dinners"
|
||||
|
||||
// newDinnersRouter returns a router whose dinners service runs over the
|
||||
// returned store. Its job runner is not started, so grounding jobs stay
|
||||
// queued.
|
||||
func newDinnersRouter(t *testing.T) (http.Handler, *store.Store) {
|
||||
t.Helper()
|
||||
e := newDinnersEnv(t)
|
||||
return e.h, e.st
|
||||
}
|
||||
|
||||
// dinnersEnv is a router whose dinners service, job runner and grounder
|
||||
// share one store, grounding with fake search, pages, images and model.
|
||||
type dinnersEnv struct {
|
||||
h http.Handler
|
||||
st *store.Store
|
||||
runner *jobs.Runner
|
||||
search *recipestest.Search
|
||||
pages *recipestest.Pages
|
||||
images *recipestest.Images
|
||||
model *llm.Fake
|
||||
// logs holds everything the router and services log.
|
||||
logs *logBuffer
|
||||
}
|
||||
|
||||
// logBuffer is a bytes.Buffer safe for concurrent log writes.
|
||||
type logBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *logBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *logBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
func newDinnersEnv(t *testing.T) *dinnersEnv {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
e := &dinnersEnv{
|
||||
st: st,
|
||||
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
||||
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
|
||||
images: &recipestest.Images{JPEGs: map[string][]byte{}},
|
||||
model: &llm.Fake{},
|
||||
logs: &logBuffer{},
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(e.logs, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.runner = runner
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model)
|
||||
svc, err := dinners.NewService(st, g, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
svc, newTestSuggest(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// start starts the env's job runner, stopping it when the test ends.
|
||||
func (e *dinnersEnv) start(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := e.runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
e.runner.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
// callDinners sends a signed-in request with body (none when empty).
|
||||
func callDinners(t *testing.T, h http.Handler, method, target, body string) (*httptest.ResponseRecorder, map[string]any) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != "" {
|
||||
r = strings.NewReader(body)
|
||||
}
|
||||
return serve(t, h, signedIn(method, target, r))
|
||||
}
|
||||
|
||||
// createDinner adds a Regular and returns its id.
|
||||
func createDinner(t *testing.T, h http.Handler, body string) string {
|
||||
t.Helper()
|
||||
rec, got := callDinners(t, h, http.MethodPost, dinnersPath, body)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST %s = %d: %s", body, rec.Code, rec.Body)
|
||||
}
|
||||
return got["id"].(string)
|
||||
}
|
||||
|
||||
// listNames returns the names GET /dinners?status=status lists, in order.
|
||||
func listNames(t *testing.T, h http.Handler, status string) []string {
|
||||
t.Helper()
|
||||
rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"?status="+status, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET status=%s = %d: %s", status, rec.Code, rec.Body)
|
||||
}
|
||||
list, ok := body["dinners"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("GET status=%s has no dinners list: %v", status, body)
|
||||
}
|
||||
names := make([]string, len(list))
|
||||
for i, d := range list {
|
||||
names[i] = d.(map[string]any)["name"].(string)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func TestCreateDinnerByName(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":" Chicken Tikka Masala "}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
||||
}
|
||||
for k, v := range map[string]any{
|
||||
"name": "Chicken Tikka Masala",
|
||||
"status": "regular",
|
||||
"origin": "regular",
|
||||
"grounding_status": "pending",
|
||||
"is_favorite": false,
|
||||
"photo_url": nil,
|
||||
"method": nil,
|
||||
"minutes_total": nil,
|
||||
"difficulty": nil,
|
||||
"updated_by": testSession.Subject,
|
||||
} {
|
||||
if got, ok := body[k]; !ok || got != v {
|
||||
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
|
||||
}
|
||||
}
|
||||
if got, ok := body["recipe_url"]; ok {
|
||||
t.Errorf("recipe_url = %#v, want no recipe_url in a dinner response", got)
|
||||
}
|
||||
var normalized, updatedBy string
|
||||
if err := st.Reader.QueryRow(`SELECT normalized_name, updated_by FROM dinners WHERE id = ?`, body["id"]).Scan(&normalized, &updatedBy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if normalized != "chicken tikka masala" || updatedBy != testSession.Subject {
|
||||
t.Errorf("stored normalized_name, updated_by = %q, %q", normalized, updatedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDinnerByURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, _ := newDinnersRouter(t)
|
||||
|
||||
link := "https://www.example.com/recipes/12345/beef-and-broccoli-stir-fry/"
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Stir fry night","recipe_url":"`+link+`"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
||||
}
|
||||
for k, v := range map[string]any{
|
||||
"name": "Stir fry night",
|
||||
"status": "regular",
|
||||
"origin": "regular",
|
||||
"grounding_status": "pending",
|
||||
"updated_by": testSession.Subject,
|
||||
} {
|
||||
if body[k] != v {
|
||||
t.Errorf("%s = %#v, want %#v", k, body[k], v)
|
||||
}
|
||||
}
|
||||
// The link stays on the server for grounding; responses never carry it.
|
||||
if got, ok := body["recipe_url"]; ok {
|
||||
t.Errorf("recipe_url = %#v, want no recipe_url in a dinner response", got)
|
||||
}
|
||||
|
||||
// The same link again is a duplicate even though its name would differ.
|
||||
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Something else entirely"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("unrelated name = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Beef and broccoli","recipe_url":"`+link+`"}`)
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
|
||||
t.Errorf("same link again = %d %v, want 409 duplicate_dinner", rec.Code, body)
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ name, body string }{
|
||||
{"plain http", `{"name":"Chili","recipe_url":"http://example.com/recipes/chili"}`},
|
||||
{"not a URL", `{"name":"Chili","recipe_url":"chili"}`},
|
||||
{"credentials", `{"name":"Chili","recipe_url":"https://me:pw@example.com/recipes/chili"}`},
|
||||
{"link without a name", `{"recipe_url":"https://example.com/recipes/chili"}`},
|
||||
{"filler name with a link", `{"name":"The Best Easy Recipe","recipe_url":"https://example.com/recipes/chili"}`},
|
||||
{"neither field", `{}`},
|
||||
} {
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, tc.body)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_dinner" {
|
||||
t.Errorf("%s: %d %v, want 400 invalid_dinner", tc.name, rec.Code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDinnerResponsesHideRecipeURL: a pasted link may carry a personal
|
||||
// token, so neither the create response nor a later list read shows any
|
||||
// part of it, while the stored link still awaits grounding.
|
||||
func TestDinnerResponsesHideRecipeURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
|
||||
const link = "https://c.example/recipes/stew?access_token=personal-token-abc123"
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Stew","recipe_url":"`+link+`"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
||||
}
|
||||
created := rec.Body.String()
|
||||
rec, _ = callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET status=regular = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
for _, resp := range []struct{ name, body string }{{"create", created}, {"list", rec.Body.String()}} {
|
||||
for _, secret := range []string{link, "c.example", "access_token", "personal-token-abc123", "recipe_url"} {
|
||||
if strings.Contains(resp.body, secret) {
|
||||
t.Errorf("%s response contains %q: %s", resp.name, secret, resp.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var stored string
|
||||
if err := st.Reader.QueryRow(`SELECT recipe_url FROM dinners WHERE id = ?`, body["id"]).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored != link {
|
||||
t.Errorf("stored recipe_url = %q, want %q kept for grounding", stored, link)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDinnerResponsesHideTokenInLinkPath: a personal token can sit
|
||||
// anywhere in a link's path, even as plain letters, so a dinner added from
|
||||
// a link is named only by the household and neither the create response, a
|
||||
// later list read nor the duplicate error for the same link shows any of
|
||||
// the link.
|
||||
func TestDinnerResponsesHideTokenInLinkPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, _ := newDinnersRouter(t)
|
||||
|
||||
const link = "https://c.example/recipes/stew-key-abcdefghijklmno"
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Sunday stew","recipe_url":"`+link+`"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if body["name"] != "Sunday stew" {
|
||||
t.Errorf("name = %v, want Sunday stew", body["name"])
|
||||
}
|
||||
created := rec.Body.String()
|
||||
rec, _ = callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET status=regular = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
listed := rec.Body.String()
|
||||
rec, _ = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Beef stew","recipe_url":"`+link+`"}`)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("same link again = %d, want 409: %s", rec.Code, rec.Body)
|
||||
}
|
||||
for _, resp := range []struct{ name, body string }{{"create", created}, {"list", listed}, {"duplicate", rec.Body.String()}} {
|
||||
lower := strings.ToLower(resp.body)
|
||||
for _, secret := range []string{"c.example", "recipes", "key", "abcdefghijklmno"} {
|
||||
if strings.Contains(lower, secret) {
|
||||
t.Errorf("%s response contains %q: %s", resp.name, secret, resp.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDinnerDuplicate(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
createDinner(t, router, `{"name":"Chicken Tikka Masala"}`)
|
||||
|
||||
for _, name := range []string{
|
||||
"chicken tikka masala",
|
||||
"Easy Chicken Tikka-Masala!",
|
||||
"The Best Homemade Chicken Tikka Masala Recipe",
|
||||
} {
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"`+name+`"}`)
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
|
||||
t.Errorf("%q: %d %v, want 409 duplicate_dinner", name, rec.Code, body)
|
||||
}
|
||||
}
|
||||
// A name repeating the dish is a duplicate with a link too.
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Chicken tikka masala","recipe_url":"https://example.com/other-curry.html"}`)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Errorf("named duplicate with a link = %d %v, want 409", rec.Code, body)
|
||||
}
|
||||
|
||||
var n int
|
||||
if err := st.Reader.QueryRow(`SELECT count(*) FROM dinners`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("dinners rows = %d, want 1", n)
|
||||
}
|
||||
|
||||
// A different dish sharing words is not a duplicate.
|
||||
createDinner(t, router, `{"name":"Chicken Tikka Wraps"}`)
|
||||
}
|
||||
|
||||
func TestCreateDinnerRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, _ := newDinnersRouter(t)
|
||||
for _, tc := range []struct{ name, body, code string }{
|
||||
{"empty name", `{"name":" "}`, "invalid_dinner"},
|
||||
{"filler only", `{"name":"The Best Easy Recipe"}`, "invalid_dinner"},
|
||||
{"too long", `{"name":"` + strings.Repeat("x", dinners.MaxNameChars+1) + `"}`, "invalid_dinner"},
|
||||
{"null name", `{"name":null}`, "invalid_request"},
|
||||
{"unknown field", `{"name":"Chili","status":"quest_log"}`, "invalid_request"},
|
||||
{"not an object", `"Chili"`, "invalid_request"},
|
||||
} {
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, tc.body)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code {
|
||||
t.Errorf("%s: %d %v, want 400 %s", tc.name, rec.Code, body, tc.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDinnersFiltersByStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
tacos := createDinner(t, router, `{"name":"Tacos"}`)
|
||||
createDinner(t, router, `{"name":"Lasagna"}`)
|
||||
pho := createDinner(t, router, `{"name":"Pho"}`)
|
||||
gone := createDinner(t, router, `{"name":"Meatloaf"}`)
|
||||
|
||||
ctx := context.Background()
|
||||
if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, pho); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.Writer.ExecContext(ctx,
|
||||
`INSERT INTO photos (id, source_url, local_path, width, height, sha256, fetched_at, created_at, updated_at)
|
||||
VALUES ('photo1', 'https://example.com/tacos.jpg', 'media/photos/photo1', 1200, 800, 'abc', 't', 't', 't')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET photo_id = 'photo1', grounding_status = 'grounded' WHERE id = ?`, tacos); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec, _ := callDinners(t, router, http.MethodDelete, dinnersPath+"/"+gone, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("DELETE = %d", rec.Code)
|
||||
}
|
||||
|
||||
if got := strings.Join(listNames(t, router, "regular"), ","); got != "Lasagna,Tacos" {
|
||||
t.Errorf("regular = %s, want Lasagna,Tacos", got)
|
||||
}
|
||||
if got := strings.Join(listNames(t, router, "quest_log"), ","); got != "Pho" {
|
||||
t.Errorf("quest_log = %s, want Pho", got)
|
||||
}
|
||||
|
||||
_, body := callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
|
||||
for _, d := range body["dinners"].([]any) {
|
||||
d := d.(map[string]any)
|
||||
switch d["name"] {
|
||||
case "Tacos":
|
||||
if d["photo_url"] != "/media/photos/photo1-400.jpg" || d["grounding_status"] != "grounded" {
|
||||
t.Errorf("Tacos photo_url, grounding_status = %v, %v", d["photo_url"], d["grounding_status"])
|
||||
}
|
||||
case "Lasagna":
|
||||
if d["photo_url"] != nil || d["grounding_status"] != "pending" {
|
||||
t.Errorf("Lasagna photo_url, grounding_status = %v, %v", d["photo_url"], d["grounding_status"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An empty list is [] rather than null.
|
||||
empty, _ := newDinnersRouter(t)
|
||||
rec, _ := callDinners(t, empty, http.MethodGet, dinnersPath+"?status=regular", "")
|
||||
if got := rec.Body.String(); got != `{"dinners":[]}` {
|
||||
t.Errorf("empty list = %s", got)
|
||||
}
|
||||
|
||||
for _, q := range []string{"", "?status=archived", "?status=suggested", "?status=bogus"} {
|
||||
rec, body := callDinners(t, router, http.MethodGet, dinnersPath+q, "")
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_dinner" {
|
||||
t.Errorf("GET %q = %d %v, want 400 invalid_dinner", q, rec.Code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchDinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
id := createDinner(t, router, `{"name":"Chili"}`)
|
||||
createDinner(t, router, `{"name":"Pot Roast"}`)
|
||||
if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id,
|
||||
`{"name":"White Chicken Chili","method":"crockpot","minutes_total":480,"difficulty":"easy"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
for k, v := range map[string]any{
|
||||
"name": "White Chicken Chili",
|
||||
"method": "crockpot",
|
||||
"minutes_total": float64(480),
|
||||
"difficulty": "easy",
|
||||
"status": "regular",
|
||||
"updated_by": testSession.Subject,
|
||||
} {
|
||||
if body[k] != v {
|
||||
t.Errorf("%s = %#v, want %#v", k, body[k], v)
|
||||
}
|
||||
}
|
||||
var normalized string
|
||||
if err := st.Reader.QueryRow(`SELECT normalized_name FROM dinners WHERE id = ?`, id).Scan(&normalized); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if normalized != "white chicken chili" {
|
||||
t.Errorf("normalized_name = %q", normalized)
|
||||
}
|
||||
|
||||
// A partial patch leaves the other fields.
|
||||
rec, body = callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"difficulty":"medium"}`)
|
||||
if rec.Code != http.StatusOK || body["method"] != "crockpot" || body["difficulty"] != "medium" || body["name"] != "White Chicken Chili" {
|
||||
t.Errorf("partial patch = %d %v", rec.Code, body)
|
||||
}
|
||||
// Renaming to its own name, respelled, is not a duplicate of itself.
|
||||
if rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"name":"Easy white chicken chili"}`); rec.Code != http.StatusOK {
|
||||
t.Errorf("self rename = %d %v", rec.Code, body)
|
||||
}
|
||||
// Renaming onto another dinner is.
|
||||
rec, body = callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"name":"The Best Pot Roast"}`)
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
|
||||
t.Errorf("rename onto Pot Roast = %d %v, want 409", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchDinnerValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, _ := newDinnersRouter(t)
|
||||
id := createDinner(t, router, `{"name":"Chili"}`)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, body, code, in string
|
||||
}{
|
||||
{"bad method", `{"method":"microwave"}`, "invalid_dinner", "method"},
|
||||
{"crock pot spelling", `{"method":"crock pot"}`, "invalid_dinner", "method"},
|
||||
{"bad difficulty", `{"difficulty":"expert"}`, "invalid_dinner", "difficulty"},
|
||||
{"zero minutes", `{"minutes_total":0}`, "invalid_dinner", "minutes_total"},
|
||||
{"negative minutes", `{"minutes_total":-5}`, "invalid_dinner", "minutes_total"},
|
||||
{"too many minutes", `{"minutes_total":1441}`, "invalid_dinner", "minutes_total"},
|
||||
{"empty name", `{"name":""}`, "invalid_dinner", "name"},
|
||||
{"every problem", `{"method":"x","difficulty":"y","minutes_total":0}`, "invalid_dinner", "method: must be one of stovetop, oven, crockpot, grill, other; minutes_total"},
|
||||
{"empty patch", `{}`, "invalid_dinner", "no fields"},
|
||||
{"fractional minutes", `{"minutes_total":30.5}`, "invalid_request", "minutes_total"},
|
||||
{"string minutes", `{"minutes_total":"30"}`, "invalid_request", "minutes_total"},
|
||||
{"null method", `{"method":null}`, "invalid_request", "method"},
|
||||
{"read-only field", `{"status":"archived"}`, "invalid_request", "status"},
|
||||
{"favorite field", `{"is_favorite":true}`, "invalid_request", "is_favorite"},
|
||||
} {
|
||||
rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, tc.body)
|
||||
msg, _ := body["error"].(map[string]any)["message"].(string)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code || !strings.Contains(msg, tc.in) {
|
||||
t.Errorf("%s: %d %v, want 400 %s containing %q", tc.name, rec.Code, body, tc.code, tc.in)
|
||||
}
|
||||
}
|
||||
|
||||
_, body := callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
|
||||
d := body["dinners"].([]any)[0].(map[string]any)
|
||||
if d["method"] != nil || d["difficulty"] != nil || d["minutes_total"] != nil || d["name"] != "Chili" {
|
||||
t.Errorf("a rejected patch changed the dinner: %v", d)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/nope", `{"method":"oven"}`)
|
||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
||||
t.Errorf("unknown id = %d %v, want 404", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDinnerArchives(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
id := createDinner(t, router, `{"name":"Shepherd's Pie"}`)
|
||||
if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, router, http.MethodDelete, dinnersPath+"/"+id, "")
|
||||
if rec.Code != http.StatusOK || body["status"] != "archived" || body["updated_by"] != testSession.Subject {
|
||||
t.Fatalf("DELETE = %d %v", rec.Code, body)
|
||||
}
|
||||
var status, name, updatedBy string
|
||||
if err := st.Reader.QueryRow(`SELECT status, name, updated_by FROM dinners WHERE id = ?`, id).Scan(&status, &name, &updatedBy); err != nil {
|
||||
t.Fatalf("archived row is gone: %v", err)
|
||||
}
|
||||
if status != "archived" || name != "Shepherd's Pie" || updatedBy != testSession.Subject {
|
||||
t.Errorf("row = %s %q %s", status, name, updatedBy)
|
||||
}
|
||||
if got := listNames(t, router, "regular"); len(got) != 0 {
|
||||
t.Errorf("regular after archive = %v", got)
|
||||
}
|
||||
// An archived name may be added again.
|
||||
createDinner(t, router, `{"name":"Shepherds Pie"}`)
|
||||
|
||||
rec, body = callDinners(t, router, http.MethodDelete, dinnersPath+"/nope", "")
|
||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
||||
t.Errorf("unknown id = %d %v, want 404", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavoriteDinnerToggles(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
id := createDinner(t, router, `{"name":"Fish Tacos"}`)
|
||||
if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, want := range []bool{true, false, true} {
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath+"/"+id+"/favorite", "")
|
||||
if rec.Code != http.StatusOK || body["is_favorite"] != want || body["updated_by"] != testSession.Subject {
|
||||
t.Fatalf("favorite = %d %v, want is_favorite %v", rec.Code, body, want)
|
||||
}
|
||||
}
|
||||
var fav int
|
||||
if err := st.Reader.QueryRow(`SELECT is_favorite FROM dinners WHERE id = ?`, id).Scan(&fav); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fav != 1 {
|
||||
t.Errorf("stored is_favorite = %d, want 1", fav)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath+"/nope/favorite", "")
|
||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
||||
t.Errorf("unknown id = %d %v, want 404", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDinnerWritesRequireCSRF sends every dinners write without the CSRF
|
||||
// header and with a wrong one; each is refused and changes nothing.
|
||||
func TestDinnerWritesRequireCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
id := createDinner(t, router, `{"name":"Chili"}`)
|
||||
|
||||
var before string
|
||||
snapshot := `SELECT group_concat(id || name || status || is_favorite || coalesce(method, '') || updated_at, '|') FROM dinners`
|
||||
if err := st.Reader.QueryRow(snapshot).Scan(&before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
writes := []struct{ method, target, body string }{
|
||||
{http.MethodPost, dinnersPath, `{"name":"Lasagna"}`},
|
||||
{http.MethodPatch, dinnersPath + "/" + id, `{"method":"oven"}`},
|
||||
{http.MethodDelete, dinnersPath + "/" + id, ""},
|
||||
{http.MethodPost, dinnersPath + "/" + id + "/favorite", ""},
|
||||
}
|
||||
for _, w := range writes {
|
||||
for _, header := range []*string{nil, ptr("wrong-token")} {
|
||||
req := httptest.NewRequest(w.method, w.target, strings.NewReader(w.body))
|
||||
req.AddCookie(validSessionCookie)
|
||||
if header != nil {
|
||||
req.Header.Set(CSRFHeader, *header)
|
||||
}
|
||||
rec, body := serve(t, router, req)
|
||||
if rec.Code != http.StatusForbidden || errorCode(body) != "csrf" {
|
||||
t.Errorf("%s %s (header %v) = %d %v, want 403 csrf", w.method, w.target, header != nil, rec.Code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var after string
|
||||
if err := st.Reader.QueryRow(snapshot).Scan(&after); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after != before {
|
||||
t.Errorf("refused writes changed dinners:\n before %s\n after %s", before, after)
|
||||
}
|
||||
|
||||
// Without a session, the list is 401 too.
|
||||
req := httptest.NewRequest(http.MethodGet, dinnersPath+"?status=regular", nil)
|
||||
if rec, body := serve(t, router, req); rec.Code != http.StatusUnauthorized || errorCode(body) != "unauthenticated" {
|
||||
t.Errorf("GET without a session = %d %v, want 401", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
@@ -0,0 +1,412 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
)
|
||||
|
||||
// Fixture pages: two curries found by searching "Chicken curry" and a stew
|
||||
// at a pasted link.
|
||||
const (
|
||||
curryPage = "https://a.example/chicken-curry"
|
||||
curry2Page = "https://b.example/curry"
|
||||
stewPage = "https://c.example/recipes/beef-stew"
|
||||
)
|
||||
|
||||
// withRecipes adds the fixture pages, their photos and the search for
|
||||
// "Chicken curry" to e.
|
||||
func (e *dinnersEnv) withRecipes(t *testing.T) *dinnersEnv {
|
||||
t.Helper()
|
||||
e.search.Results["Chicken curry"] = []recipes.Candidate{{URL: curryPage}, {URL: curry2Page}}
|
||||
e.pages.Bodies[curryPage] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
|
||||
e.pages.Bodies[curry2Page] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25)
|
||||
e.pages.Bodies[stewPage] = recipestest.RecipePage("Beef Stew", "Stew Co", "https://c.example/stew.jpg", 150)
|
||||
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
|
||||
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
|
||||
e.images.JPEGs["https://c.example/stew.jpg"] = recipestest.JPEG(t, 400, 320, 3)
|
||||
return e
|
||||
}
|
||||
|
||||
// waitJob polls GET /jobs/{id} until the job ends and returns its body.
|
||||
func waitJob(t *testing.T, h http.Handler, id string) map[string]any {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
rec, body := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET job = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if s := body["status"]; s == "succeeded" || s == "failed" {
|
||||
return body
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job %s never finished; last %v", id, body)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// listedDinner returns dinner id as GET /dinners?status=regular lists it.
|
||||
func listedDinner(t *testing.T, h http.Handler, id string) map[string]any {
|
||||
t.Helper()
|
||||
rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"?status=regular", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET dinners = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
for _, d := range body["dinners"].([]any) {
|
||||
if d := d.(map[string]any); d["id"] == id {
|
||||
return d
|
||||
}
|
||||
}
|
||||
t.Fatalf("dinner %s not listed", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPostDinnerReturnsBeforeGroundingCompletes(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
e.search.Gate = make(chan struct{})
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
e.start(t)
|
||||
|
||||
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
jobID, _ := created["grounding_job_id"].(string)
|
||||
if created["grounding_status"] != "pending" || created["grounding_error"] != nil || jobID == "" {
|
||||
t.Fatalf("created = %v, want pending with a grounding_job_id", created)
|
||||
}
|
||||
// The search is held, so grounding cannot have completed.
|
||||
_, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+jobID, "")
|
||||
if s := job["status"]; s != "queued" && s != "running" {
|
||||
t.Fatalf("job right after POST = %v, want queued or running", job)
|
||||
}
|
||||
if d := listedDinner(t, e.h, created["id"].(string)); d["grounding_status"] != "pending" {
|
||||
t.Fatalf("dinner right after POST = %v, want pending", d)
|
||||
}
|
||||
|
||||
close(e.search.Gate)
|
||||
job = waitJob(t, e.h, jobID)
|
||||
if job["status"] != "succeeded" || job["kind"] != "ground_dinner" {
|
||||
t.Fatalf("job = %v, want ground_dinner succeeded", job)
|
||||
}
|
||||
d := listedDinner(t, e.h, created["id"].(string))
|
||||
photo, _ := d["photo_url"].(string)
|
||||
if d["grounding_status"] != "grounded" || d["recipe_id"] == nil || !strings.HasSuffix(photo, "-400.jpg") ||
|
||||
d["method"] != "stovetop" || d["minutes_total"] != 40.0 || d["difficulty"] != "easy" || d["grounding_error"] != nil {
|
||||
t.Errorf("dinner = %v, want grounded with recipe, photo, method, minutes and difficulty", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostDinnerGroundingFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
e.start(t)
|
||||
|
||||
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Nothing matches this"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
job := waitJob(t, e.h, created["grounding_job_id"].(string))
|
||||
if job["status"] != "failed" || job["error"] != "no_recipe_found" {
|
||||
t.Errorf("job = %v, want failed with no_recipe_found", job)
|
||||
}
|
||||
d := listedDinner(t, e.h, created["id"].(string))
|
||||
if d["grounding_status"] != "failed" || d["grounding_error"] != "no_recipe_found" || d["recipe_id"] != nil {
|
||||
t.Errorf("dinner = %v, want failed with no_recipe_found", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRecipeCandidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
id := createDinner(t, e.h, `{"name":"Chicken curry"}`) // runner not started: stays pending
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
list, _ := body["candidates"].([]any)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("candidates = %v, want 2", body)
|
||||
}
|
||||
first := list[0].(map[string]any)
|
||||
want := map[string]any{
|
||||
"candidate_id": curryPage,
|
||||
"title": "Chicken Curry",
|
||||
"site_name": "Curry House",
|
||||
"photo_url": nil,
|
||||
"minutes": 40.0,
|
||||
}
|
||||
if len(first) != len(want) {
|
||||
t.Errorf("candidate keys = %v, want exactly %v", first, want)
|
||||
}
|
||||
for k, v := range want {
|
||||
if got, ok := first[k]; !ok || got != v {
|
||||
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
|
||||
}
|
||||
}
|
||||
|
||||
rec, body = callDinners(t, e.h, http.MethodGet, dinnersPath+"/nope/recipe-candidates", "")
|
||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
||||
t.Errorf("unknown dinner = %d %v, want 404", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRecipeCandidatesSearchFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
// The provider's text must reach neither the response nor the logs.
|
||||
const leak = "provider-said-token-abc123"
|
||||
e.search.Err = fmt.Errorf("%w: %s", recipes.ErrSearchRefused, leak)
|
||||
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
|
||||
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
||||
if rec.Code != http.StatusBadGateway || errorCode(body) != "search_failed" {
|
||||
t.Errorf("GET = %d %v, want 502 search_failed", rec.Code, body)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), leak) {
|
||||
t.Errorf("response quotes the provider: %s", rec.Body)
|
||||
}
|
||||
logs := e.logs.String()
|
||||
if strings.Contains(logs, leak) || !strings.Contains(logs, "reason=search_failed") {
|
||||
t.Errorf("logs = %q, want reason=search_failed and no provider text", logs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostGroundViaCandidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
|
||||
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"candidate_id":"`+curry2Page+`"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
photo, _ := d["photo_url"].(string)
|
||||
if d["grounding_status"] != "grounded" || d["recipe_id"] == nil || photo == "" ||
|
||||
d["method"] != "stovetop" || d["minutes_total"] != 25.0 || d["difficulty"] != "easy" || d["updated_by"] != testSession.Subject {
|
||||
t.Errorf("dinner = %v", d)
|
||||
}
|
||||
// The candidate is now stored, so it is offered with its photo.
|
||||
_, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
||||
for _, c := range body["candidates"].([]any) {
|
||||
if c := c.(map[string]any); c["candidate_id"] == curry2Page && c["photo_url"] != photo {
|
||||
t.Errorf("grounded candidate photo_url = %v, want %s", c["photo_url"], photo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostGroundViaRecipeURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
||||
|
||||
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+stewPage+`"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if d["grounding_status"] != "grounded" || d["photo_url"] == nil || d["method"] != "oven" || d["minutes_total"] != 150.0 {
|
||||
t.Errorf("dinner = %v", d)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"https://gone.example/soup"}`)
|
||||
if rec.Code != http.StatusUnprocessableEntity || errorCode(body) != "ungroundable" || strings.Contains(rec.Body.String(), "gone.example") {
|
||||
t.Errorf("unusable link = %d %v, want 422 ungroundable", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostGroundViaRecipeURLStorageFailure: a pasted link that fails for a
|
||||
// reason other than its recipe is a 500 logged by reason and dinner id,
|
||||
// never with the link, which can carry a personal token, or the dependency
|
||||
// error.
|
||||
func TestPostGroundViaRecipeURLStorageFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
const token = "personal-token-abc123"
|
||||
link := stewPage + "?access_token=" + token
|
||||
e.pages.Bodies[link] = e.pages.Bodies[stewPage]
|
||||
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(),
|
||||
`CREATE TRIGGER refuse_recipes BEFORE INSERT ON recipes BEGIN SELECT RAISE(ABORT, 'disk full'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+link+`"}`)
|
||||
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" || strings.Contains(rec.Body.String(), token) {
|
||||
t.Errorf("POST = %d %s, want 500 internal without the link", rec.Code, rec.Body)
|
||||
}
|
||||
logs := e.logs.String()
|
||||
if strings.Contains(logs, token) || strings.Contains(logs, "c.example") || strings.Contains(logs, "disk full") ||
|
||||
!strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
|
||||
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the link or dependency error", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostGroundViaRecipeURLDinnerWriteFailure: a pasted link whose recipe
|
||||
// is stored but whose dinner cannot be written is a 500 logged by reason
|
||||
// and dinner id, never with the dependency error.
|
||||
func TestPostGroundViaRecipeURLDinnerWriteFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(),
|
||||
`CREATE TRIGGER refuse_grounding BEFORE UPDATE OF recipe_id ON dinners WHEN NEW.recipe_id IS NOT NULL BEGIN SELECT RAISE(ABORT, 'disk full'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+stewPage+`"}`)
|
||||
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
|
||||
t.Errorf("POST = %d %s, want 500 internal", rec.Code, rec.Body)
|
||||
}
|
||||
var stored int
|
||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM recipes WHERE source_url = ?`, stewPage).Scan(&stored); err != nil || stored != 1 {
|
||||
t.Fatalf("recipes stored for the link = %d (%v), want 1: the failure must be the dinner write", stored, err)
|
||||
}
|
||||
logs := e.logs.String()
|
||||
if strings.Contains(logs, "disk full") || !strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
|
||||
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the dependency error", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostGroundUnexpectedFailures: grounding by hand that fails reading
|
||||
// the dinner, or storing a manual recipe, is a 500 logged by reason and
|
||||
// dinner id, never with the database error.
|
||||
func TestPostGroundUnexpectedFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
const (
|
||||
// Renaming a column every dinner read selects fails the read.
|
||||
breakRead = `ALTER TABLE dinners RENAME COLUMN cuisine TO cuisine_gone`
|
||||
// The trigger's message stands in for the database error.
|
||||
breakStore = `CREATE TRIGGER refuse_recipes BEFORE INSERT ON recipes BEGIN SELECT RAISE(ABORT, 'disk full'); END`
|
||||
manual = `{"manual":{"ingredients":["1 lb beef"],"steps":["Brown."],"minutes":240,"method":"crockpot"}}`
|
||||
)
|
||||
for _, tc := range []struct {
|
||||
name, sabotage, fix, leak string
|
||||
}{
|
||||
{"link read", breakRead, `{"recipe_url":"` + stewPage + `"}`, "cuisine"},
|
||||
{"manual read", breakRead, manual, "cuisine"},
|
||||
{"manual store", breakStore, manual, "disk full"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), tc.sabotage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", tc.fix)
|
||||
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
|
||||
t.Errorf("POST = %d %s, want 500 internal", rec.Code, rec.Body)
|
||||
}
|
||||
logs := e.logs.String()
|
||||
if strings.Contains(logs, tc.leak) || !strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
|
||||
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the database error", logs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostGroundArchived: grounding an archived dinner by hand is a 409.
|
||||
func TestPostGroundArchived(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
id := createDinner(t, e.h, `{"name":"Family chili"}`)
|
||||
if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
|
||||
`{"manual":{"ingredients":["1 lb beef"],"minutes":240,"method":"crockpot"}}`)
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "dinner_archived" {
|
||||
t.Errorf("POST = %d %v, want 409 dinner_archived", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostGroundViaManual(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
id := createDinner(t, e.h, `{"name":"Family chili"}`)
|
||||
|
||||
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
|
||||
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if d["grounding_status"] != "manual" || d["recipe_id"] == nil || d["photo_url"] != nil ||
|
||||
d["method"] != "crockpot" || d["minutes_total"] != 240.0 || d["grounding_error"] != nil {
|
||||
t.Errorf("dinner = %v", d)
|
||||
}
|
||||
var lines int
|
||||
if err := e.st.Reader.QueryRow(`SELECT count(*) FROM recipe_ingredients WHERE recipe_id = ?`, d["recipe_id"]).Scan(&lines); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lines != 2 {
|
||||
t.Errorf("stored %d ingredient lines, want 2", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostGroundRejects(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
id := createDinner(t, e.h, `{"name":"Pad thai"}`)
|
||||
path := dinnersPath + "/" + id + "/ground"
|
||||
|
||||
for _, tc := range []struct{ name, body, code string }{
|
||||
{"empty", `{}`, "invalid_dinner"},
|
||||
{"two kinds", `{"candidate_id":"https://a.example/x","recipe_url":"https://a.example/x"}`, "invalid_dinner"},
|
||||
{"candidate not a link", `{"candidate_id":"c1"}`, "invalid_dinner"},
|
||||
{"http link", `{"recipe_url":"http://a.example/x"}`, "invalid_dinner"},
|
||||
{"null", `{"recipe_url":null}`, "invalid_request"},
|
||||
{"unknown field", `{"url":"https://a.example/x"}`, "invalid_request"},
|
||||
{"unknown manual field", `{"manual":{"ingredients":["x"],"minutes":5,"method":"oven","serves":4}}`, "invalid_request"},
|
||||
{"manual without method", `{"manual":{"ingredients":["x"],"minutes":5}}`, "invalid_dinner"},
|
||||
{"manual without ingredients", `{"manual":{"ingredients":[],"minutes":5,"method":"oven"}}`, "invalid_dinner"},
|
||||
{"manual minutes too many", `{"manual":{"ingredients":["x"],"minutes":5000,"method":"oven"}}`, "invalid_dinner"},
|
||||
{"not an object", `[]`, "invalid_request"},
|
||||
} {
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, path, tc.body)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code {
|
||||
t.Errorf("%s: %d %v, want 400 %s", tc.name, rec.Code, body, tc.code)
|
||||
}
|
||||
}
|
||||
if d := listedDinner(t, e.h, id); d["grounding_status"] != "pending" {
|
||||
t.Errorf("dinner after rejected fixes = %v, want unchanged", d)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/nope/ground",
|
||||
`{"manual":{"ingredients":["x"],"minutes":5,"method":"oven"}}`)
|
||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
||||
t.Errorf("unknown dinner = %d %v, want 404", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostDinnerEnqueueFailureAnswersFailedDinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(),
|
||||
`CREATE TRIGGER refuse_jobs BEFORE INSERT ON jobs BEGIN SELECT RAISE(ABORT, 'no jobs'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
id, _ := created["id"].(string)
|
||||
if id == "" || created["grounding_status"] != "failed" || created["grounding_error"] != "grounding_error" || created["grounding_job_id"] != nil {
|
||||
t.Fatalf("created = %v, want its id, failed (grounding_error) and a null grounding_job_id", created)
|
||||
}
|
||||
if got := listedDinner(t, e.h, id); got["grounding_status"] != "failed" {
|
||||
t.Errorf("listed = %v, want failed", got)
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||
newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestRunner(t),
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t),
|
||||
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||
newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -46,7 +46,7 @@ func TestRecipeSearchSettingsAPI(t *testing.T) {
|
||||
}
|
||||
|
||||
rec, body := call(http.MethodGet, recipeSearchPath, "")
|
||||
if rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false {
|
||||
if def, ok := body["default_base_url"]; rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false || !ok || def != nil {
|
||||
t.Errorf("GET unconfigured = %d %v", rec.Code, body)
|
||||
}
|
||||
rec, body = call(http.MethodPost, recipeSearchTestPath, "")
|
||||
|
||||
@@ -10,11 +10,14 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
)
|
||||
|
||||
// reservedPrefixes are owned by the API router and are never answered with the
|
||||
@@ -31,18 +34,34 @@ type RouteMounter interface {
|
||||
// only from a peer in trusted (see resolveOrigin). version is reported by
|
||||
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
||||
// /media; settings serves /api/v1/settings/household; search serves
|
||||
// /api/v1/settings/recipe-search and its /test; runner serves
|
||||
// /api/v1/settings/recipe-search and its /test; ai serves
|
||||
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
|
||||
// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE
|
||||
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
|
||||
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
|
||||
// suggestSvc serves POST /api/v1/suggestions/batches, GET
|
||||
// /api/v1/suggestions/deck and POST /api/v1/suggestions/{id}/accept and
|
||||
// /decline; runner serves
|
||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
if search == nil {
|
||||
return nil, errors.New("server: nil recipe search settings service")
|
||||
}
|
||||
if ai == nil {
|
||||
return nil, errors.New("server: nil AI settings service")
|
||||
}
|
||||
if dinnerSvc == nil {
|
||||
return nil, errors.New("server: nil dinners service")
|
||||
}
|
||||
if suggestSvc == nil {
|
||||
return nil, errors.New("server: nil suggestions service")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
}
|
||||
@@ -80,6 +99,23 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Get("/settings/recipe-search", getRecipeSearchSettings(logger, search))
|
||||
apiV1.Patch("/settings/recipe-search", patchRecipeSearchSettings(logger, search))
|
||||
apiV1.Post("/settings/recipe-search/test", postRecipeSearchTest(logger, search))
|
||||
apiV1.Get("/settings/ai", getAISettings(logger, ai))
|
||||
apiV1.Patch("/settings/ai", patchAISettings(logger, ai))
|
||||
apiV1.Put("/settings/ai/api-key", putAIKey(logger, ai))
|
||||
apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai))
|
||||
apiV1.Get("/settings/ai/models", getAIModels(logger, ai))
|
||||
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
|
||||
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
|
||||
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
|
||||
apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc))
|
||||
apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc))
|
||||
apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc))
|
||||
apiV1.Get("/dinners/{id}/recipe-candidates", getDinnerCandidates(logger, dinnerSvc))
|
||||
apiV1.Post("/dinners/{id}/ground", postDinnerGround(logger, dinnerSvc))
|
||||
apiV1.Post("/suggestions/batches", postSuggestionBatch(logger, suggestSvc))
|
||||
apiV1.Get("/suggestions/deck", getSuggestionDeck(logger, suggestSvc))
|
||||
apiV1.Post("/suggestions/{id}/accept", postSuggestionDecision(logger, suggestSvc, true))
|
||||
apiV1.Post("/suggestions/{id}/decline", postSuggestionDecision(logger, suggestSvc, false))
|
||||
apiV1.Post("/jobs", postJob(logger, runner))
|
||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||
media.Get("/photos/{name}", getPhoto(logger, photos))
|
||||
|
||||
+107
-12
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
@@ -16,11 +17,17 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
@@ -42,7 +49,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), runner, newTestPhotos(t), assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), runner, newTestPhotos(t), assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -131,24 +138,94 @@ func newTestPhotos(t *testing.T) *recipes.PhotoCache {
|
||||
// configured, over a migrated temp database and a random secret box.
|
||||
func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
|
||||
t.Helper()
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
search, err := recipes.NewSearchSettingsService(newTestStore(t), box)
|
||||
search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return search
|
||||
}
|
||||
|
||||
// newTestDinners returns a dinners service over a migrated temp database,
|
||||
// with its own unstarted job runner and a grounder that finds nothing.
|
||||
func newTestDinners(t *testing.T) *dinners.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||
svc, err := dinners.NewService(st, g, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestSuggest returns a suggestions service over a migrated temp
|
||||
// database, with its own unstarted job runner, a grounder that finds
|
||||
// nothing and a suggester that always fails.
|
||||
func newTestSuggest(t *testing.T) *suggest.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
return skills.SideQuests{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := suggest.NewService(st, g, fail, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestBox returns a secret box under a random key.
|
||||
func newTestBox(t *testing.T) *secretbox.Box {
|
||||
t.Helper()
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
// newTestAI returns an AI settings service over a migrated temp database
|
||||
// with the seed AI settings and a random secret box.
|
||||
func newTestAI(t *testing.T) *aisettings.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
if err := household.SeedAISettings(context.Background(), st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ai, err := aisettings.New(st, newTestBox(t), "https://mealquest.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ai
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresAISettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresSearchSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||
}
|
||||
}
|
||||
@@ -157,7 +234,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted nil photo files")
|
||||
}
|
||||
}
|
||||
@@ -166,16 +243,34 @@ func TestNewRouterRequiresSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresDinners(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil dinners service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil suggestions service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresJobRunner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil job runner")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,21 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
)
|
||||
|
||||
// maxSettingsBody bounds a PATCH /settings/household or
|
||||
// /settings/recipe-search request body.
|
||||
// /settings/recipe-search, or PUT /settings/ai/api-key, request body.
|
||||
const maxSettingsBody = 16 << 10
|
||||
|
||||
// maxAISettingsBody bounds a PATCH /settings/ai request body. It fits the
|
||||
// longest valid system prompt at 12 bytes of JSON per character (a
|
||||
// \u-escaped surrogate pair) on top of maxSettingsBody for the other
|
||||
// fields, so only aisettings.MaxSystemPromptChars limits the prompt.
|
||||
const maxAISettingsBody = maxSettingsBody + 12*aisettings.MaxSystemPromptChars
|
||||
|
||||
// settingsPatch is the PATCH /settings/household body. Every field is
|
||||
// optional; unknown fields and null values are refused.
|
||||
type settingsPatch struct {
|
||||
@@ -30,6 +37,9 @@ type settingsPatch struct {
|
||||
SuggestionsTime *string `json:"suggestions_time"`
|
||||
ChatAutosendDow *int `json:"chat_autosend_dow"`
|
||||
ChatAutosendTime *string `json:"chat_autosend_time"`
|
||||
// OnboardingCompleted true sets onboarding_completed_at, which is
|
||||
// itself read-only.
|
||||
OnboardingCompleted *bool `json:"onboarding_completed"`
|
||||
}
|
||||
|
||||
func getHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc {
|
||||
@@ -103,7 +113,7 @@ func decodePatch[T any](body io.Reader) (T, error) {
|
||||
if errors.As(err, &typeErr) {
|
||||
return patch, fmt.Errorf("%s: want a JSON %s", typeErr.Field, typeErr.Type)
|
||||
}
|
||||
return patch, fmt.Errorf("invalid settings: %v", err)
|
||||
return patch, fmt.Errorf("invalid request body: %v", err)
|
||||
}
|
||||
return patch, nil
|
||||
}
|
||||
@@ -124,13 +134,18 @@ func writeJSON(w http.ResponseWriter, r *http.Request, logger *slog.Logger, stat
|
||||
|
||||
// writeError responds with status and a non-retryable API error.
|
||||
func writeError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, code, message string) {
|
||||
body, err := json.Marshal(errorBody{Error: apiError{Code: code, Message: message}})
|
||||
writeAPIError(w, r, logger, status, apiError{Code: code, Message: message})
|
||||
}
|
||||
|
||||
// writeAPIError responds with status and e.
|
||||
func writeAPIError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, e apiError) {
|
||||
body, err := json.Marshal(errorBody{Error: e})
|
||||
if err != nil {
|
||||
panic(err) // string and bool fields cannot fail to marshal
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
logger.WarnContext(r.Context(), "write error response", "code", code, "error", err)
|
||||
logger.WarnContext(r.Context(), "write error response", "code", e.Code, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
@@ -115,6 +117,63 @@ func TestPatchHouseholdSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// newOnboardingRouter returns a router whose settings store holds regulars
|
||||
// Regulars.
|
||||
func newOnboardingRouter(t *testing.T, regulars int) http.Handler {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range regulars {
|
||||
now := store.Now().String()
|
||||
if _, err := db.New(st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
|
||||
ID: store.NewID(), Name: fmt.Sprintf("Dinner %d", i), NormalizedName: fmt.Sprintf("dinner %d", i),
|
||||
Status: "regular", Origin: "regular", GroundingStatus: "pending", CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return newTestRouterWith(t, "dev", household.NewSettingsService(st), newTestRunner(t))
|
||||
}
|
||||
|
||||
func TestPatchHouseholdSettingsCompletesOnboarding(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec, body := serve(t, newOnboardingRouter(t, household.MinRegulars), signedIn(http.MethodPatch, settingsPath,
|
||||
strings.NewReader(`{"onboarding_completed":true}`)))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if at, ok := body["onboarding_completed_at"].(string); !ok || at != body["updated_at"] {
|
||||
t.Errorf("onboarding_completed_at = %#v, want the update time %#v", body["onboarding_completed_at"], body["updated_at"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestPatchHouseholdSettingsOnboardingNeedsRegulars: completing onboarding
|
||||
// with fewer than three Regulars is refused and leaves it incomplete.
|
||||
func TestPatchHouseholdSettingsOnboardingNeedsRegulars(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for regulars := range household.MinRegulars {
|
||||
t.Run(fmt.Sprintf("%d Regulars", regulars), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newOnboardingRouter(t, regulars)
|
||||
rec, _ := serve(t, router, signedIn(http.MethodPatch, settingsPath,
|
||||
strings.NewReader(`{"onboarding_completed":true}`)))
|
||||
if rec.Code != http.StatusBadRequest ||
|
||||
!strings.Contains(rec.Body.String(), `"code":"invalid_settings"`) ||
|
||||
!strings.Contains(rec.Body.String(), "onboarding_completed: add at least 3 Regulars first") {
|
||||
t.Fatalf("PATCH = %d %s, want 400 invalid_settings on onboarding_completed", rec.Code, rec.Body)
|
||||
}
|
||||
_, body := serve(t, router, signedIn(http.MethodGet, settingsPath, nil))
|
||||
if body["onboarding_completed_at"] != nil {
|
||||
t.Errorf("onboarding_completed_at = %#v after a refused completion, want null", body["onboarding_completed_at"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchHouseholdSettingsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -136,6 +195,8 @@ func TestPatchHouseholdSettingsRejected(t *testing.T) {
|
||||
{"null value", `{"timezone":null}`, "invalid_request", "timezone"},
|
||||
{"unknown field", `{"dinner":"17:30"}`, "invalid_request", "dinner"},
|
||||
{"read-only field", `{"updated_by":"someone"}`, "invalid_request", "updated_by"},
|
||||
{"read-only onboarding time", `{"onboarding_completed_at":"2026-10-06T12:00:00.000Z"}`, "invalid_request", "onboarding_completed_at"},
|
||||
{"onboarding undone", `{"onboarding_completed":false}`, "invalid_settings", "onboarding_completed"},
|
||||
{"not an object", `["timezone"]`, "invalid_request", "JSON object"},
|
||||
{"null body", `null`, "invalid_request", "JSON object"},
|
||||
{"trailing data", `{"dinner_time":"18:00"} {}`, "invalid_request", "JSON object"},
|
||||
@@ -216,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||
newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
)
|
||||
|
||||
type deck struct {
|
||||
Suggestions []suggest.Suggestion `json:"suggestions"`
|
||||
}
|
||||
|
||||
// postSuggestionBatch starts a manual suggestion batch and answers 202 with
|
||||
// its batch_id and the job_id that GET /jobs/{id} reports on, without
|
||||
// waiting for the job. When a batch is already pending or running it
|
||||
// answers 202 with that batch instead of starting another.
|
||||
func postSuggestionBatch(logger *slog.Logger, svc *suggest.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := svc.StartBatch(r.Context(), suggest.TriggerManual)
|
||||
if err != nil {
|
||||
writeSuggestionError(w, r, logger, "start suggestion batch", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusAccepted, b)
|
||||
}
|
||||
}
|
||||
|
||||
// getSuggestionDeck answers GET /suggestions/deck with the pending
|
||||
// suggestions, oldest first.
|
||||
func getSuggestionDeck(logger *slog.Logger, svc *suggest.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := svc.Deck(r.Context())
|
||||
if err != nil {
|
||||
writeSuggestionError(w, r, logger, "list suggestion deck", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, deck{Suggestions: list})
|
||||
}
|
||||
}
|
||||
|
||||
// postSuggestionDecision records Try it (accept) or Pass (decline) on a
|
||||
// suggestion, recording the session's actor as its dinner's updated_by.
|
||||
func postSuggestionDecision(logger *slog.Logger, svc *suggest.Service, accept bool) http.HandlerFunc {
|
||||
decide, action := svc.Decline, "decline suggestion"
|
||||
if accept {
|
||||
decide, action = svc.Accept, "accept suggestion"
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d, err := decide(r.Context(), chi.URLParam(r, "id"), actor.Subject)
|
||||
if err != nil {
|
||||
writeSuggestionError(w, r, logger, action, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, d)
|
||||
}
|
||||
}
|
||||
|
||||
// writeSuggestionError maps a suggest.Service error to its API error: an
|
||||
// unknown suggestion is 404, and a decision on one already decided the
|
||||
// other way, or whose dinner left the deck, is 409.
|
||||
func writeSuggestionError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||
var decided *suggest.DecidedError
|
||||
switch {
|
||||
case errors.As(err, &decided):
|
||||
writeError(w, r, logger, http.StatusConflict, "suggestion_decided", decided.Error())
|
||||
case errors.Is(err, suggest.ErrDinnerNotSuggested):
|
||||
writeError(w, r, logger, http.StatusConflict, "dinner_not_suggested", err.Error())
|
||||
case errors.Is(err, suggest.ErrNotFound):
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "suggestion not found")
|
||||
default:
|
||||
logger.ErrorContext(r.Context(), action, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
)
|
||||
|
||||
const suggestionsPath = "/api/v1/suggestions"
|
||||
|
||||
// newSuggestionsRouter returns a router whose suggestions service has an
|
||||
// unstarted job runner, so batches stay pending, and a deck seeded with
|
||||
// pending suggestions of dinners named names, in order, whose ids it
|
||||
// returns.
|
||||
func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.Store, []string) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
return skills.SideQuests{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := suggest.NewService(st, g, fail, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
q := db.New(st.Writer)
|
||||
ctx := t.Context()
|
||||
now := store.Now().String()
|
||||
batch, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := q.FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: batch.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ids []string
|
||||
for i, name := range names {
|
||||
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
|
||||
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "suggested", Origin: "side_quest",
|
||||
Difficulty: sql.NullString{String: "easy", Valid: true}, GroundingStatus: "grounded", CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := store.NewID()
|
||||
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
|
||||
ID: id, DinnerID: d.ID, BatchID: batch.ID, Name: name, Reason: fmt.Sprintf("Reason %d", i), CitedRegularIds: "[]",
|
||||
CreatedAt: store.Now().String(), UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return h, st, ids
|
||||
}
|
||||
|
||||
func TestSuggestionDeck(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, ids := newSuggestionsRouter(t, "Pho", "Ramen")
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodGet, suggestionsPath+"/deck", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET deck = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
list, _ := body["suggestions"].([]any)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("deck = %v, want 2 suggestions", body)
|
||||
}
|
||||
for i, name := range []string{"Pho", "Ramen"} {
|
||||
s := list[i].(map[string]any)
|
||||
if s["id"] != ids[i] || s["name"] != name || s["difficulty"] != "easy" || s["reason"] != fmt.Sprintf("Reason %d", i) || s["status"] != "pending" {
|
||||
t.Errorf("deck[%d] = %v, want %s", i, s, name)
|
||||
}
|
||||
if _, ok := s["photo_url"]; !ok {
|
||||
t.Errorf("deck[%d] has no photo_url key", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestionDecisions(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, ids := newSuggestionsRouter(t, "Pho", "Ramen", "Laksa", "Bibimbap", "Katsu", "Tonkatsu", "Udon")
|
||||
|
||||
for _, tc := range []struct {
|
||||
target string
|
||||
status int
|
||||
code string
|
||||
sugStatus string
|
||||
dinnerStatus string
|
||||
}{
|
||||
{ids[0] + "/accept", http.StatusOK, "", "accepted", "quest_log"},
|
||||
{ids[0] + "/accept", http.StatusOK, "", "accepted", "quest_log"},
|
||||
{ids[0] + "/decline", http.StatusConflict, "suggestion_decided", "", ""},
|
||||
{ids[1] + "/decline", http.StatusOK, "", "declined", "archived"},
|
||||
{ids[1] + "/decline", http.StatusOK, "", "declined", "archived"},
|
||||
{ids[1] + "/accept", http.StatusConflict, "suggestion_decided", "", ""},
|
||||
{"nope/accept", http.StatusNotFound, "not_found", "", ""},
|
||||
{"nope/decline", http.StatusNotFound, "not_found", "", ""},
|
||||
} {
|
||||
rec, body := callDinners(t, h, http.MethodPost, suggestionsPath+"/"+tc.target, "")
|
||||
if rec.Code != tc.status {
|
||||
t.Fatalf("POST %s = %d, want %d: %s", tc.target, rec.Code, tc.status, rec.Body)
|
||||
}
|
||||
if tc.code != "" {
|
||||
if errorCode(body) != tc.code {
|
||||
t.Errorf("POST %s error = %v, want %s", tc.target, body, tc.code)
|
||||
}
|
||||
continue
|
||||
}
|
||||
s, _ := body["suggestion"].(map[string]any)
|
||||
if s["status"] != tc.sugStatus || s["dinner_status"] != tc.dinnerStatus {
|
||||
t.Errorf("POST %s = %v, want %s with dinner %s", tc.target, body, tc.sugStatus, tc.dinnerStatus)
|
||||
}
|
||||
if body["replenish_job_id"] != nil {
|
||||
t.Errorf("POST %s replenished with 5 left: %v", tc.target, body["replenish_job_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// A third decision leaves 4: a replenish batch starts.
|
||||
rec, body := callDinners(t, h, http.MethodPost, suggestionsPath+"/"+ids[2]+"/decline", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("decline = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if id, _ := body["replenish_job_id"].(string); id == "" {
|
||||
t.Errorf("decline leaving 4 = %v, want a replenish_job_id", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSuggestionBatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, _ := newSuggestionsRouter(t)
|
||||
|
||||
rec, first := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "")
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST batches = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if first["batch_id"] == nil || first["job_id"] == nil || first["trigger"] != "manual" || first["status"] != "pending" {
|
||||
t.Errorf("POST batches = %v, want a pending manual batch with its job", first)
|
||||
}
|
||||
var kind string
|
||||
if err := st.Reader.QueryRow(`SELECT kind FROM jobs WHERE id = ?`, first["job_id"]).Scan(&kind); err != nil || kind != suggest.KindSuggestionBatch {
|
||||
t.Errorf("job kind = %q (%v), want %s", kind, err, suggest.KindSuggestionBatch)
|
||||
}
|
||||
|
||||
rec, again := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "")
|
||||
if rec.Code != http.StatusAccepted || again["batch_id"] != first["batch_id"] || again["job_id"] != first["job_id"] {
|
||||
t.Errorf("second POST batches = %d %v, want the pending batch %v", rec.Code, again, first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestionWritesNeedCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, ids := newSuggestionsRouter(t, "Pho")
|
||||
for _, target := range []string{"/batches", "/" + ids[0] + "/accept", "/" + ids[0] + "/decline"} {
|
||||
req := httptest.NewRequest(http.MethodPost, suggestionsPath+target, nil)
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec, body := serve(t, h, req)
|
||||
if rec.Code != http.StatusForbidden || errorCode(body) != "csrf" {
|
||||
t.Errorf("POST %s without CSRF = %d %v, want 403 csrf", target, rec.Code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -175,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name", got)
|
||||
}
|
||||
if !tableExists(t, s, "dinners") {
|
||||
t.Error("table from 0001_init was not created")
|
||||
@@ -557,3 +558,78 @@ func TestMigrateAIRunsStatus(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateSuggestionName upgrades a database at 0011 holding a pending
|
||||
// and a declined suggestion and checks 0012 keeps both, gives each its
|
||||
// dinner's name, and refuses a suggestion with no name.
|
||||
func TestMigrateSuggestionName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
before := fstest.MapFS{}
|
||||
for _, name := range []string{
|
||||
"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql", "0003_jobs_status.sql",
|
||||
"0004_ai_provider_presets.sql", "0005_ai_provider_xai.sql", "0006_ai_runs_status.sql", "0007_recipe_grounding.sql",
|
||||
"0008_dinner_recipe_url.sql", "0009_dinner_grounding_error.sql", "0010_dinner_grounding_revision.sql",
|
||||
"0011_ground_dinner_job_revision.sql",
|
||||
} {
|
||||
body, err := fs.ReadFile(migrations.FS, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before[name] = &fstest.MapFile{Data: body}
|
||||
}
|
||||
if err := Migrate(ctx, s.Writer, before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
`INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at)
|
||||
VALUES ('dinner-1', 'Pho', 'pho', 'suggested', 'side_quest', 0, 'grounded', 'c', 'u'),
|
||||
('dinner-2', 'Ramen', 'ramen', 'archived', 'side_quest', 0, 'grounded', 'c', 'u')`,
|
||||
`INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
|
||||
VALUES ('batch-1', 'manual', 'done', 'c', 'u')`,
|
||||
`INSERT INTO suggestions (id, dinner_id, batch_id, reason, cited_regular_ids, status, decided_at, created_at, updated_at)
|
||||
VALUES ('sugg-1', 'dinner-1', 'batch-1', 'why 1', '[]', 'pending', NULL, 'c1', 'u1'),
|
||||
('sugg-2', 'dinner-2', 'batch-1', 'why 2', '["dinner-1"]', 'declined', 'd2', 'c2', 'u2')`,
|
||||
} {
|
||||
if _, err := s.Writer.Exec(stmt); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := s.Reader.Query(`SELECT id, dinner_id, batch_id, name, reason, cited_regular_ids, status, COALESCE(decided_at, ''), created_at, updated_at
|
||||
FROM suggestions ORDER BY id`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got [][]string
|
||||
for rows.Next() {
|
||||
r := make([]string, 10)
|
||||
if err := rows.Scan(&r[0], &r[1], &r[2], &r[3], &r[4], &r[5], &r[6], &r[7], &r[8], &r[9]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, r)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := [][]string{
|
||||
{"sugg-1", "dinner-1", "batch-1", "Pho", "why 1", "[]", "pending", "", "c1", "u1"},
|
||||
{"sugg-2", "dinner-2", "batch-1", "Ramen", "why 2", `["dinner-1"]`, "declined", "d2", "c2", "u2"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("suggestions after 0012 =\n %v\nwant\n %v", got, want)
|
||||
}
|
||||
if _, err := s.Writer.Exec(`UPDATE suggestions SET name = '' WHERE id = 'sugg-1'`); err == nil || !strings.Contains(err.Error(), "CHECK constraint failed") {
|
||||
t.Errorf("empty suggestion name: error %v, want CHECK constraint failure", err)
|
||||
}
|
||||
if _, err := s.Writer.Exec(`UPDATE suggestions SET dinner_id = 'nope' WHERE id = 'sugg-1'`); err == nil || !strings.Contains(err.Error(), "FOREIGN KEY constraint failed") {
|
||||
t.Errorf("unknown dinner: error %v, want FOREIGN KEY constraint failure", err)
|
||||
}
|
||||
if tableExists(t, s, "suggestions_copy") {
|
||||
t.Error("suggestions_copy left behind")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestRepositoryInsertReadUpdate(t *testing.T) {
|
||||
parents: []string{"dinners", "suggestion_batches"},
|
||||
row: repoRow{"suggestions", map[string]any{
|
||||
"id": "sugg-1", "dinner_id": "dinner-1", "batch_id": "batch-1",
|
||||
"reason": "You make chili often.", "cited_regular_ids": `["dinner-1"]`,
|
||||
"name": "White Chicken Chili", "reason": "You make chili often.", "cited_regular_ids": `["dinner-1"]`,
|
||||
"status": "pending", "decided_at": nil,
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
|
||||
@@ -110,6 +110,7 @@ func testRow(table string, i int) map[string]any {
|
||||
case "suggestions":
|
||||
r["dinner_id"] = rowID("dinners", 0)
|
||||
r["batch_id"] = rowID("suggestion_batches", 0)
|
||||
r["name"] = fmt.Sprintf("Side quest %d", i)
|
||||
r["reason"] = "Like your chili."
|
||||
r["cited_regular_ids"] = "[]"
|
||||
r["status"] = "pending"
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
package suggest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// BatchJob is the KindSuggestionBatch payload: the batch to run.
|
||||
type BatchJob struct {
|
||||
BatchID string `json:"batch_id"`
|
||||
}
|
||||
|
||||
// Batch stages, as the job's result reports them.
|
||||
const (
|
||||
StageSuggesting = "suggesting"
|
||||
StageGrounding = "grounding"
|
||||
StageDone = "done"
|
||||
)
|
||||
|
||||
// batchResult is the KindSuggestionBatch result, published as the batch
|
||||
// goes: its stage, the suggestions stored so far, and how many Side
|
||||
// quests were dropped, by suggest_side_quests' rules, because no recipe
|
||||
// grounds them, or because they repeat a dinner added meanwhile.
|
||||
type batchResult struct {
|
||||
BatchID string `json:"batch_id"`
|
||||
Stage string `json:"stage"`
|
||||
SuggestionIDs []string `json:"suggestion_ids"`
|
||||
Dropped int `json:"dropped"`
|
||||
}
|
||||
|
||||
func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
|
||||
// runBatch runs batch p.BatchID: it asks suggest_side_quests for BatchSize
|
||||
// Side quests given the household context, the deck and every declined
|
||||
// name, then grounds them in the model's order, at most the number still
|
||||
// wanted at a time, until BatchSize are kept or none are left. A Side quest
|
||||
// no recipe grounds is dropped; any other grounding failure fails the
|
||||
// batch, as does suggest_side_quests failing, with no Side quest made up
|
||||
// instead. Each round's grounded Side quests are stored before the next
|
||||
// round starts, so the deck fills as the batch goes. The batch ends done
|
||||
// or failed; Side quests stored before a failure stay in the deck. When
|
||||
// recording the batch's end fails, so does the job; when it times out at
|
||||
// shutdown, the job is left running and the batch with it, for the next
|
||||
// start to fail.
|
||||
func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) {
|
||||
n, err := db.New(s.st.Writer).StartSuggestionBatch(ctx, db.StartSuggestionBatchParams{
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: p.BatchID,
|
||||
})
|
||||
if err != nil {
|
||||
return batchResult{}, fmt.Errorf("suggest: start batch %s: %w", p.BatchID, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID)
|
||||
}
|
||||
res, err := s.fill(ctx, p.BatchID, publish)
|
||||
if err == nil {
|
||||
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
finishErr := s.finishBatch(ctx, p.BatchID, BatchFailed)
|
||||
if ctx.Err() != nil {
|
||||
return batchResult{}, errors.Join(ctx.Err(), finishErr)
|
||||
}
|
||||
var failure *jobs.Failure
|
||||
if errors.As(err, &failure) {
|
||||
// Only the code is logged: the error may quote a model's or
|
||||
// search provider's response.
|
||||
s.logger.InfoContext(ctx, "suggestion batch failed", "batch_id", p.BatchID, "reason", failure.Code)
|
||||
}
|
||||
return batchResult{}, errors.Join(err, finishErr)
|
||||
}
|
||||
|
||||
// fill does runBatch's work, returning a *jobs.Failure for a failure the
|
||||
// household may see.
|
||||
func (s *Service) fill(ctx context.Context, batchID string, publish func(batchResult) error) (batchResult, error) {
|
||||
res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
|
||||
if err := publish(res); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
in, err := s.request(ctx)
|
||||
if err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
out, err := s.suggest(ctx, batchID, in)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return batchResult{}, ctx.Err()
|
||||
}
|
||||
return batchResult{}, &jobs.Failure{Code: ReasonAIFailed}
|
||||
}
|
||||
res.Stage = StageGrounding
|
||||
res.Dropped = len(out.Dropped)
|
||||
if err := publish(res); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
left := out.Suggestions
|
||||
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 {
|
||||
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))]
|
||||
left = left[len(round):]
|
||||
grounded, err := s.ground(ctx, round)
|
||||
if err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
res.Dropped += len(round) - len(grounded)
|
||||
ids, err := s.store(ctx, batchID, grounded)
|
||||
if err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
res.Dropped += len(grounded) - len(ids)
|
||||
res.SuggestionIDs = append(res.SuggestionIDs, ids...)
|
||||
if err := publish(res); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
}
|
||||
res.Stage = StageDone
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// request reads what suggest_side_quests is given: the household context,
|
||||
// the deck's names and every declined name. A household with no Regulars
|
||||
// fails with ReasonNoRegulars.
|
||||
func (s *Service) request(ctx context.Context) (skills.SideQuestsRequest, error) {
|
||||
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
|
||||
if err != nil {
|
||||
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: %w", err)
|
||||
}
|
||||
if len(hctx.Regulars) == 0 {
|
||||
return skills.SideQuestsRequest{}, &jobs.Failure{Code: ReasonNoRegulars}
|
||||
}
|
||||
q := db.New(s.st.Reader)
|
||||
deck, err := q.ListDeck(ctx)
|
||||
if err != nil {
|
||||
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list deck: %w", err)
|
||||
}
|
||||
pending := []string{}
|
||||
for _, row := range deck {
|
||||
pending = append(pending, row.Dinner.Name)
|
||||
}
|
||||
declined, err := q.ListDeclinedSuggestionNames(ctx)
|
||||
if err != nil {
|
||||
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list declined names: %w", err)
|
||||
}
|
||||
return skills.SideQuestsRequest{Context: hctx, Needed: BatchSize, Pending: pending, Declined: declined}, nil
|
||||
}
|
||||
|
||||
// groundedQuest is a Side quest with the recipe that grounds it.
|
||||
type groundedQuest struct {
|
||||
quest skills.SideQuest
|
||||
grounded recipes.Grounded
|
||||
}
|
||||
|
||||
// ground grounds quests by their search queries and returns those a
|
||||
// recipe grounds, in order. A Side quest no recipe grounds is left out;
|
||||
// any other failure fails the batch.
|
||||
func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]groundedQuest, error) {
|
||||
reqs := make([]recipes.GroundRequest, len(quests))
|
||||
for i, q := range quests {
|
||||
reqs[i] = recipes.GroundRequest{DishName: q.Name, Query: q.SearchQuery}
|
||||
}
|
||||
results := s.grounder.GroundMany(ctx, reqs)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []groundedQuest
|
||||
for i, r := range results {
|
||||
var ungroundable *recipes.ErrUngroundable
|
||||
switch {
|
||||
case r.Err == nil:
|
||||
out = append(out, groundedQuest{quest: quests[i], grounded: r.Grounded})
|
||||
case errors.As(r.Err, &ungroundable):
|
||||
case errors.Is(r.Err, recipes.ErrSearchNotConfigured):
|
||||
return nil, &jobs.Failure{Code: ReasonSearchNotConfigured}
|
||||
case errors.Is(r.Err, recipes.ErrSearchFailed):
|
||||
return nil, &jobs.Failure{Code: ReasonSearchFailed}
|
||||
case errors.Is(r.Err, recipes.ErrNormalizeFailed):
|
||||
return nil, &jobs.Failure{Code: ReasonAIFailed}
|
||||
default:
|
||||
return nil, fmt.Errorf("suggest: ground %q: %w", quests[i].Name, r.Err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// store stores each grounded Side quest of batchID as a dinner with status
|
||||
// suggested and origin side_quest, grounded in its recipe, and a pending
|
||||
// suggestion citing its Regulars by id, all in one transaction, and
|
||||
// returns the suggestion ids. A Side quest is left out when, since
|
||||
// suggest_side_quests ran, a dinner it repeats was added, a suggestion it
|
||||
// repeats passed, or a Regular it cites renamed or archived. Declined
|
||||
// names are read in the same transaction as the store, so a Pass committed
|
||||
// before it is never missed.
|
||||
func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, error) {
|
||||
ids := []string{}
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
existing, err := q.ListUnarchivedDinners(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: list dinners for dedupe: %w", err)
|
||||
}
|
||||
names, err := q.ListDeclinedSuggestionNames(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: list declined names for dedupe: %w", err)
|
||||
}
|
||||
regulars := map[string][]string{}
|
||||
for _, row := range existing {
|
||||
names = append(names, row.Dinner.Name)
|
||||
if row.Dinner.Status == dinners.StatusRegular {
|
||||
regulars[row.Dinner.Name] = append(regulars[row.Dinner.Name], row.Dinner.ID)
|
||||
}
|
||||
}
|
||||
for _, gq := range quests {
|
||||
cited, ok := citedIDs(gq.quest.CitedRegulars, regulars)
|
||||
if !ok || repeats(gq.quest.Name, names) {
|
||||
s.logger.InfoContext(ctx, "suggested dinner dropped: changed while grounding", "batch_id", batchID)
|
||||
continue
|
||||
}
|
||||
citedJSON, err := json.Marshal(cited)
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: encode cited Regulars: %w", err)
|
||||
}
|
||||
now := store.Now().String()
|
||||
gr := gq.grounded
|
||||
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: gq.quest.Name,
|
||||
NormalizedName: harness.Normalize(gq.quest.Name),
|
||||
Status: dinners.StatusSuggested,
|
||||
Origin: "side_quest",
|
||||
Method: sql.NullString{String: gr.Method, Valid: true},
|
||||
MinutesTotal: nullInt(gr.MinutesTotal),
|
||||
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
||||
Cuisine: sql.NullString{String: gq.quest.Cuisine, Valid: true},
|
||||
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
||||
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
||||
GroundingStatus: dinners.GroundingGrounded,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: create dinner: %w", err)
|
||||
}
|
||||
id := store.NewID()
|
||||
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
|
||||
ID: id,
|
||||
DinnerID: d.ID,
|
||||
BatchID: batchID,
|
||||
Name: d.Name,
|
||||
Reason: gq.quest.Reason,
|
||||
CitedRegularIds: string(citedJSON),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("suggest: create suggestion: %w", err)
|
||||
}
|
||||
names = append(names, d.Name)
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// citedIDs returns the ids of the Regulars named cited, false when one
|
||||
// names no Regular or more than one.
|
||||
func citedIDs(cited []string, regulars map[string][]string) ([]string, bool) {
|
||||
ids := make([]string, len(cited))
|
||||
for i, name := range cited {
|
||||
if len(regulars[name]) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
ids[i] = regulars[name][0]
|
||||
}
|
||||
return ids, true
|
||||
}
|
||||
|
||||
// repeats reports whether name is harness.Similar to any of names.
|
||||
func repeats(name string, names []string) bool {
|
||||
for _, n := range names {
|
||||
if harness.Similar(name, n) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func nullInt(v *int) sql.NullInt64 {
|
||||
if v == nil {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
return sql.NullInt64{Int64: int64(*v), Valid: true}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
// Package suggest keeps the household's Side quest deck: batches of new
|
||||
// dinners the AI suggests, each grounded in a recipe, and the household's
|
||||
// Try it / Pass decision on each. A batch runs as a job: suggest_side_quests
|
||||
// proposes Side quests, each is grounded with the recipe grounding pipeline
|
||||
// (dropping those no recipe grounds), and every one kept is stored as a
|
||||
// dinner with status suggested and origin side_quest and a pending
|
||||
// suggestion. Only one batch is pending or running at a time.
|
||||
package suggest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// KindSuggestionBatch is the job that runs a suggestion batch.
|
||||
const KindSuggestionBatch = "suggestion_batch"
|
||||
|
||||
const (
|
||||
// BatchSize is how many Side quests a batch keeps at most.
|
||||
BatchSize = 5
|
||||
// ReplenishBelow is the deck size under which a decision starts a
|
||||
// replenish batch.
|
||||
ReplenishBelow = 5
|
||||
// ContextBudget is the character budget of the household context
|
||||
// suggest_side_quests is given (see harness.BuildContext).
|
||||
ContextBudget = 16000
|
||||
)
|
||||
|
||||
// Batch triggers (the suggestion_batches.trigger column).
|
||||
const (
|
||||
TriggerScheduled = "scheduled"
|
||||
TriggerManual = "manual"
|
||||
TriggerReplenish = "replenish"
|
||||
)
|
||||
|
||||
// Batch statuses (the suggestion_batches.status column).
|
||||
const (
|
||||
BatchPending = "pending"
|
||||
BatchRunning = "running"
|
||||
BatchDone = "done"
|
||||
BatchFailed = "failed"
|
||||
)
|
||||
|
||||
// Suggestion statuses (the suggestions.status column).
|
||||
const (
|
||||
StatusPending = "pending"
|
||||
StatusAccepted = "accepted"
|
||||
StatusDeclined = "declined"
|
||||
)
|
||||
|
||||
// Reasons a batch failed, stored as the KindSuggestionBatch job's failure
|
||||
// code. Any other failure, such as the database's, is the runner's
|
||||
// internal_error.
|
||||
const (
|
||||
// ReasonNoRegulars: the household has no Regulars for Side quests to
|
||||
// be based on.
|
||||
ReasonNoRegulars = "no_regulars"
|
||||
// ReasonAIFailed: suggest_side_quests, or normalize_recipe while
|
||||
// grounding, failed; no Side quest is made up instead (D-017).
|
||||
ReasonAIFailed = "ai_failed"
|
||||
// ReasonSearchNotConfigured: recipe search is not set up.
|
||||
ReasonSearchNotConfigured = "search_not_configured"
|
||||
// ReasonSearchFailed: the search provider failed.
|
||||
ReasonSearchFailed = "search_failed"
|
||||
)
|
||||
|
||||
// Reasons are every batch failure reason.
|
||||
var Reasons = []string{ReasonNoRegulars, ReasonAIFailed, ReasonSearchNotConfigured, ReasonSearchFailed}
|
||||
|
||||
var triggers = []string{TriggerScheduled, TriggerManual, TriggerReplenish}
|
||||
|
||||
// ErrNotFound is returned for an unknown suggestion id.
|
||||
var ErrNotFound = errors.New("suggestion not found")
|
||||
|
||||
// ErrDinnerNotSuggested is a decision on a pending suggestion whose dinner
|
||||
// was archived or moved out of the deck some other way.
|
||||
var ErrDinnerNotSuggested = errors.New("the suggestion's dinner is no longer suggested")
|
||||
|
||||
// DecidedError is a decision on a suggestion already decided the other
|
||||
// way.
|
||||
type DecidedError struct {
|
||||
// Status is the suggestion's decision: accepted or declined.
|
||||
Status string
|
||||
}
|
||||
|
||||
func (e *DecidedError) Error() string {
|
||||
return "the suggestion was already " + e.Status
|
||||
}
|
||||
|
||||
// Suggester runs suggest_side_quests on in; scope names the batch. In
|
||||
// production it is skills.SuggestSideQuests with the household's
|
||||
// harness.Config.
|
||||
type Suggester func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error)
|
||||
|
||||
// Service runs suggestion batches and records decisions.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
grounder *recipes.Grounder
|
||||
suggest Suggester
|
||||
logger *slog.Logger
|
||||
batchJob *jobs.Kind[BatchJob]
|
||||
}
|
||||
|
||||
// NewService returns a service over st that suggests with suggest, grounds
|
||||
// with grounder and registers the KindSuggestionBatch job on runner, which
|
||||
// must not have started yet.
|
||||
func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("suggest: nil store")
|
||||
case grounder == nil:
|
||||
return nil, errors.New("suggest: nil grounder")
|
||||
case suggest == nil:
|
||||
return nil, errors.New("suggest: nil suggester")
|
||||
case runner == nil:
|
||||
return nil, errors.New("suggest: nil job runner")
|
||||
case logger == nil:
|
||||
return nil, errors.New("suggest: nil logger")
|
||||
}
|
||||
s := &Service{st: st, grounder: grounder, suggest: suggest, logger: logger}
|
||||
kind, err := jobs.Register(runner, KindSuggestionBatch, s.runBatch, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.batchJob = kind
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Batch is a suggestion batch with the id of the job running it.
|
||||
type Batch struct {
|
||||
ID string `json:"batch_id"`
|
||||
JobID string `json:"job_id"`
|
||||
Trigger string `json:"trigger"`
|
||||
Status string `json:"status"`
|
||||
// Created says whether this call created the batch rather than found
|
||||
// it already pending or running.
|
||||
Created bool `json:"-"`
|
||||
}
|
||||
|
||||
// StartBatch creates a batch with trigger and enqueues the
|
||||
// KindSuggestionBatch job that runs it, unless a batch is already pending
|
||||
// or running, which it returns instead with Created false.
|
||||
func (s *Service) StartBatch(ctx context.Context, trigger string) (Batch, error) {
|
||||
if !slices.Contains(triggers, trigger) {
|
||||
return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", trigger)
|
||||
}
|
||||
var b Batch
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
var err error
|
||||
b, err = s.startBatch(ctx, tx, trigger)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return Batch{}, err
|
||||
}
|
||||
if b.Created {
|
||||
s.batchJob.Wake()
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// startBatch does StartBatch's work in tx: the batch and the job that runs
|
||||
// it are committed together, so a batch is never seen without its job. A
|
||||
// batch still pending or running whose job is no longer queued or running
|
||||
// is failed first, so a batch whose job ended without recording its end
|
||||
// (the process stopped, or the write failed) never blocks a later one. The
|
||||
// caller wakes the job runner once tx has committed and Created is true.
|
||||
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) {
|
||||
q := db.New(tx)
|
||||
n, err := q.FailOrphanedSuggestionBatches(ctx, db.FailOrphanedSuggestionBatchesParams{
|
||||
UpdatedAt: store.Now().String(),
|
||||
Kind: KindSuggestionBatch,
|
||||
})
|
||||
if err != nil {
|
||||
return Batch{}, fmt.Errorf("suggest: fail orphaned batches: %w", err)
|
||||
}
|
||||
if n > 0 {
|
||||
s.logger.WarnContext(ctx, "failed suggestion batches whose job ended", "count", n)
|
||||
}
|
||||
cur, err := q.GetActiveSuggestionBatch(ctx)
|
||||
if err == nil {
|
||||
jobID, err := q.GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: cur.ID})
|
||||
if err != nil {
|
||||
return Batch{}, fmt.Errorf("suggest: read job of batch %s: %w", cur.ID, err)
|
||||
}
|
||||
return Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return Batch{}, fmt.Errorf("suggest: read active batch: %w", err)
|
||||
}
|
||||
now := store.Now().String()
|
||||
row, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{
|
||||
ID: store.NewID(),
|
||||
Trigger: trigger,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return Batch{}, fmt.Errorf("suggest: create batch: %w", err)
|
||||
}
|
||||
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID})
|
||||
if err != nil {
|
||||
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err)
|
||||
}
|
||||
return Batch{ID: row.ID, JobID: jobID, Trigger: row.Trigger, Status: row.Status, Created: true}, nil
|
||||
}
|
||||
|
||||
// finishBatch records running batch id as status, with
|
||||
// jobs.FinalWriteContext: the write is made even when ctx has been
|
||||
// cancelled, but a writer that stays busy cannot hold its worker past
|
||||
// jobs.FinalWriteTimeout. Its job's handler returns any error, so the job
|
||||
// never ends succeeded with the batch still running; the runner keeps only
|
||||
// a code, so the error is logged too.
|
||||
func (s *Service) finishBatch(ctx context.Context, id, status string) error {
|
||||
write, cancel := jobs.FinalWriteContext(ctx)
|
||||
defer cancel()
|
||||
n, err := db.New(s.st.Writer).FinishSuggestionBatch(write, db.FinishSuggestionBatchParams{
|
||||
Status: status,
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: id,
|
||||
})
|
||||
if err == nil && n != 1 {
|
||||
err = errors.New("batch is not running")
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.ErrorContext(ctx, "record suggestion batch status", "batch_id", id, "status", status, "error", err)
|
||||
return fmt.Errorf("suggest: record batch %s %s: %w", id, status, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Suggestion is a Side quest suggestion as the API shows it, with its
|
||||
// dinner.
|
||||
type Suggestion struct {
|
||||
ID string `json:"id"`
|
||||
DinnerID string `json:"dinner_id"`
|
||||
Name string `json:"name"`
|
||||
PhotoURL *string `json:"photo_url"`
|
||||
Difficulty *string `json:"difficulty"`
|
||||
Method *string `json:"method"`
|
||||
MinutesTotal *int64 `json:"minutes_total"`
|
||||
Cuisine *string `json:"cuisine"`
|
||||
// Reason is the "Why this?".
|
||||
Reason string `json:"reason"`
|
||||
CitedRegularIDs []string `json:"cited_regular_ids"`
|
||||
Status string `json:"status"`
|
||||
DinnerStatus string `json:"dinner_status"`
|
||||
DecidedAt *string `json:"decided_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Deck returns the pending suggestions whose dinner is still suggested,
|
||||
// oldest first.
|
||||
func (s *Service) Deck(ctx context.Context) ([]Suggestion, error) {
|
||||
rows, err := db.New(s.st.Reader).ListDeck(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: list deck: %w", err)
|
||||
}
|
||||
out := make([]Suggestion, len(rows))
|
||||
for i, row := range rows {
|
||||
out[i], err = fromRow(row.Suggestion, row.Dinner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Decision is a suggestion once decided, with the id of the replenish
|
||||
// batch's job when the decision started one.
|
||||
type Decision struct {
|
||||
Suggestion Suggestion `json:"suggestion"`
|
||||
ReplenishJobID *string `json:"replenish_job_id"`
|
||||
}
|
||||
|
||||
// Accept records Try it on suggestion id, moving its dinner to the Quest
|
||||
// log and recording updatedBy on it.
|
||||
func (s *Service) Accept(ctx context.Context, id, updatedBy string) (Decision, error) {
|
||||
return s.decide(ctx, id, StatusAccepted, dinners.StatusQuestLog, updatedBy)
|
||||
}
|
||||
|
||||
// Decline records Pass on suggestion id, archiving its dinner and
|
||||
// recording updatedBy on it. The suggestion keeps the name its dish was
|
||||
// suggested under, whatever the dinner is later renamed, and every later
|
||||
// batch reads it as declined, so the dish is never suggested again.
|
||||
func (s *Service) Decline(ctx context.Context, id, updatedBy string) (Decision, error) {
|
||||
return s.decide(ctx, id, StatusDeclined, dinners.StatusArchived, updatedBy)
|
||||
}
|
||||
|
||||
// decide records decision on suggestion id and moves its dinner to
|
||||
// dinnerStatus. Repeating a decision changes nothing and returns the
|
||||
// suggestion as it is; the opposite decision is a *DecidedError. A
|
||||
// decision that leaves the deck under ReplenishBelow starts a replenish
|
||||
// batch unless one is already pending or running, in the decision's own
|
||||
// transaction: the decision is never recorded without the replenish it
|
||||
// calls for, and a failure to start the batch records neither.
|
||||
func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, updatedBy string) (Decision, error) {
|
||||
if updatedBy == "" {
|
||||
return Decision{}, errors.New("suggest: decide: no member to record as updated_by")
|
||||
}
|
||||
var row db.GetSuggestionRow
|
||||
var replenish *Batch
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
cur, err := q.GetSuggestion(ctx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: read suggestion %s: %w", id, err)
|
||||
}
|
||||
switch cur.Suggestion.Status {
|
||||
case decision:
|
||||
row = cur
|
||||
return nil
|
||||
case StatusPending:
|
||||
default:
|
||||
return &DecidedError{Status: cur.Suggestion.Status}
|
||||
}
|
||||
if cur.Dinner.Status != dinners.StatusSuggested {
|
||||
return ErrDinnerNotSuggested
|
||||
}
|
||||
now := store.Now().String()
|
||||
if _, err := q.DecideSuggestion(ctx, db.DecideSuggestionParams{
|
||||
Status: decision,
|
||||
Now: sql.NullString{String: now, Valid: true},
|
||||
ID: id,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("suggest: decide %s: %w", id, err)
|
||||
}
|
||||
if _, err := q.MoveSuggestedDinner(ctx, db.MoveSuggestedDinnerParams{
|
||||
Status: dinnerStatus,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: now,
|
||||
ID: cur.Dinner.ID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("suggest: move dinner %s: %w", cur.Dinner.ID, err)
|
||||
}
|
||||
row, err = q.GetSuggestion(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: read suggestion %s: %w", id, err)
|
||||
}
|
||||
left, err := q.CountDeck(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: count deck: %w", err)
|
||||
}
|
||||
if left >= ReplenishBelow {
|
||||
return nil
|
||||
}
|
||||
b, err := s.startBatch(ctx, tx, TriggerReplenish)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
replenish = &b
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
created := replenish != nil && replenish.Created
|
||||
if created {
|
||||
s.batchJob.Wake()
|
||||
}
|
||||
sug, err := fromRow(row.Suggestion, row.Dinner)
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
d := Decision{Suggestion: sug}
|
||||
if created {
|
||||
d.ReplenishJobID = &replenish.JobID
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func fromRow(sg db.Suggestion, d db.Dinner) (Suggestion, error) {
|
||||
out := Suggestion{
|
||||
ID: sg.ID,
|
||||
DinnerID: d.ID,
|
||||
Name: d.Name,
|
||||
Difficulty: nullString(d.Difficulty),
|
||||
Method: nullString(d.Method),
|
||||
Cuisine: nullString(d.Cuisine),
|
||||
Reason: sg.Reason,
|
||||
Status: sg.Status,
|
||||
DinnerStatus: d.Status,
|
||||
DecidedAt: nullString(sg.DecidedAt),
|
||||
CreatedAt: sg.CreatedAt,
|
||||
}
|
||||
if err := json.Unmarshal([]byte(sg.CitedRegularIds), &out.CitedRegularIDs); err != nil || out.CitedRegularIDs == nil {
|
||||
return Suggestion{}, fmt.Errorf("suggest: suggestion %s cited_regular_ids is not a JSON array: %q", sg.ID, sg.CitedRegularIds)
|
||||
}
|
||||
if d.MinutesTotal.Valid {
|
||||
out.MinutesTotal = &d.MinutesTotal.Int64
|
||||
}
|
||||
if d.PhotoID.Valid {
|
||||
u := dinners.PhotoURL(d.PhotoID.String)
|
||||
out.PhotoURL = &u
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func nullString(s sql.NullString) *string {
|
||||
if !s.Valid {
|
||||
return nil
|
||||
}
|
||||
return &s.String
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
-- 0008_dinner_recipe_url: a Regular added from a pasted recipe link keeps
|
||||
-- that link until grounding resolves it to a recipe (recipe_id). Nothing
|
||||
-- else records the link: recipes rows exist only for fetched pages.
|
||||
|
||||
ALTER TABLE dinners ADD COLUMN recipe_url TEXT;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 0009_dinner_grounding_error: a dinner whose grounding failed keeps why,
|
||||
-- as one of the reason codes package dinners defines (no_recipe_found,
|
||||
-- recipe_unusable, search_not_configured, search_failed, ai_failed,
|
||||
-- interrupted, grounding_error). It is NULL unless grounding_status is
|
||||
-- failed.
|
||||
|
||||
ALTER TABLE dinners ADD COLUMN grounding_error TEXT;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 0010_dinner_grounding_revision: each ground_dinner job carries the
|
||||
-- dinner's grounding_revision when it was enqueued, and records its result
|
||||
-- only while the dinner still has that revision. Renaming a pending dinner
|
||||
-- raises the revision before enqueuing the job for the new name, so an
|
||||
-- earlier job — queued or running — can no longer write the dinner.
|
||||
|
||||
ALTER TABLE dinners ADD COLUMN grounding_revision INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 0011_ground_dinner_job_revision: every dinner starts at grounding
|
||||
-- revision 0 (0010), so a ground_dinner job enqueued before 0010, whose
|
||||
-- payload has only dinner_id, is for revision 0. Recording it lets the boot
|
||||
-- cleanup, which matches a pending dinner to its job by revision, see a job
|
||||
-- still queued for its dinner. This is a migration of its own, not part of
|
||||
-- 0010, so it also runs on a database that has already applied 0010.
|
||||
|
||||
UPDATE jobs
|
||||
SET payload = json_set(payload, '$.revision', 0)
|
||||
WHERE kind = 'ground_dinner'
|
||||
AND json_type(payload, '$.revision') IS NULL;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- 0012_suggestion_name: a suggestion keeps the name its dish was suggested
|
||||
-- under. Its dinner can be renamed, even once archived by a Pass, so the
|
||||
-- declined names every later batch refuses are read from here, never from
|
||||
-- the dinner. A suggestion stored before this takes its dinner's name now.
|
||||
-- SQLite cannot add a NOT NULL column without a default, so the table is
|
||||
-- rebuilt as 0005 does: copy the rows aside, drop the table, create it
|
||||
-- again, copy the rows back and drop the copy. Nothing references
|
||||
-- suggestions.
|
||||
|
||||
CREATE TABLE suggestions_copy AS SELECT * FROM suggestions;
|
||||
|
||||
DROP TABLE suggestions;
|
||||
|
||||
CREATE TABLE suggestions (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
dinner_id TEXT NOT NULL REFERENCES dinners (id),
|
||||
batch_id TEXT NOT NULL REFERENCES suggestion_batches (id),
|
||||
name TEXT NOT NULL CHECK (name <> ''),
|
||||
reason TEXT NOT NULL,
|
||||
cited_regular_ids TEXT NOT NULL, -- JSON array of dinner ids
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'declined')),
|
||||
decided_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO suggestions (
|
||||
id, dinner_id, batch_id, name, reason, cited_regular_ids, status, decided_at, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
suggestions_copy.id, suggestions_copy.dinner_id, suggestions_copy.batch_id, dinners.name,
|
||||
suggestions_copy.reason, suggestions_copy.cited_regular_ids, suggestions_copy.status,
|
||||
suggestions_copy.decided_at, suggestions_copy.created_at, suggestions_copy.updated_at
|
||||
FROM suggestions_copy
|
||||
JOIN dinners ON dinners.id = suggestions_copy.dinner_id;
|
||||
|
||||
DROP TABLE suggestions_copy;
|
||||
@@ -1,12 +1,12 @@
|
||||
-- name: CreateDinner :one
|
||||
INSERT INTO dinners (
|
||||
id, name, normalized_name, status, origin, method, minutes_total,
|
||||
difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status,
|
||||
updated_by, created_at, updated_at
|
||||
difficulty, cuisine, is_favorite, recipe_id, recipe_url, photo_id,
|
||||
grounding_status, updated_by, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?
|
||||
?, ?, ?, ?
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
@@ -19,6 +19,20 @@ SELECT * FROM dinners
|
||||
WHERE status = ?
|
||||
ORDER BY normalized_name, id;
|
||||
|
||||
-- name: CountRegularDinners :one
|
||||
-- How many Regulars the household has; archived dinners are not counted.
|
||||
SELECT COUNT(*) FROM dinners
|
||||
WHERE status = 'regular';
|
||||
|
||||
-- name: ListUnarchivedDinners :many
|
||||
-- Every dinner a new or renamed dinner must not repeat, with the
|
||||
-- source_url of the recipe it is grounded in (NULL while it has none).
|
||||
SELECT sqlc.embed(dinners), recipes.source_url AS recipe_source_url
|
||||
FROM dinners
|
||||
LEFT JOIN recipes ON recipes.id = dinners.recipe_id
|
||||
WHERE dinners.status <> 'archived'
|
||||
ORDER BY dinners.id;
|
||||
|
||||
-- name: UpdateDinner :one
|
||||
-- An unknown id returns sql.ErrNoRows.
|
||||
UPDATE dinners
|
||||
@@ -31,8 +45,10 @@ SET name = ?,
|
||||
difficulty = ?,
|
||||
cuisine = ?,
|
||||
recipe_id = ?,
|
||||
recipe_url = ?,
|
||||
photo_id = ?,
|
||||
grounding_status = ?,
|
||||
grounding_revision = ?,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
@@ -55,3 +71,98 @@ SET is_favorite = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING *;
|
||||
|
||||
-- name: ToggleDinnerFavorite :one
|
||||
-- An unknown id returns sql.ErrNoRows.
|
||||
UPDATE dinners
|
||||
SET is_favorite = 1 - is_favorite,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING *;
|
||||
|
||||
-- name: GroundDinner :one
|
||||
-- Records a dinner's grounding by hand (grounded or manual) and clears any
|
||||
-- grounding_error and pasted recipe_url: the recipe chosen replaces the
|
||||
-- link. It writes only an unarchived dinner still as it was observed
|
||||
-- before grounding (same name, normalized_name, grounding_status,
|
||||
-- grounding_revision and recipe_id), so a dinner renamed, archived or
|
||||
-- grounded some other way meanwhile is left as it is. That dinner, or an
|
||||
-- unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
|
||||
-- no longer as observed is kept: a member edited it while it was grounding.
|
||||
UPDATE dinners
|
||||
SET recipe_id = sqlc.arg(recipe_id),
|
||||
recipe_url = NULL,
|
||||
photo_id = sqlc.arg(photo_id),
|
||||
method = CASE WHEN method IS sqlc.narg(observed_method)
|
||||
THEN sqlc.arg(method) ELSE method END,
|
||||
minutes_total = CASE WHEN minutes_total IS sqlc.narg(observed_minutes_total)
|
||||
THEN sqlc.arg(minutes_total) ELSE minutes_total END,
|
||||
difficulty = CASE WHEN difficulty IS sqlc.narg(observed_difficulty)
|
||||
THEN sqlc.arg(difficulty) ELSE difficulty END,
|
||||
grounding_status = sqlc.arg(grounding_status),
|
||||
grounding_error = NULL,
|
||||
updated_by = sqlc.arg(updated_by),
|
||||
updated_at = sqlc.arg(updated_at)
|
||||
WHERE id = sqlc.arg(id)
|
||||
AND status <> 'archived'
|
||||
AND name = sqlc.arg(observed_name)
|
||||
AND normalized_name = sqlc.arg(observed_normalized_name)
|
||||
AND grounding_status = sqlc.arg(observed_grounding_status)
|
||||
AND grounding_revision = sqlc.arg(observed_grounding_revision)
|
||||
AND recipe_id IS sqlc.narg(observed_recipe_id)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GroundPendingDinner :execrows
|
||||
-- Records the background grounding of an unarchived dinner still pending at
|
||||
-- the grounding_revision it was grounded for; a dinner grounded some other
|
||||
-- way, renamed or archived meanwhile is left as it is (0 rows). A method,
|
||||
-- minutes_total or difficulty already set is kept: a pending dinner has
|
||||
-- them only when a member edited them while it was grounding.
|
||||
UPDATE dinners
|
||||
SET recipe_id = sqlc.arg(recipe_id),
|
||||
photo_id = sqlc.arg(photo_id),
|
||||
method = COALESCE(method, sqlc.arg(method)),
|
||||
minutes_total = COALESCE(minutes_total, sqlc.arg(minutes_total)),
|
||||
difficulty = COALESCE(difficulty, sqlc.arg(difficulty)),
|
||||
grounding_status = 'grounded',
|
||||
grounding_error = NULL,
|
||||
updated_at = sqlc.arg(updated_at)
|
||||
WHERE id = sqlc.arg(id)
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'pending'
|
||||
AND grounding_revision = sqlc.arg(grounding_revision);
|
||||
|
||||
-- name: FailPendingDinnerGrounding :execrows
|
||||
-- Records why the background grounding of an unarchived dinner still
|
||||
-- pending at the grounding_revision it was grounded for failed; a dinner
|
||||
-- grounded some other way, renamed or archived meanwhile is left as it is
|
||||
-- (0 rows).
|
||||
UPDATE dinners
|
||||
SET grounding_status = 'failed',
|
||||
grounding_error = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'pending'
|
||||
AND grounding_revision = ?;
|
||||
|
||||
-- name: FailOrphanedPendingDinnerGroundings :execrows
|
||||
-- Fails every unarchived dinner still pending whose grounding job for its
|
||||
-- current grounding_revision is no longer queued or running: the process
|
||||
-- stopped before the job, or before its handler recorded a result. An
|
||||
-- archived dinner is left as it is; archiving stopped its grounding. Run at
|
||||
-- boot, after the job runner fails the jobs a previous process left running.
|
||||
UPDATE dinners
|
||||
SET grounding_status = 'failed',
|
||||
grounding_error = sqlc.arg(grounding_error),
|
||||
updated_at = sqlc.arg(updated_at)
|
||||
WHERE grounding_status = 'pending'
|
||||
AND status <> 'archived'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM jobs
|
||||
WHERE jobs.kind = sqlc.arg(kind)
|
||||
AND jobs.status IN ('queued', 'running')
|
||||
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
|
||||
AND json_extract(jobs.payload, '$.revision') = dinners.grounding_revision
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
-- Reads for the AI household context block (internal/ai/harness).
|
||||
|
||||
-- name: ListRecentDeclinedNames :many
|
||||
-- The names of the most recently declined suggestions, newest first.
|
||||
SELECT dinners.name
|
||||
-- The names the most recently declined suggestions were suggested under,
|
||||
-- newest first; renaming their dinners does not change them.
|
||||
SELECT name
|
||||
FROM suggestions
|
||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
||||
WHERE suggestions.status = 'declined'
|
||||
ORDER BY suggestions.decided_at DESC, suggestions.id DESC
|
||||
WHERE status = 'declined'
|
||||
ORDER BY decided_at DESC, id DESC
|
||||
LIMIT ?;
|
||||
|
||||
-- name: ListPlannedDinners :many
|
||||
|
||||
@@ -69,3 +69,15 @@ ORDER BY position, id;
|
||||
-- name: DeleteRecipeSteps :exec
|
||||
DELETE FROM recipe_steps
|
||||
WHERE recipe_id = ?;
|
||||
|
||||
-- name: CreateManualRecipe :one
|
||||
-- A recipe the household typed in. Its source_url is a mealquest:manual:
|
||||
-- URN rather than a page, so it never matches a fetched page.
|
||||
INSERT INTO recipes (
|
||||
id, source_url, title, total_minutes, raw_jsonld, method,
|
||||
minutes_total, fetched_at, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
-- name: CreateSuggestionBatch :one
|
||||
INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
|
||||
VALUES (?, ?, 'pending', ?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetActiveSuggestionBatch :one
|
||||
-- The batch still pending or running, oldest first. Returns sql.ErrNoRows
|
||||
-- when there is none.
|
||||
SELECT * FROM suggestion_batches
|
||||
WHERE status IN ('pending', 'running')
|
||||
ORDER BY id
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetSuggestionBatchJobID :one
|
||||
-- The id of the job that runs batch batch_id.
|
||||
SELECT id FROM jobs
|
||||
WHERE kind = sqlc.arg(kind)
|
||||
AND json_extract(payload, '$.batch_id') = sqlc.arg(batch_id)
|
||||
ORDER BY id
|
||||
LIMIT 1;
|
||||
|
||||
-- name: StartSuggestionBatch :execrows
|
||||
-- Marks a pending batch running; a batch in any other status is left as
|
||||
-- it is (0 rows).
|
||||
UPDATE suggestion_batches
|
||||
SET status = 'running', updated_at = ?
|
||||
WHERE id = ? AND status = 'pending';
|
||||
|
||||
-- name: FinishSuggestionBatch :execrows
|
||||
-- Records a pending or running batch as done or failed; a batch already
|
||||
-- finished is left as it is (0 rows).
|
||||
UPDATE suggestion_batches
|
||||
SET status = ?, updated_at = ?
|
||||
WHERE id = ? AND status IN ('pending', 'running');
|
||||
|
||||
-- name: FailOrphanedSuggestionBatches :execrows
|
||||
-- Fails every batch still pending or running whose job is no longer queued
|
||||
-- or running: the process stopped, or recording the batch's end failed,
|
||||
-- before its handler recorded a result. Run before reading the active batch.
|
||||
UPDATE suggestion_batches
|
||||
SET status = 'failed', updated_at = sqlc.arg(updated_at)
|
||||
WHERE status IN ('pending', 'running')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM jobs
|
||||
WHERE jobs.kind = sqlc.arg(kind)
|
||||
AND jobs.status IN ('queued', 'running')
|
||||
AND json_extract(jobs.payload, '$.batch_id') = suggestion_batches.id
|
||||
);
|
||||
|
||||
-- name: CreateSuggestion :exec
|
||||
-- name is the name the dish is suggested under, kept whatever its dinner is
|
||||
-- later renamed.
|
||||
INSERT INTO suggestions (
|
||||
id, dinner_id, batch_id, name, reason, cited_regular_ids, status, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, 'pending', ?, ?
|
||||
);
|
||||
|
||||
-- name: GetSuggestion :one
|
||||
-- A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
|
||||
SELECT sqlc.embed(suggestions), sqlc.embed(dinners)
|
||||
FROM suggestions
|
||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
||||
WHERE suggestions.id = ?;
|
||||
|
||||
-- name: ListDeck :many
|
||||
-- The pending suggestions whose dinner is still suggested, oldest first,
|
||||
-- with their dinners.
|
||||
SELECT sqlc.embed(suggestions), sqlc.embed(dinners)
|
||||
FROM suggestions
|
||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
||||
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested'
|
||||
ORDER BY suggestions.created_at, suggestions.id;
|
||||
|
||||
-- name: CountDeck :one
|
||||
-- How many suggestions ListDeck returns.
|
||||
SELECT COUNT(*)
|
||||
FROM suggestions
|
||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
||||
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested';
|
||||
|
||||
-- name: ListDeclinedSuggestionNames :many
|
||||
-- The name every declined suggestion was suggested under, oldest decision
|
||||
-- first; renaming its dinner does not change it.
|
||||
SELECT name
|
||||
FROM suggestions
|
||||
WHERE status = 'declined'
|
||||
ORDER BY decided_at, id;
|
||||
|
||||
-- name: DecideSuggestion :execrows
|
||||
-- Records the household's decision on a pending suggestion; one already
|
||||
-- decided is left as it is (0 rows).
|
||||
UPDATE suggestions
|
||||
SET status = sqlc.arg(status), decided_at = sqlc.arg(now), updated_at = sqlc.arg(now)
|
||||
WHERE id = sqlc.arg(id) AND status = 'pending';
|
||||
|
||||
-- name: MoveSuggestedDinner :execrows
|
||||
-- Moves a dinner still suggested to status (quest_log or archived),
|
||||
-- recording updated_by; a dinner no longer suggested is left as it is
|
||||
-- (0 rows).
|
||||
UPDATE dinners
|
||||
SET status = sqlc.arg(status), updated_by = sqlc.arg(updated_by), updated_at = sqlc.arg(updated_at)
|
||||
WHERE id = sqlc.arg(id) AND status = 'suggested';
|
||||
+140
-13
@@ -1,11 +1,13 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApiClient, createVerifiedSession } from './api/index.ts'
|
||||
import { createNetworkStatus } from './pwa/networkStatus.ts'
|
||||
import { mockViewport } from './test/matchMedia.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
|
||||
import App, { routes } from './App.tsx'
|
||||
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { InDataRouter } from './test/dataRouter.tsx'
|
||||
import { dinnerBody } from './test/dinners.ts'
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
@@ -19,12 +21,29 @@ function renderAt(path: string, location = fakeLocation(path)) {
|
||||
|
||||
function renderAppAt(path: string, location = fakeLocation(path)) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<InDataRouter path={path}>
|
||||
<App api={createApiClient(location, createNetworkStatus(window), createVerifiedSession(localStorage))} serviceWorker={undefined} />
|
||||
</MemoryRouter>,
|
||||
</InDataRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
// The routes that render a built screen rather than a placeholder page.
|
||||
const SCREENS = {
|
||||
'/onboarding': true,
|
||||
'/discover': true,
|
||||
'/settings': true,
|
||||
'/settings/ai': true,
|
||||
'/settings/household': true,
|
||||
'/settings/regulars': true,
|
||||
}
|
||||
|
||||
// SIGNED_IN answers what every route reads first: the session, then the
|
||||
// household settings, onboarding finished.
|
||||
const SIGNED_IN = {
|
||||
'/api/v1/me': () => jsonResponse(200, ME),
|
||||
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||
}
|
||||
|
||||
describe('App routes', () => {
|
||||
it('registers every handoff route plus the extra settings pages', () => {
|
||||
expect(routes.map((route) => route.path)).toEqual([
|
||||
@@ -46,23 +65,126 @@ describe('App routes', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
|
||||
it.each(routes.filter(({ path }) => !Object.hasOwn(SCREENS, path)).map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
|
||||
'%s renders its placeholder page inside the shell',
|
||||
async (path, title) => {
|
||||
mockViewport(390)
|
||||
stubFetch(jsonResponse(200, ME))
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
renderAt(path)
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: title })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('/settings renders the settings home inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
'/api/v1/me': () => jsonResponse(200, ME),
|
||||
...SETTINGS_API,
|
||||
})
|
||||
renderAt('/settings')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Settings' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/settings/ai renders the AI assistant screen inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
...SIGNED_IN,
|
||||
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||
'/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }),
|
||||
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||
})
|
||||
renderAt('/settings/ai')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'AI assistant' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/discover renders the Side quests deck inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
...SIGNED_IN,
|
||||
'/api/v1/suggestions/deck': () => jsonResponse(200, { suggestions: [] }),
|
||||
'/api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }),
|
||||
})
|
||||
renderAt('/discover')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Side quests' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByText('Quest log · 0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/settings/household renders the household settings screen inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
renderAt('/settings/household')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Household' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByLabelText('Dinner time')).toHaveValue('17:30')
|
||||
})
|
||||
|
||||
it('/settings/regulars renders the Regular dinners screen inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
...SIGNED_IN,
|
||||
'/api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: [dinnerBody('d1', 'Beef tacos')] }),
|
||||
})
|
||||
renderAt('/settings/regulars')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Regular dinners' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App first-run redirect', () => {
|
||||
const FIRST_RUN = {
|
||||
'/api/v1/me': () => jsonResponse(200, ME),
|
||||
'/api/v1/settings/household': () => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }),
|
||||
}
|
||||
|
||||
it.each(routes.filter(({ path }) => path !== '/onboarding').map(({ path }) => path.replace(':id', 'abc123')))(
|
||||
'%s redirects to /onboarding while onboarding is not finished',
|
||||
async (path) => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath(FIRST_RUN)
|
||||
renderAt(path)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('stays on an onboarding step', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({ ...FIRST_RUN, '/api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: [] }) })
|
||||
renderAt('/onboarding/regulars')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the routes once onboarding is finished', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
renderAt('/plan')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a household settings failure with a retry instead of rendering the routes', async () => {
|
||||
mockViewport(390)
|
||||
stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
renderAt('/plan')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
screen.getByRole('button', { name: 'Retry' }).click()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App bootstrap', () => {
|
||||
it('shows the skeleton until /me loads, then the routes', async () => {
|
||||
mockViewport(390)
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
renderAt('/plan')
|
||||
|
||||
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
|
||||
@@ -96,7 +218,7 @@ describe('App bootstrap', () => {
|
||||
|
||||
it('reports a /me failure with a retry instead of rendering the routes', async () => {
|
||||
mockViewport(390)
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME))
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
renderAt('/')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||
@@ -116,7 +238,7 @@ describe('App offline bootstrap', () => {
|
||||
// visitOnline is an earlier online visit on this device: /me answers with a
|
||||
// member, then the app is closed.
|
||||
async function visitOnline() {
|
||||
stubFetch(jsonResponse(200, ME))
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
const { unmount } = renderAt('/')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
unmount()
|
||||
@@ -177,19 +299,24 @@ describe('App offline bootstrap', () => {
|
||||
goOffline()
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/plan']}>
|
||||
<InDataRouter path="/plan">
|
||||
<App api={api} serviceWorker={undefined} />
|
||||
</MemoryRouter>,
|
||||
</InDataRouter>,
|
||||
)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
|
||||
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||
const fetchMock = stubFetchByPath(SIGNED_IN)
|
||||
onLine = true
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('online'))
|
||||
})
|
||||
await vi.waitFor(() => expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' }))
|
||||
await vi.waitFor(() =>
|
||||
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
||||
'/api/v1/me',
|
||||
'/api/v1/settings/household',
|
||||
]),
|
||||
)
|
||||
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
+31
-7
@@ -1,11 +1,18 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Route, Routes } from 'react-router'
|
||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
||||
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
||||
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
|
||||
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
||||
import { OfflinePill } from './pwa/OfflinePill.tsx'
|
||||
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
||||
import { AISettingsPage } from './settings/AISettingsPage.tsx'
|
||||
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
|
||||
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
|
||||
import { SettingsPage } from './settings/SettingsPage.tsx'
|
||||
import { AppShell } from './shell/AppShell.tsx'
|
||||
|
||||
// Every route the app serves, with the heading its placeholder page shows.
|
||||
@@ -27,14 +34,30 @@ 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>> = {
|
||||
'/discover': <DiscoverPage />,
|
||||
'/settings': <SettingsPage />,
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
'/settings/household': <HouseholdSettingsPage />,
|
||||
'/settings/regulars': <RegularsSettingsPage />,
|
||||
}
|
||||
|
||||
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
|
||||
// Onboarding is a full screen of its own, outside the shell; until it is
|
||||
// finished, OnboardingGate sends every other route to it.
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
{routes.map(({ path, title }) => (
|
||||
<Route key={path} path={path} element={<PlaceholderPage title={title} />} />
|
||||
))}
|
||||
<Route element={<OnboardingGate />}>
|
||||
<Route path={`${ONBOARDING_PATH}/*`} element={<OnboardingPage />} />
|
||||
<Route element={<AppShell />}>
|
||||
{routes
|
||||
.filter(({ path }) => path !== ONBOARDING_PATH)
|
||||
.map(({ path, title }) => (
|
||||
<Route key={path} path={path} element={screens[path] ?? <PlaceholderPage title={title} />} />
|
||||
))}
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
@@ -50,8 +73,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 (
|
||||
|
||||
@@ -41,6 +41,11 @@ export function AuthBootstrap({ children }: { children: ReactNode }) {
|
||||
</main>
|
||||
)
|
||||
}
|
||||
return <LoadingScreen />
|
||||
}
|
||||
|
||||
// LoadingScreen is the full-screen skeleton shown while MealQuest loads.
|
||||
export function LoadingScreen() {
|
||||
return (
|
||||
<main className={styles.screen} aria-busy="true" aria-label="Loading MealQuest">
|
||||
<div className={styles.loading}>
|
||||
|
||||
@@ -14,7 +14,7 @@ function job(status: string, result: Draft | null, error: string | null = null)
|
||||
function renderJob(jobId: string | null) {
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <ApiProvider client={api}>{children}</ApiProvider>
|
||||
return renderHook(({ id }) => useJob<Draft>(id), { wrapper, initialProps: { id: jobId } })
|
||||
return renderHook(({ id }) => useJob<Draft>('plan.draft', id), { wrapper, initialProps: { id: jobId } })
|
||||
}
|
||||
|
||||
// advance runs the timers due in ms and lets the polls they start answer.
|
||||
@@ -42,7 +42,7 @@ describe('useJob', () => {
|
||||
job('succeeded', { dinners: ['Tacos', 'Chili', 'Soup'] }),
|
||||
)
|
||||
const { result } = renderJob('j1')
|
||||
expect(result.current).toEqual({ status: undefined, result: null, error: null })
|
||||
expect(result.current).toMatchObject({ status: undefined, result: null, error: null })
|
||||
|
||||
await advance(0)
|
||||
expect(result.current.status).toBe('queued')
|
||||
@@ -51,7 +51,7 @@ describe('useJob', () => {
|
||||
await advance(499)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
await advance(1)
|
||||
expect(result.current).toEqual({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
|
||||
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
|
||||
|
||||
await advance(999)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
@@ -62,7 +62,7 @@ describe('useJob', () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
await advance(2000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5)
|
||||
expect(result.current).toEqual({
|
||||
expect(result.current).toMatchObject({
|
||||
status: 'succeeded',
|
||||
result: { dinners: ['Tacos', 'Chili', 'Soup'] },
|
||||
error: null,
|
||||
@@ -106,10 +106,76 @@ describe('useJob', () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
await advance(1)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
expect(result.current).toEqual({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null })
|
||||
expect(result.current).toMatchObject({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an unknown status', { id: 'j1', kind: 'plan.draft', status: 'stuck', result: null, error: null }],
|
||||
['another job', { id: 'j2', kind: 'plan.draft', status: 'running', result: null, error: null }],
|
||||
['another kind', { id: 'j1', kind: 'suggestion_batch', status: 'running', result: null, error: null }],
|
||||
['no result', { id: 'j1', kind: 'plan.draft', status: 'running', error: null }],
|
||||
['an error that is not text', { id: 'j1', kind: 'plan.draft', status: 'failed', result: null, error: 500 }],
|
||||
['not a job', ['j1']],
|
||||
])('exposes an answer with %s as the error and stops, keeping the last status', async (_, body) => {
|
||||
const fetchMock = stubFetch(job('running', { dinners: ['Tacos'] }), jsonResponse(200, body))
|
||||
const { result } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
await advance(500)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] } })
|
||||
expect(result.current.error?.message).toBe('GET /api/v1/jobs/j1: response is not the plan.draft job j1')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await advance(10_000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('polls again at once on poll after stopping on an answer that is not the job, then keeps polling', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
jsonResponse(200, { id: 'j1', kind: 'plan.draft', status: 'stuck', result: null, error: null }),
|
||||
job('running', { dinners: ['Tacos'] }),
|
||||
job('succeeded', { dinners: ['Tacos', 'Chili'] }),
|
||||
)
|
||||
const { result } = renderJob('j1')
|
||||
await advance(0)
|
||||
expect(result.current.error).not.toBeNull()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
act(() => result.current.poll())
|
||||
await advance(0)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
|
||||
await advance(500)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
expect(result.current).toMatchObject({ status: 'succeeded', error: null })
|
||||
})
|
||||
|
||||
it('polls at once on poll during the backoff after a failed poll, starting the backoff again', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
job('running', null),
|
||||
errorResponse(503, 'unavailable', true),
|
||||
errorResponse(503, 'unavailable', true),
|
||||
job('running', { dinners: ['Tacos'] }),
|
||||
job('succeeded', { dinners: ['Tacos'] }),
|
||||
)
|
||||
const { result } = renderJob('j1')
|
||||
await advance(0)
|
||||
await advance(500)
|
||||
await advance(1000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
|
||||
// The next poll was due 2 s later; poll asks now.
|
||||
act(() => result.current.poll())
|
||||
await advance(0)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
await advance(500)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5)
|
||||
expect(result.current.status).toBe('succeeded')
|
||||
})
|
||||
|
||||
it('stops polling on unmount after a failed poll', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(503, 'unavailable', true))
|
||||
const { result, unmount } = renderJob('j1')
|
||||
@@ -125,12 +191,15 @@ describe('useJob', () => {
|
||||
})
|
||||
|
||||
it('stops polling the old job when the job id changes', async () => {
|
||||
const fetchMock = stubFetch(job('running', null), job('running', null))
|
||||
const fetchMock = stubFetch(
|
||||
job('running', null),
|
||||
jsonResponse(200, { id: 'j2', kind: 'plan.draft', status: 'running', result: null, error: null }),
|
||||
)
|
||||
const { result, rerender } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
rerender({ id: 'j2' })
|
||||
expect(result.current).toEqual({ status: undefined, result: null, error: null })
|
||||
expect(result.current).toMatchObject({ status: undefined, result: null, error: null })
|
||||
await advance(0)
|
||||
expect(request(fetchMock, 1).path).toBe('/api/v1/jobs/j2')
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
@@ -165,6 +234,6 @@ describe('useJob', () => {
|
||||
const { result } = renderJob(null)
|
||||
await advance(5000)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(result.current).toEqual({ status: undefined, result: null, error: null })
|
||||
expect(result.current).toMatchObject({ status: undefined, result: null, error: null })
|
||||
})
|
||||
})
|
||||
|
||||
+73
-28
@@ -1,8 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useApi } from './ApiContext.tsx'
|
||||
import { isRecord } from './errors.ts'
|
||||
|
||||
export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed'
|
||||
|
||||
const STATUSES: readonly string[] = ['queued', 'running', 'succeeded', 'failed'] satisfies JobStatus[]
|
||||
|
||||
// Job is the GET /api/v1/jobs/{id} response. result may be set while the job
|
||||
// is running (a partial result); error is set only when it failed.
|
||||
export type Job<R> = {
|
||||
@@ -18,28 +21,56 @@ export type JobState<R> = {
|
||||
status: JobStatus | undefined
|
||||
// The latest result, partial while the job runs.
|
||||
result: R | null
|
||||
// The job's failure, or the latest polling request's own failure (cleared
|
||||
// by the next poll that answers).
|
||||
// The job's failure, the latest polling request's own failure (cleared
|
||||
// by the next poll that answers), or an answer that is not this job.
|
||||
error: Error | null
|
||||
// poll asks for the job now, starting the backoff again — also after
|
||||
// polling stopped on an answer that is not this job.
|
||||
poll: () => void
|
||||
}
|
||||
|
||||
// Polling starts at FIRST_DELAY_MS and doubles up to MAX_DELAY_MS.
|
||||
export const FIRST_DELAY_MS = 500
|
||||
export const MAX_DELAY_MS = 2000
|
||||
|
||||
type JobData<R> = Omit<JobState<R>, 'poll'>
|
||||
|
||||
const idle = { status: undefined, result: null, error: null }
|
||||
|
||||
// parseJob checks a GET /api/v1/jobs/{id} answer is the kind of job asked
|
||||
// for, with a known status. result is passed on unread for the caller to
|
||||
// check.
|
||||
function parseJob<R>(where: string, kind: string, jobId: string, body: unknown): Job<R> {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
body.id !== jobId ||
|
||||
body.kind !== kind ||
|
||||
typeof body.status !== 'string' ||
|
||||
!STATUSES.includes(body.status) ||
|
||||
!('result' in body) ||
|
||||
(body.error !== null && typeof body.error !== 'string')
|
||||
) {
|
||||
throw new Error(`${where}: response is not the ${kind} job ${jobId}`)
|
||||
}
|
||||
return { id: jobId, kind, status: body.status as JobStatus, result: body.result as R | null, error: body.error as string | null }
|
||||
}
|
||||
|
||||
function isTerminal(status: JobStatus): boolean {
|
||||
return status === 'succeeded' || status === 'failed'
|
||||
}
|
||||
|
||||
// useJob polls GET /api/v1/jobs/{jobId} until the job succeeds or fails,
|
||||
// backing off from 500 ms to 2 s. A failed poll is exposed as the error and
|
||||
// polling continues. Polling stops only at a terminal status, when jobId
|
||||
// changes and on unmount. A null jobId polls nothing.
|
||||
export function useJob<R>(jobId: string | null): JobState<R> {
|
||||
// useJob polls GET /api/v1/jobs/{jobId}, a job of the given kind, until it
|
||||
// succeeds or fails, backing off from 500 ms to 2 s. A failed poll is
|
||||
// exposed as the error and polling continues. An answer that is not this
|
||||
// job is exposed as the error and stops polling until poll is called.
|
||||
// Polling also stops at a terminal status, when jobId changes and on
|
||||
// unmount. A null jobId polls nothing.
|
||||
export function useJob<R>(kind: string, jobId: string | null): JobState<R> {
|
||||
const { apiFetch } = useApi()
|
||||
const [state, setState] = useState<{ jobId: string | null; job: JobState<R> }>({ jobId, job: idle })
|
||||
const [state, setState] = useState<{ jobId: string | null; job: JobData<R> }>({ jobId, job: idle })
|
||||
// Each poll call bumps round, which restarts the polling effect.
|
||||
const [round, setRound] = useState(0)
|
||||
const poll = useCallback(() => setRound((r) => r + 1), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (jobId === null) return
|
||||
@@ -47,37 +78,51 @@ export function useJob<R>(jobId: string | null): JobState<R> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let delay = FIRST_DELAY_MS
|
||||
|
||||
function next() {
|
||||
timer = setTimeout(poll, delay)
|
||||
delay = Math.min(delay * 2, MAX_DELAY_MS)
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
async function fetchJob() {
|
||||
const path = `/api/v1/jobs/${encodeURIComponent(jobId!)}`
|
||||
let body: unknown
|
||||
try {
|
||||
const job = await apiFetch<Job<R>>(`/api/v1/jobs/${encodeURIComponent(jobId!)}`)
|
||||
if (!active) return
|
||||
const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null
|
||||
setState({ jobId, job: { status: job.status, result: job.result, error } })
|
||||
if (isTerminal(job.status)) return
|
||||
body = await apiFetch<unknown>(path)
|
||||
} catch (error) {
|
||||
if (!active) return
|
||||
// A failed poll is not the job's end: keep the last known status and
|
||||
// result, expose the request's error, and poll again.
|
||||
setState((prev) => ({
|
||||
jobId,
|
||||
job: { ...(prev.jobId === jobId ? prev.job : idle), error: error instanceof Error ? error : new Error(String(error)) },
|
||||
}))
|
||||
fail(error instanceof Error ? error : new Error(String(error)))
|
||||
next()
|
||||
return
|
||||
}
|
||||
next()
|
||||
if (!active) return
|
||||
let job: Job<R>
|
||||
try {
|
||||
job = parseJob<R>(`GET ${path}`, kind, jobId!, body)
|
||||
} catch (error) {
|
||||
// An answer that is not this job will not become one by asking
|
||||
// again: expose it and stop.
|
||||
fail(error as Error)
|
||||
return
|
||||
}
|
||||
const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null
|
||||
setState({ jobId, job: { status: job.status, result: job.result, error } })
|
||||
if (!isTerminal(job.status)) next()
|
||||
}
|
||||
|
||||
void poll()
|
||||
// fail keeps the last known status and result, with error.
|
||||
function fail(error: Error) {
|
||||
setState((prev) => ({ jobId, job: { ...(prev.jobId === jobId ? prev.job : idle), error } }))
|
||||
}
|
||||
|
||||
function next() {
|
||||
timer = setTimeout(fetchJob, delay)
|
||||
delay = Math.min(delay * 2, MAX_DELAY_MS)
|
||||
}
|
||||
|
||||
void fetchJob()
|
||||
return () => {
|
||||
active = false
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [apiFetch, jobId])
|
||||
}, [apiFetch, kind, jobId, round])
|
||||
|
||||
// State left from a previous jobId is not this job's.
|
||||
return state.jobId === jobId ? state.job : idle
|
||||
return { ...(state.jobId === jobId ? state.job : idle), poll }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useIsMutating, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
|
||||
export const DINNERS_PATH = '/api/v1/dinners'
|
||||
export const REGULARS_PATH = `${DINNERS_PATH}?status=regular`
|
||||
|
||||
// GROUNDING_POLL_MS is how often the Regulars are read again while any of
|
||||
// them is still finding its recipe.
|
||||
export const GROUNDING_POLL_MS = 3000
|
||||
|
||||
// Methods and Difficulties are the values the dinners API allows.
|
||||
export const METHODS = ['stovetop', 'oven', 'crockpot', 'grill', 'other'] as const
|
||||
export const DIFFICULTIES = ['easy', 'medium', 'hard'] as const
|
||||
const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const
|
||||
|
||||
export type Method = (typeof METHODS)[number]
|
||||
export type Difficulty = (typeof DIFFICULTIES)[number]
|
||||
export type GroundingStatus = (typeof GROUNDING_STATUSES)[number]
|
||||
|
||||
// Regular is the part of a dinner the Regulars screens read. photo_id is
|
||||
// the stored photo's id, from photo_url. pending is true while an
|
||||
// optimistic add waits for the server; its id is not the server's.
|
||||
export type Regular = {
|
||||
id: string
|
||||
name: string
|
||||
method: Method | null
|
||||
minutes_total: number | null
|
||||
difficulty: Difficulty | null
|
||||
is_favorite: boolean
|
||||
photo_id: string | undefined
|
||||
grounding_status: GroundingStatus
|
||||
pending?: true
|
||||
}
|
||||
|
||||
// NewRegular is a POST /api/v1/dinners body: the household's name for the
|
||||
// dinner, with the recipe link to ground it in when one was pasted. A
|
||||
// dinner is never named from its link, which may carry private details.
|
||||
export type NewRegular = { name: string; recipe_url?: string }
|
||||
|
||||
// RegularPatch is a PATCH /api/v1/dinners/{id} body; every field is optional.
|
||||
export type RegularPatch = Partial<{ name: string; method: Method; minutes_total: number; difficulty: Difficulty }>
|
||||
|
||||
// ManualRecipe is a recipe the household types in; the server needs its
|
||||
// minutes and method.
|
||||
export type ManualRecipe = { ingredients: string[]; steps: string[]; minutes: number; method: Method }
|
||||
|
||||
// RecipeFix is a POST /api/v1/dinners/{id}/ground body: exactly one way to
|
||||
// ground the dinner.
|
||||
export type RecipeFix = { candidate_id: string } | { recipe_url: string } | { manual: ManualRecipe }
|
||||
|
||||
// RecipeCandidate is one recipe page a dinner could be grounded in.
|
||||
export type RecipeCandidate = {
|
||||
candidate_id: string
|
||||
title: string
|
||||
site_name: string
|
||||
photo_id: string | undefined
|
||||
minutes: number | null
|
||||
}
|
||||
|
||||
// CachedRegular is a Regular as the cache holds it. removing is true while
|
||||
// an optimistic removal waits for the server: the entry keeps its place, so
|
||||
// a refused removal returns exactly there whatever else changed meanwhile.
|
||||
type CachedRegular = Regular & { removing?: true }
|
||||
|
||||
function shown(list: CachedRegular[]): Regular[] {
|
||||
return list.filter((regular) => !regular.removing)
|
||||
}
|
||||
|
||||
function restored(regular: CachedRegular): CachedRegular {
|
||||
const copy = { ...regular }
|
||||
delete copy.removing
|
||||
return copy
|
||||
}
|
||||
|
||||
// REGULARS_KEY prefixes every Regulars query, so a change refreshes them all
|
||||
// (the settings home's count included).
|
||||
export const REGULARS_KEY = ['dinners', 'regular'] as const
|
||||
const REGULARS_LIST_KEY = [...REGULARS_KEY, 'list'] as const
|
||||
const REGULARS_MUTATION_KEY = ['dinners', 'regular', 'change'] as const
|
||||
|
||||
export function oneOf<T extends string>(where: string, field: string, values: readonly T[], value: unknown): T {
|
||||
if (typeof value !== 'string' || !(values as readonly string[]).includes(value)) {
|
||||
throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not one of ${values.join(', ')}`)
|
||||
}
|
||||
return value as T
|
||||
}
|
||||
|
||||
function nullable<T>(value: unknown, parse: (value: unknown) => T): T | null {
|
||||
return value === null ? null : parse(value)
|
||||
}
|
||||
|
||||
function wholeNumber(where: string, field: string, value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) throw new Error(`${where}: ${field} is not a whole number`)
|
||||
return value
|
||||
}
|
||||
|
||||
const PHOTO_URL = /^\/media\/photos\/([^/]+)-400\.jpg$/
|
||||
|
||||
// photoId reads the photo id out of a photo_url, which the server always
|
||||
// sends as /media/photos/{id}-400.jpg or null.
|
||||
export function photoId(where: string, value: unknown): string | undefined {
|
||||
if (value === null) return undefined
|
||||
const match = typeof value === 'string' ? PHOTO_URL.exec(value) : null
|
||||
if (!match?.[1]) throw new Error(`${where}: photo_url ${JSON.stringify(value)} is not a stored photo`)
|
||||
return decodeURIComponent(match[1])
|
||||
}
|
||||
|
||||
function parseDinner(where: string, body: unknown): Regular {
|
||||
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
|
||||
throw new Error(`${where}: response is not a dinner`)
|
||||
}
|
||||
if (typeof body.is_favorite !== 'boolean') throw new Error(`${where}: is_favorite is not a boolean`)
|
||||
return {
|
||||
id: body.id,
|
||||
name: body.name,
|
||||
method: nullable(body.method, (v) => oneOf(where, 'method', METHODS, v)),
|
||||
minutes_total: nullable(body.minutes_total, (v) => wholeNumber(where, 'minutes_total', v)),
|
||||
difficulty: nullable(body.difficulty, (v) => oneOf(where, 'difficulty', DIFFICULTIES, v)),
|
||||
is_favorite: body.is_favorite,
|
||||
photo_id: photoId(where, body.photo_url),
|
||||
grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status),
|
||||
}
|
||||
}
|
||||
|
||||
function parseCandidate(where: string, body: unknown): RecipeCandidate {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.candidate_id !== 'string' ||
|
||||
typeof body.title !== 'string' ||
|
||||
typeof body.site_name !== 'string'
|
||||
) {
|
||||
throw new Error(`${where}: response is not a recipe candidate`)
|
||||
}
|
||||
return {
|
||||
candidate_id: body.candidate_id,
|
||||
title: body.title,
|
||||
site_name: body.site_name,
|
||||
photo_id: photoId(where, body.photo_url),
|
||||
minutes: nullable(body.minutes, (v) => wholeNumber(where, 'minutes', v)),
|
||||
}
|
||||
}
|
||||
|
||||
function dinnerPath(id: string): string {
|
||||
return `${DINNERS_PATH}/${encodeURIComponent(id)}`
|
||||
}
|
||||
|
||||
// useRegulars is the household's Regulars, in the server's order. While any
|
||||
// of them is still finding its recipe it is read again every
|
||||
// GROUNDING_POLL_MS, except while any change is in flight (whose settling
|
||||
// reads it again anyway), so a poll never overwrites a change being saved.
|
||||
// Watching the changes in flight re-renders the hook as each one starts and
|
||||
// settles, which pauses and resumes the poll.
|
||||
export function useRegulars() {
|
||||
const { apiFetch } = useApi()
|
||||
const changing = useIsMutating({ mutationKey: REGULARS_MUTATION_KEY }) > 0
|
||||
return useQuery({
|
||||
queryKey: REGULARS_LIST_KEY,
|
||||
queryFn: async () => {
|
||||
const where = `GET ${REGULARS_PATH}`
|
||||
const body = await apiFetch<unknown>(REGULARS_PATH)
|
||||
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
|
||||
return body.dinners.map((dinner): CachedRegular => parseDinner(where, dinner))
|
||||
},
|
||||
select: shown,
|
||||
refetchInterval: (query) => {
|
||||
const list = query.state.data
|
||||
if (list === undefined || changing) return false
|
||||
return list.some((regular) => regular.grounding_status === 'pending') ? GROUNDING_POLL_MS : false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// changeOptimistically stops Regulars reads in flight, so they cannot
|
||||
// overwrite the optimistic list, then applies change to it.
|
||||
async function changeOptimistically(queryClient: QueryClient, change: (list: CachedRegular[]) => CachedRegular[]) {
|
||||
await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY })
|
||||
setRegulars(queryClient, change)
|
||||
}
|
||||
|
||||
function setRegulars(queryClient: QueryClient, change: (list: CachedRegular[]) => CachedRegular[]) {
|
||||
queryClient.setQueryData<CachedRegular[]>(REGULARS_LIST_KEY, (list) => change(list ?? []))
|
||||
}
|
||||
|
||||
// replace puts the server's answer for a dinner in its place in the list.
|
||||
function replace(queryClient: QueryClient, dinner: Regular) {
|
||||
setRegulars(queryClient, (list) => list.map((regular) => (regular.id === dinner.id ? dinner : regular)))
|
||||
}
|
||||
|
||||
// settle refetches the Regulars once the last change in flight is done, so
|
||||
// an earlier change's refetch never drops a later change's optimistic entry.
|
||||
function settle(queryClient: QueryClient) {
|
||||
if (queryClient.isMutating({ mutationKey: REGULARS_MUTATION_KEY }) === 1) {
|
||||
void queryClient.invalidateQueries({ queryKey: REGULARS_KEY })
|
||||
}
|
||||
}
|
||||
|
||||
let pendingIds = 0
|
||||
|
||||
// useAddRegular adds a Regular by name, with or without a recipe link (POST
|
||||
// /api/v1/dinners). It shows in the list at once under its name and is
|
||||
// taken out again if the server refuses it.
|
||||
export function useAddRegular() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationKey: REGULARS_MUTATION_KEY,
|
||||
mutationFn: async (add: NewRegular) =>
|
||||
parseDinner(`POST ${DINNERS_PATH}`, await apiFetch<unknown>(DINNERS_PATH, { method: 'POST', body: add })),
|
||||
onMutate: async (add) => {
|
||||
const id = `pending-${++pendingIds}`
|
||||
const optimistic: CachedRegular = {
|
||||
id,
|
||||
name: add.name,
|
||||
method: null,
|
||||
minutes_total: null,
|
||||
difficulty: null,
|
||||
is_favorite: false,
|
||||
photo_id: undefined,
|
||||
grounding_status: 'pending',
|
||||
pending: true,
|
||||
}
|
||||
await changeOptimistically(queryClient, (list) => [...list, optimistic])
|
||||
return { id }
|
||||
},
|
||||
onSuccess: (dinner, _add, context) => {
|
||||
setRegulars(queryClient, (list) => list.map((regular) => (regular.id === context.id ? dinner : regular)))
|
||||
},
|
||||
onError: (_error, _add, context) => {
|
||||
if (context === undefined) return
|
||||
setRegulars(queryClient, (list) => list.filter((regular) => regular.id !== context.id))
|
||||
},
|
||||
onSettled: () => settle(queryClient),
|
||||
})
|
||||
}
|
||||
|
||||
// useRemoveRegular archives a Regular (DELETE /api/v1/dinners/{id}). It
|
||||
// leaves the list at once and is put back in its place if the server
|
||||
// refuses.
|
||||
export function useRemoveRegular() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationKey: REGULARS_MUTATION_KEY,
|
||||
mutationFn: async (regular: Regular) => {
|
||||
await apiFetch<unknown>(dinnerPath(regular.id), { method: 'DELETE' })
|
||||
},
|
||||
onMutate: async (regular) => {
|
||||
await changeOptimistically(queryClient, (list) =>
|
||||
list.map((r): CachedRegular => (r.id === regular.id ? { ...r, removing: true } : r)),
|
||||
)
|
||||
},
|
||||
onSuccess: (_data, regular) => {
|
||||
setRegulars(queryClient, (list) => list.filter((r) => r.id !== regular.id))
|
||||
},
|
||||
onError: (_error, regular) => {
|
||||
setRegulars(queryClient, (list) => list.map((r) => (r.id === regular.id ? restored(r) : r)))
|
||||
},
|
||||
onSettled: () => settle(queryClient),
|
||||
})
|
||||
}
|
||||
|
||||
// useEditRegular saves changes to a Regular (PATCH /api/v1/dinners/{id})
|
||||
// and shows the server's answer once it is saved.
|
||||
export function useEditRegular() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationKey: REGULARS_MUTATION_KEY,
|
||||
mutationFn: async ({ id, patch }: { id: string; patch: RegularPatch }) =>
|
||||
parseDinner(`PATCH ${DINNERS_PATH}/{id}`, await apiFetch<unknown>(dinnerPath(id), { method: 'PATCH', body: patch })),
|
||||
onSuccess: (dinner) => replace(queryClient, dinner),
|
||||
onSettled: () => settle(queryClient),
|
||||
})
|
||||
}
|
||||
|
||||
// useToggleFavorite toggles a Regular's favorite heart (POST
|
||||
// /api/v1/dinners/{id}/favorite). The heart changes at once and changes
|
||||
// back if the server refuses.
|
||||
export function useToggleFavorite() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
const set = (id: string, is_favorite: boolean) =>
|
||||
setRegulars(queryClient, (list) => list.map((r) => (r.id === id ? { ...r, is_favorite } : r)))
|
||||
return useMutation({
|
||||
mutationKey: REGULARS_MUTATION_KEY,
|
||||
mutationFn: async (regular: Regular) =>
|
||||
parseDinner(
|
||||
`POST ${DINNERS_PATH}/{id}/favorite`,
|
||||
await apiFetch<unknown>(`${dinnerPath(regular.id)}/favorite`, { method: 'POST' }),
|
||||
),
|
||||
onMutate: async (regular) => {
|
||||
await changeOptimistically(queryClient, (list) =>
|
||||
list.map((r) => (r.id === regular.id ? { ...r, is_favorite: !regular.is_favorite } : r)),
|
||||
)
|
||||
},
|
||||
onSuccess: (dinner) => replace(queryClient, dinner),
|
||||
onError: (_error, regular) => set(regular.id, regular.is_favorite),
|
||||
onSettled: () => settle(queryClient),
|
||||
})
|
||||
}
|
||||
|
||||
// useGroundRegular fixes a Regular's recipe (POST /api/v1/dinners/{id}/ground)
|
||||
// with a candidate, a pasted link or a recipe typed in. A candidate or link
|
||||
// is read in the request, which can take up to a minute and a half.
|
||||
export function useGroundRegular() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationKey: REGULARS_MUTATION_KEY,
|
||||
mutationFn: async ({ id, fix }: { id: string; fix: RecipeFix }) =>
|
||||
parseDinner(
|
||||
`POST ${DINNERS_PATH}/{id}/ground`,
|
||||
await apiFetch<unknown>(`${dinnerPath(id)}/ground`, { method: 'POST', body: fix }),
|
||||
),
|
||||
onSuccess: (dinner) => replace(queryClient, dinner),
|
||||
onSettled: () => settle(queryClient),
|
||||
})
|
||||
}
|
||||
|
||||
// useRecipeCandidates is up to three recipe pages the dinner could be
|
||||
// grounded in, best first (GET /api/v1/dinners/{id}/recipe-candidates).
|
||||
export function useRecipeCandidates(id: string) {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: ['dinners', 'candidates', id],
|
||||
queryFn: async () => {
|
||||
const path = `${dinnerPath(id)}/recipe-candidates`
|
||||
const where = `GET ${DINNERS_PATH}/{id}/recipe-candidates`
|
||||
const body = await apiFetch<unknown>(path)
|
||||
if (!isRecord(body) || !Array.isArray(body.candidates)) throw new Error(`${where}: response has no candidates list`)
|
||||
return body.candidates.map((candidate) => parseCandidate(where, candidate))
|
||||
},
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import cases from '../../../internal/ai/harness/testdata/dedupe_cases.json'
|
||||
import { normalizeName, similarNames } from './names.ts'
|
||||
|
||||
// The cases internal/ai/harness's tests run against Normalize and Similar.
|
||||
describe('dinner names, as the server compares them', () => {
|
||||
it('has cases to run', () => {
|
||||
expect(cases.normalize.length).toBeGreaterThan(0)
|
||||
expect(cases.similar.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.each(cases.normalize)('normalizes "$name" to "$want"', ({ name, want }) => {
|
||||
expect(normalizeName(name)).toBe(want)
|
||||
})
|
||||
|
||||
it.each(cases.similar)('"$a" and "$b" similar: $want', ({ a, b, want }) => {
|
||||
expect(similarNames(a, b)).toBe(want)
|
||||
expect(similarNames(b, a)).toBe(want)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
// The PWA's copy of the server's dinner-name rules (internal/ai/harness
|
||||
// Normalize and Similar), so a screen can tell beforehand that the dinners
|
||||
// API would refuse a name as a duplicate. The server still decides.
|
||||
// internal/ai/harness/testdata/dedupe_cases.json is run against both.
|
||||
|
||||
import folds from './letterFolds.json'
|
||||
|
||||
// SIMILAR_THRESHOLD is harness.SimilarThreshold.
|
||||
const SIMILAR_THRESHOLD = 0.85
|
||||
|
||||
// fillerWords is harness's fillerWords.
|
||||
const fillerWords = new Set(['easy', 'best', 'homemade', 'quick', 'simple', 'the', 'a', 'recipe', 'classic'])
|
||||
|
||||
// letterFolds is harness's letterFolds: gen_folds.py writes both tables,
|
||||
// and harness's TestLetterFoldsMatchPWA fails when they differ.
|
||||
const letterFolds: Record<string, string> = folds
|
||||
|
||||
const combiningMark = /\p{Mn}/u
|
||||
const wordChar = /[\p{L}\p{Nd}]/u
|
||||
|
||||
// normalizeName is harness.Normalize: lowercased, accents dropped, letters
|
||||
// folded, apostrophes removed, other punctuation turned into spaces and
|
||||
// filler words dropped. "Easy Chicken Tikka-Masala!" is
|
||||
// "chicken tikka masala".
|
||||
export function normalizeName(name: string): string {
|
||||
let out = ''
|
||||
// Lowercased letter by letter, as Go's strings.ToLower does, so no
|
||||
// letter is lowered by its context (a final Σ is σ, not ς).
|
||||
for (const letter of name) {
|
||||
for (const ch of letter.toLowerCase()) {
|
||||
if (combiningMark.test(ch) || ch === "'" || ch === '’') continue
|
||||
const fold = letterFolds[ch]
|
||||
if (fold !== undefined) out += fold
|
||||
else if (wordChar.test(ch)) out += ch
|
||||
else out += ' '
|
||||
}
|
||||
}
|
||||
return out
|
||||
.split(' ')
|
||||
.filter((word) => word !== '' && !fillerWords.has(word))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function words(name: string): Set<string> {
|
||||
const normalized = normalizeName(name)
|
||||
return new Set(normalized === '' ? [] : normalized.split(' '))
|
||||
}
|
||||
|
||||
// similarNames is harness.Similar: whether a and b name the same dinner, by
|
||||
// the share of the words of their normalized forms they have in common.
|
||||
export function similarNames(a: string, b: string): boolean {
|
||||
const left = words(a)
|
||||
const right = words(b)
|
||||
if (left.size === 0 || right.size === 0) return false
|
||||
let shared = 0
|
||||
for (const word of left) if (right.has(word)) shared++
|
||||
return shared / (left.size + right.size - shared) >= SIMILAR_THRESHOLD
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
// Refusal is one change the server turned down. key is unique to that
|
||||
// attempt, so two attempts of the same change in flight each keep their own
|
||||
// refusal. change names what was tried, so trying it again once it has been
|
||||
// refused clears the earlier refusal and no other.
|
||||
export type Refusal = { key: number; change: string; text: string }
|
||||
|
||||
// useRefusals keeps each change's own refusal: the mutation hooks only
|
||||
// report their latest change, which would hide a refusal while another
|
||||
// change is in flight. track(change, what, answer) clears change's earlier
|
||||
// refusal and records "Could not {what}: …" if answer rejects.
|
||||
export function useRefusals() {
|
||||
const [refusals, setRefusals] = useState<Refusal[]>([])
|
||||
const attempts = useRef(0)
|
||||
|
||||
function track(change: string, what: string, answer: Promise<unknown>) {
|
||||
const key = ++attempts.current
|
||||
setRefusals((all) => all.filter((refusal) => refusal.change !== change))
|
||||
answer.catch((error: Error) => {
|
||||
setRefusals((all) => [...all, { key, change, text: `Could not ${what}: ${error.message}` }])
|
||||
})
|
||||
}
|
||||
|
||||
return { refusals, track }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { DOMAttributes } from 'react'
|
||||
import { DIFFICULTIES, type Difficulty } from '../dinners/api.ts'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import type { SideQuest } from './api.ts'
|
||||
import styles from './DiscoverPage.module.css'
|
||||
|
||||
// DECK_DEPTH is the depths the deck shows, top card first.
|
||||
export const DECK_DEPTH = [0, 1, 2] as const
|
||||
|
||||
const DIFFICULTY_LABEL: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
|
||||
|
||||
// SwipeHandlers are the pointer handlers that drag the top card.
|
||||
export type SwipeHandlers = Pick<
|
||||
DOMAttributes<HTMLElement>,
|
||||
'onPointerDown' | 'onPointerMove' | 'onPointerUp' | 'onPointerCancel' | 'onLostPointerCapture'
|
||||
>
|
||||
|
||||
export type DeckCardProps = {
|
||||
quest: SideQuest
|
||||
// 0 is the top card; 1 and 2 stack behind it.
|
||||
depth: number
|
||||
// swipe makes the card draggable; only the top card gets it.
|
||||
swipe?: SwipeHandlers
|
||||
}
|
||||
|
||||
// DeckCard is one Side quest in the deck. Only the top card is read out and
|
||||
// reachable; the cards behind it are the stack's edge, kept rendered so they
|
||||
// can be promoted. Its "Try it!" and "Pass" stamps stay hidden until a
|
||||
// decision or a drag shows them, and its shade until Pass folds it (see
|
||||
// motion.ts).
|
||||
export function DeckCard({ quest, depth, swipe }: DeckCardProps) {
|
||||
const top = depth === 0
|
||||
const titleId = `side-quest-${quest.id}`
|
||||
return (
|
||||
<article
|
||||
className={`${styles.card} ${styles[`depth${depth}`]}${swipe === undefined ? '' : ` ${styles.draggable}`}`}
|
||||
aria-labelledby={top ? titleId : undefined}
|
||||
aria-hidden={top ? undefined : true}
|
||||
inert={!top}
|
||||
{...swipe}
|
||||
>
|
||||
<Photo id={quest.photo_id} name={quest.name} radius="24px" inset="10px" sizes="400px" className={styles.photo} />
|
||||
<div className={styles.content}>
|
||||
<h2 id={titleId} className={styles.name}>
|
||||
{quest.name}
|
||||
</h2>
|
||||
{quest.difficulty !== null && <DifficultyDots difficulty={quest.difficulty} />}
|
||||
<p className={styles.why}>
|
||||
<strong>Why this?</strong> {quest.reason}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`${styles.stamp} ${styles.tryStamp}`} data-part="try-stamp" aria-hidden="true">
|
||||
Try it!
|
||||
</span>
|
||||
<span className={`${styles.stamp} ${styles.passStamp}`} data-part="pass-stamp" aria-hidden="true">
|
||||
Pass
|
||||
</span>
|
||||
<span className={styles.shade} data-part="shade" />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function DifficultyDots({ difficulty }: { difficulty: Difficulty }) {
|
||||
const level = DIFFICULTIES.indexOf(difficulty) + 1
|
||||
return (
|
||||
<p className={styles.difficulty}>
|
||||
<span className={styles.dots} aria-hidden="true">
|
||||
{DIFFICULTIES.map((d, i) => (
|
||||
<span key={d} data-filled={i < level} className={i < level ? styles.dotFilled : styles.dot} />
|
||||
))}
|
||||
</span>
|
||||
{DIFFICULTY_LABEL[difficulty]}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/* Discover · Side quests (mock 1a). */
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
max-width: 440px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
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: 30px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* The Quest log count pill, in a 44 px tall hit box. */
|
||||
.questLogButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.questLog {
|
||||
flex: none;
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent-2-200);
|
||||
color: var(--color-accent-2-800);
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* The Quest log's dinner names, under the header while the pill is open. */
|
||||
.questLogList {
|
||||
flex: 1 0 100%;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.questLogNames {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.questLogEmpty {
|
||||
margin: 0;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* The deck: three cards stacked in the same box, the top one in front. */
|
||||
.deck {
|
||||
position: relative;
|
||||
height: 440px;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius-card);
|
||||
overflow: hidden;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.depth0 {
|
||||
z-index: 3;
|
||||
transform: none;
|
||||
background: var(--color-neutral-100);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* The top card follows a drag instead of scrolling the page. */
|
||||
.draggable {
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.depth1 {
|
||||
z-index: 2;
|
||||
transform: translateY(8px) scale(0.95);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.depth2 {
|
||||
z-index: 1;
|
||||
transform: translateY(16px) scale(0.9);
|
||||
background: var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.photo {
|
||||
flex: none;
|
||||
width: calc(100% - 20px);
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 4px 20px 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.difficulty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dot,
|
||||
.dotFilled {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.dotFilled {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
/* The "Why this?" box. */
|
||||
.why {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
background: var(--color-accent-2-100);
|
||||
color: var(--color-accent-2-900);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* The decision stamps, hidden until a decision shows them. */
|
||||
.stamp {
|
||||
position: absolute;
|
||||
top: 26px;
|
||||
padding: 6px 14px;
|
||||
border: 3px solid;
|
||||
border-radius: 14px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 24px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tryStamp {
|
||||
left: 22px;
|
||||
transform: rotate(-12deg);
|
||||
border-color: var(--color-accent-2-700);
|
||||
background: var(--color-accent-2-100);
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.passStamp {
|
||||
right: 22px;
|
||||
transform: rotate(12deg);
|
||||
border-color: var(--color-neutral-800);
|
||||
background: var(--color-neutral-100);
|
||||
color: var(--color-neutral-900);
|
||||
}
|
||||
|
||||
/* The shade Pass darkens the card with as it folds. */
|
||||
.shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: var(--color-text);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Pass and Try it: IconButtons with their 11 px labels shown. */
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 28px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Skeleton cards while the deck loads or a batch finds Side quests. */
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.deck > .skeletonCard {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
height: auto;
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
.skeletonCard.depth0,
|
||||
.skeletonCard.depth1,
|
||||
.skeletonCard.depth2 {
|
||||
background: var(--color-surface);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.finding {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,413 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Heart, X } from 'lucide-react'
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type Ref } from 'react'
|
||||
import { useJob } from '../api/index.ts'
|
||||
import { Card } from '../ui/Card.tsx'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import {
|
||||
BATCH_JOB_KIND,
|
||||
DECK_KEY,
|
||||
parseBatchResult,
|
||||
stillPending,
|
||||
useDeciding,
|
||||
useDeck,
|
||||
useDecide,
|
||||
useQuestLog,
|
||||
useStartBatch,
|
||||
type Choice,
|
||||
type SideQuest,
|
||||
} from './api.ts'
|
||||
import { DeckCard, DECK_DEPTH, type SwipeHandlers } from './DeckCard.tsx'
|
||||
import {
|
||||
bump,
|
||||
fadeOut,
|
||||
follow,
|
||||
isAbort,
|
||||
pass,
|
||||
prefersReducedMotion,
|
||||
promote,
|
||||
snapBack,
|
||||
tryIt,
|
||||
unfollow,
|
||||
type Exit,
|
||||
} from './motion.ts'
|
||||
import styles from './DiscoverPage.module.css'
|
||||
|
||||
// SWIPE_THRESHOLD is how far, in px, the top card must be dragged left or
|
||||
// right for its release to Pass or Try it.
|
||||
const SWIPE_THRESHOLD = 110
|
||||
|
||||
// A Drag is the pointer dragging the top card: where it went down, and the
|
||||
// card's drag transform so far.
|
||||
type Drag = { pointerId: number; x: number; y: number; dx: number; transform: string | null }
|
||||
|
||||
// isEditable says whether a key press belongs to a field being typed in,
|
||||
// which the deck's ← / → keys leave alone.
|
||||
function isEditable(target: EventTarget | null): boolean {
|
||||
return (
|
||||
target instanceof HTMLElement &&
|
||||
(target.isContentEditable || target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement)
|
||||
)
|
||||
}
|
||||
|
||||
// QuestLogPill is the "Quest log · N" pill. It shows and hides the names
|
||||
// of the dinners in the Quest log; the Quest log sheet replaces this list.
|
||||
// ref is the visible pill, which Try it bumps.
|
||||
function QuestLogPill({ dinners, ref }: { dinners: { id: string; name: string }[]; ref: Ref<HTMLSpanElement> }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const listId = useId()
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.questLogButton}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<span ref={ref} className={styles.questLog}>Quest log · {dinners.length}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<Card id={listId} tone="muted" className={styles.questLogList}>
|
||||
{dinners.length === 0 ? (
|
||||
<p className={styles.questLogEmpty}>No dinners in the Quest log yet. Try it on a Side quest to add one.</p>
|
||||
) : (
|
||||
<ul aria-label="Quest log" className={styles.questLogNames}>
|
||||
{dinners.map((dinner) => (
|
||||
<li key={dinner.id}>{dinner.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// DiscoverPage is mock 1a: the deck of Side quests to Try it or Pass, one
|
||||
// at a time from the top card. A decision from the buttons, the ← / → keys
|
||||
// or a swipe of the top card throws it out (motion.ts) and sends the
|
||||
// decision once it is gone, taking the card off the deck at once (see
|
||||
// useDecide); Retry sends a failed one again while the deck's newest read
|
||||
// still has its Side quest pending. Input is locked from the press
|
||||
// until the decision settles, which is once the deck and the Quest log have
|
||||
// been read again after it, so no decision starts under those reads. The lock is a
|
||||
// ref, set before anything starts, so a second press before the next render
|
||||
// does nothing, and busy mirrors it to disable the buttons.
|
||||
export function DiscoverPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const deck = useDeck()
|
||||
// The deck shown: the deck's newest read, less the Side quests being
|
||||
// decided, which a read landing mid-decision may still have pending.
|
||||
const inFlight = useDeciding()
|
||||
const cards = deck.data?.filter((quest) => !inFlight.has(quest.id))
|
||||
const questLog = useQuestLog()
|
||||
const decision = useDecide()
|
||||
const startBatch = useStartBatch()
|
||||
// The job of the batch last known to be finding Side quests: the one Get
|
||||
// more now started or joined, or the replenish a decision started.
|
||||
const [batchJobId, setBatchJobId] = useState<string | null>(null)
|
||||
const job = useJob<unknown>(BATCH_JOB_KIND, batchJobId)
|
||||
|
||||
const top = cards?.[0]
|
||||
const [busy, setBusy] = useState(false)
|
||||
const jobRunning = batchJobId !== null && job.status !== 'succeeded' && job.status !== 'failed'
|
||||
const finding = startBatch.isPending || jobRunning
|
||||
const { mutate: mutateStartBatch } = startBatch
|
||||
const getMore = useCallback(() => mutateStartBatch(undefined, { onSuccess: (b) => setBatchJobId(b.job_id) }), [mutateStartBatch])
|
||||
|
||||
// The batch's result as far as it has gone, or why it cannot be read.
|
||||
const batch = useMemo(() => {
|
||||
if (job.result === null) return { stored: 0, error: null }
|
||||
try {
|
||||
return { stored: parseBatchResult(`GET /api/v1/jobs/${batchJobId}`, job.result).suggestion_ids.length, error: null }
|
||||
} catch (error) {
|
||||
return { stored: 0, error: error as Error }
|
||||
}
|
||||
}, [job.result, batchJobId])
|
||||
// The job's failure, the latest poll's failure, an answer that is not the
|
||||
// batch's job, or an unreadable result.
|
||||
const batchError = job.error ?? batch.error
|
||||
|
||||
const deckRef = useRef<HTMLDivElement>(null)
|
||||
const passRef = useRef<HTMLButtonElement>(null)
|
||||
const tryRef = useRef<HTMLButtonElement>(null)
|
||||
const questLogRef = useRef<HTMLSpanElement>(null)
|
||||
// The top card's exit until its decision settles, and the Side quest the
|
||||
// last decision sent was for, until the deck moves past it.
|
||||
const exit = useRef<Exit | null>(null)
|
||||
const leaving = useRef<string | null>(null)
|
||||
const drag = useRef<Drag | null>(null)
|
||||
// The top card's snap-back while it may still run; a new drag or a
|
||||
// decision stops it so it does not fight the card's new motion.
|
||||
const springing = useRef<Animation | null>(null)
|
||||
const stopSpring = useCallback(() => {
|
||||
springing.current?.cancel()
|
||||
springing.current = null
|
||||
}, [])
|
||||
|
||||
const { mutate: mutateDecision } = decision
|
||||
const deciding = useRef(false)
|
||||
const lock = useCallback(() => {
|
||||
if (deciding.current) return false
|
||||
deciding.current = true
|
||||
setBusy(true)
|
||||
return true
|
||||
}, [])
|
||||
// send records a decision. A failed one stops the card's exit, so a card
|
||||
// useDecide put back on the deck is at rest; a saved one that started a
|
||||
// replenish batch follows its job.
|
||||
const send = useCallback(
|
||||
(variables: { quest: SideQuest; choice: Choice }) => {
|
||||
leaving.current = variables.quest.id
|
||||
mutateDecision(variables, {
|
||||
onSuccess: (replenishJobId) => {
|
||||
if (replenishJobId !== null) setBatchJobId(replenishJobId)
|
||||
},
|
||||
onError: () => {
|
||||
exit.current?.cancel()
|
||||
leaving.current = null
|
||||
},
|
||||
onSettled: () => {
|
||||
exit.current = null
|
||||
deciding.current = false
|
||||
setBusy(false)
|
||||
},
|
||||
})
|
||||
},
|
||||
[mutateDecision],
|
||||
)
|
||||
const submit = useCallback(
|
||||
(variables: { quest: SideQuest; choice: Choice }) => {
|
||||
if (lock()) send(variables)
|
||||
},
|
||||
[lock, send],
|
||||
)
|
||||
// decide bumps the button, throws the top card out — or, motion reduced,
|
||||
// fades it — then sends the decision; Try it bumps the Quest log pill too.
|
||||
// A swipe's decision comes from the card's drag transform: it is thrown
|
||||
// from there without a bump or wiggle. A button or key decision ends a
|
||||
// drag in progress, putting the card back first.
|
||||
const decide = useCallback(
|
||||
(choice: Choice, from?: string) => {
|
||||
if (top === undefined || !lock()) return
|
||||
stopSpring()
|
||||
const card = deckRef.current!.querySelector('article')!
|
||||
if (from === undefined && drag.current !== null) {
|
||||
drag.current = null
|
||||
unfollow(card)
|
||||
}
|
||||
const reduced = prefersReducedMotion()
|
||||
let run: Exit
|
||||
if (reduced) {
|
||||
run = fadeOut(card)
|
||||
} else if (from === undefined) {
|
||||
bump((choice === 'try' ? tryRef : passRef).current!)
|
||||
run = choice === 'try' ? tryIt(card) : pass(card)
|
||||
} else {
|
||||
run = choice === 'try' ? tryIt(card, from) : pass(card, from)
|
||||
}
|
||||
if (from !== undefined) {
|
||||
// Putting a swiped card back puts it back at rest.
|
||||
const thrown = run
|
||||
run = {
|
||||
finished: thrown.finished,
|
||||
cancel: () => {
|
||||
thrown.cancel()
|
||||
unfollow(card)
|
||||
},
|
||||
}
|
||||
}
|
||||
exit.current = run
|
||||
run.finished.then(
|
||||
() => {
|
||||
// The Quest log pill is missing while the Quest log fails to load.
|
||||
if (choice === 'try' && !reduced && questLogRef.current !== null) bump(questLogRef.current)
|
||||
send({ quest: top, choice })
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!isAbort(error)) throw error
|
||||
},
|
||||
)
|
||||
},
|
||||
[top, lock, send, stopSpring],
|
||||
)
|
||||
|
||||
// swipe drags the top card: it follows the pointer, captured from the
|
||||
// press, until the release decides past the threshold or springs it back
|
||||
// short of it. The release decides from where the pointer let go, which
|
||||
// may differ from its last move. A cancel, or losing the capture before
|
||||
// the release, springs it back from its last move. One pointer drags at
|
||||
// a time: a press while a drag is in progress is ignored, as is input
|
||||
// while busy, and a drag held into a busy spell (a Retry) is put back on
|
||||
// release.
|
||||
const swipe = useMemo<SwipeHandlers>(() => {
|
||||
function track(card: HTMLElement, current: Drag, event: PointerEvent<HTMLElement>) {
|
||||
current.dx = event.clientX - current.x
|
||||
current.transform = follow(card, current.dx, event.clientY - current.y)
|
||||
}
|
||||
function release(event: PointerEvent<HTMLElement>, decides: boolean) {
|
||||
const card = event.currentTarget
|
||||
const current = drag.current
|
||||
if (current === null || current.pointerId !== event.pointerId) return
|
||||
drag.current = null
|
||||
const moved = current.transform !== null || event.clientX !== current.x || event.clientY !== current.y
|
||||
if (decides && moved && !deciding.current) track(card, current, event)
|
||||
if (current.transform === null) return
|
||||
if (deciding.current) unfollow(card)
|
||||
else if (decides && current.dx > SWIPE_THRESHOLD) decide('try', current.transform)
|
||||
else if (decides && current.dx < -SWIPE_THRESHOLD) decide('pass', current.transform)
|
||||
else if (prefersReducedMotion()) unfollow(card)
|
||||
else springing.current = snapBack(card, current.transform)
|
||||
}
|
||||
return {
|
||||
onPointerDown: (event) => {
|
||||
if (drag.current !== null || deciding.current || !event.isPrimary || event.button !== 0) return
|
||||
stopSpring()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
drag.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, dx: 0, transform: null }
|
||||
},
|
||||
onPointerMove: (event) => {
|
||||
const current = drag.current
|
||||
if (current === null || current.pointerId !== event.pointerId || deciding.current) return
|
||||
track(event.currentTarget, current, event)
|
||||
},
|
||||
onPointerUp: (event) => release(event, true),
|
||||
onPointerCancel: (event) => release(event, false),
|
||||
// After a release the drag is already over, so this only ends a drag
|
||||
// whose capture was lost before it.
|
||||
onLostPointerCapture: (event) => release(event, false),
|
||||
}
|
||||
}, [decide, stopSpring])
|
||||
|
||||
// Leaving the page stops an exit before its decision is sent.
|
||||
useEffect(() => () => exit.current?.cancel(), [])
|
||||
|
||||
const topId = top?.id
|
||||
// A drag belongs to the card it started on, which a new top card ends.
|
||||
useLayoutEffect(() => {
|
||||
drag.current = null
|
||||
}, [topId])
|
||||
|
||||
// Once the deck moves past the card a decision was sent for, the cards
|
||||
// behind it move up a depth.
|
||||
useLayoutEffect(() => {
|
||||
if (leaving.current === null || leaving.current === topId) return
|
||||
leaving.current = null
|
||||
if (topId === undefined || prefersReducedMotion()) return
|
||||
promote([...deckRef.current!.querySelectorAll<HTMLElement>(':scope > article')])
|
||||
}, [topId])
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
if (isEditable(event.target)) return
|
||||
if (event.key === 'ArrowLeft') decide('pass')
|
||||
else if (event.key === 'ArrowRight') decide('try')
|
||||
else return
|
||||
event.preventDefault()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [decide])
|
||||
|
||||
// The batch stores its Side quests as it goes, so the deck is read again
|
||||
// whenever it reports more, and once it ends. A read already out may be a
|
||||
// decision's, which must not be cut short: the batch's read waits for it,
|
||||
// then goes out after it, as it may predate what the batch reported.
|
||||
const { stored } = batch
|
||||
useEffect(() => {
|
||||
if (job.status === undefined) return
|
||||
const after = queryClient.isFetching({ queryKey: DECK_KEY }) > 0
|
||||
void queryClient
|
||||
.invalidateQueries({ queryKey: DECK_KEY }, { cancelRefetch: false })
|
||||
.then(() => (after ? queryClient.invalidateQueries({ queryKey: DECK_KEY }, { cancelRefetch: false }) : undefined))
|
||||
}, [queryClient, job.status, stored])
|
||||
|
||||
// A failed decision can be retried only while the deck's newest read
|
||||
// still has its Side quest pending: anything else decided it already.
|
||||
const pending = decision.isError ? stillPending(deck, decision.variables.quest.id) : undefined
|
||||
const retryable = pending !== undefined && pending !== null
|
||||
|
||||
let body
|
||||
if (deck.isError) {
|
||||
body = <ErrorBanner onRetry={() => void deck.refetch()}>Couldn't load your side quests. {deck.error.message}</ErrorBanner>
|
||||
} else if (deck.isPending || (cards!.length === 0 && (finding || decision.isPending))) {
|
||||
// An empty deck is not caught up while a batch finds more Side quests,
|
||||
// or while the last decision may yet start one or come back refused.
|
||||
body = (
|
||||
<section aria-busy="true" aria-label="Side quests loading" className={styles.loading}>
|
||||
<div className={styles.deck}>
|
||||
{DECK_DEPTH.map((depth) => (
|
||||
<Skeleton key={depth} data-testid="skeleton-card" className={`${styles.skeletonCard} ${styles[`depth${depth}`]}`} />
|
||||
))}
|
||||
</div>
|
||||
{finding && <p className={styles.finding}>Finding new side quests…</p>}
|
||||
{jobRunning && batchError !== null && (
|
||||
<ErrorBanner onRetry={job.poll}>Couldn't check on new side quests. {batchError.message}</ErrorBanner>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
} else if (top === undefined) {
|
||||
body = (
|
||||
<>
|
||||
{startBatch.isError && (
|
||||
<ErrorBanner onRetry={getMore}>Couldn't ask for more side quests. {startBatch.error.message}</ErrorBanner>
|
||||
)}
|
||||
{batchError !== null && (
|
||||
<ErrorBanner onRetry={getMore}>Couldn't find new side quests. {batchError.message}</ErrorBanner>
|
||||
)}
|
||||
<EmptyState title="All caught up" action={{ label: 'Get more now', onClick: getMore }}>
|
||||
New side quests arrive Sunday.
|
||||
</EmptyState>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
body = (
|
||||
<>
|
||||
<div ref={deckRef} className={styles.deck}>
|
||||
{cards!.slice(0, DECK_DEPTH.length).map((quest, depth) => (
|
||||
<DeckCard key={quest.id} quest={quest} depth={depth} swipe={depth === 0 ? swipe : undefined} />
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<IconButton ref={passRef} label="Pass" size={62} tone="neutral" showLabel disabled={busy} onClick={() => decide('pass')}>
|
||||
<Icon icon={X} size="nav" />
|
||||
</IconButton>
|
||||
<IconButton ref={tryRef} label="Try it" size={62} tone="teal" showLabel disabled={busy} onClick={() => decide('try')}>
|
||||
<Icon icon={Heart} size="nav" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<div>
|
||||
<p className={styles.kicker}>Discover</p>
|
||||
<h1 className={styles.title}>Side quests</h1>
|
||||
</div>
|
||||
{questLog.isSuccess && <QuestLogPill ref={questLogRef} dinners={questLog.data} />}
|
||||
</header>
|
||||
{questLog.isError && (
|
||||
<ErrorBanner onRetry={() => void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message}</ErrorBanner>
|
||||
)}
|
||||
{body}
|
||||
{/* A failed decision is shown whatever the deck has come to since. */}
|
||||
{decision.isError &&
|
||||
(retryable ? (
|
||||
<ErrorBanner onRetry={() => submit(decision.variables)}>Couldn't save that choice. {decision.error.message}</ErrorBanner>
|
||||
) : pending === undefined ? (
|
||||
<ErrorBanner>Couldn't save that choice. {decision.error.message}</ErrorBanner>
|
||||
) : (
|
||||
<ErrorBanner>That side quest has been decided already. {decision.error.message}</ErrorBanner>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { DIFFICULTIES, DINNERS_PATH, oneOf, photoId, type Difficulty } from '../dinners/api.ts'
|
||||
|
||||
export const SUGGESTIONS_PATH = '/api/v1/suggestions'
|
||||
export const DECK_PATH = `${SUGGESTIONS_PATH}/deck`
|
||||
export const BATCHES_PATH = `${SUGGESTIONS_PATH}/batches`
|
||||
export const QUEST_LOG_PATH = `${DINNERS_PATH}?status=quest_log`
|
||||
|
||||
// DECK_KEY is the deck's query; QUEST_LOG_KEY the Quest log's;
|
||||
// DECIDE_KEY the decisions'.
|
||||
export const DECK_KEY = ['suggestions', 'deck'] as const
|
||||
export const QUEST_LOG_KEY = ['dinners', 'quest_log'] as const
|
||||
const DECIDE_KEY = ['suggestions', 'decide'] as const
|
||||
|
||||
// SideQuest is a pending suggestion as the deck shows it. dinner_id is the
|
||||
// dinner Try it moves to the Quest log; photo_id is the stored photo's id,
|
||||
// from photo_url; reason is the "Why this?".
|
||||
export type SideQuest = {
|
||||
id: string
|
||||
dinner_id: string
|
||||
name: string
|
||||
photo_id: string | undefined
|
||||
difficulty: Difficulty | null
|
||||
reason: string
|
||||
}
|
||||
|
||||
// Choice is the household's decision on a Side quest: Try it or Pass.
|
||||
export type Choice = 'try' | 'pass'
|
||||
|
||||
// Batch is a POST /api/v1/suggestions/batches answer: the batch and the job
|
||||
// running it, which GET /api/v1/jobs/{id} reports on.
|
||||
export type Batch = { batch_id: string; job_id: string }
|
||||
|
||||
// BATCH_JOB_KIND is the kind of the job running a suggestion batch.
|
||||
export const BATCH_JOB_KIND = 'suggestion_batch'
|
||||
|
||||
// BatchResult is the suggestion batch job's result, published as it goes.
|
||||
export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number }
|
||||
|
||||
// QuestLogDinner is a dinner in the Quest log, as the Quest log pill lists it.
|
||||
export type QuestLogDinner = { id: string; name: string }
|
||||
|
||||
// DECISION_STATUS is the status a suggestion has once each choice is saved.
|
||||
const DECISION_STATUS: Record<Choice, string> = { try: 'accepted', pass: 'declined' }
|
||||
|
||||
// parseBatchResult checks a suggestion batch job's result, which useJob
|
||||
// passes on unread.
|
||||
export function parseBatchResult(where: string, body: unknown): BatchResult {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.batch_id !== 'string' ||
|
||||
typeof body.stage !== 'string' ||
|
||||
!Array.isArray(body.suggestion_ids) ||
|
||||
!body.suggestion_ids.every((id) => typeof id === 'string') ||
|
||||
typeof body.dropped !== 'number'
|
||||
) {
|
||||
throw new Error(`${where}: result is not a suggestion batch`)
|
||||
}
|
||||
return { batch_id: body.batch_id, stage: body.stage, suggestion_ids: body.suggestion_ids as string[], dropped: body.dropped }
|
||||
}
|
||||
|
||||
function parseSideQuest(where: string, body: unknown): SideQuest {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.id !== 'string' ||
|
||||
typeof body.dinner_id !== 'string' ||
|
||||
typeof body.name !== 'string' ||
|
||||
typeof body.reason !== 'string'
|
||||
) {
|
||||
throw new Error(`${where}: response is not a suggestion`)
|
||||
}
|
||||
return {
|
||||
id: body.id,
|
||||
dinner_id: body.dinner_id,
|
||||
name: body.name,
|
||||
photo_id: photoId(where, body.photo_url),
|
||||
difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty),
|
||||
reason: body.reason,
|
||||
}
|
||||
}
|
||||
|
||||
// useDeck is the pending Side quests, oldest first (GET
|
||||
// /api/v1/suggestions/deck).
|
||||
export function useDeck() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: DECK_KEY,
|
||||
queryFn: async () => {
|
||||
const where = `GET ${DECK_PATH}`
|
||||
const body = await apiFetch<unknown>(DECK_PATH)
|
||||
if (!isRecord(body) || !Array.isArray(body.suggestions)) throw new Error(`${where}: response has no suggestions list`)
|
||||
return body.suggestions.map((suggestion) => parseSideQuest(where, suggestion))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// useQuestLog is the dinners in the Quest log, in the server's order (GET
|
||||
// /api/v1/dinners?status=quest_log).
|
||||
export function useQuestLog() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: QUEST_LOG_KEY,
|
||||
queryFn: async (): Promise<QuestLogDinner[]> => {
|
||||
const where = `GET ${QUEST_LOG_PATH}`
|
||||
const body = await apiFetch<unknown>(QUEST_LOG_PATH)
|
||||
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
|
||||
return body.dinners.map((dinner) => {
|
||||
if (!isRecord(dinner) || typeof dinner.id !== 'string' || typeof dinner.name !== 'string') {
|
||||
throw new Error(`${where}: response is not a dinner`)
|
||||
}
|
||||
return { id: dinner.id, name: dinner.name }
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// useStartBatch asks for more Side quests now (POST
|
||||
// /api/v1/suggestions/batches). The server answers with the batch already
|
||||
// running when there is one.
|
||||
export function useStartBatch() {
|
||||
const { apiFetch } = useApi()
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<Batch> => {
|
||||
const body = await apiFetch<unknown>(BATCHES_PATH, { method: 'POST' })
|
||||
if (!isRecord(body) || typeof body.batch_id !== 'string' || typeof body.job_id !== 'string') {
|
||||
throw new Error(`POST ${BATCHES_PATH}: response is not a batch`)
|
||||
}
|
||||
return { batch_id: body.batch_id, job_id: body.job_id }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// stillPending says whether the deck's newest read has the Side quest id
|
||||
// pending, after a failed decision on it: the Side quest when it does, null
|
||||
// when it does not, and undefined when that read failed, which says neither.
|
||||
// deck is the deck query's state, from the cache or useDeck.
|
||||
export function stillPending(deck: { status: string; data: SideQuest[] | undefined } | undefined, id: string): SideQuest | null | undefined {
|
||||
if (deck?.status !== 'success') return undefined
|
||||
return deck.data!.find((q) => q.id === id) ?? null
|
||||
}
|
||||
|
||||
// useDeciding is the ids of the Side quests whose decisions are pending:
|
||||
// sent and not yet settled. The deck shown leaves them out, whatever a read
|
||||
// of the deck has said meanwhile.
|
||||
export function useDeciding(): Set<string> {
|
||||
const ids = useMutationState<string>({
|
||||
filters: { mutationKey: DECIDE_KEY, status: 'pending' },
|
||||
select: (mutation) => (mutation.state.variables as { quest: SideQuest }).quest.id,
|
||||
})
|
||||
return new Set(ids)
|
||||
}
|
||||
|
||||
// useDecide records Try it (POST /api/v1/suggestions/{id}/accept) or Pass
|
||||
// (POST /api/v1/suggestions/{id}/decline) on the Side quest the household
|
||||
// decided on, whether or not a read of the deck has since taken it away.
|
||||
// The answer must be that suggestion, decided that way; the mutation's data
|
||||
// is the id of the replenish job the decision started, or null.
|
||||
//
|
||||
// The decision is applied at once: the Side quest leaves the deck shown
|
||||
// while it is pending (see useDeciding) and, on Try it, its dinner joins the
|
||||
// Quest log unless it is there already. Nothing else is worked out here:
|
||||
// the server is the record of both. However the decision ends, saved or
|
||||
// failed, the deck and the Quest log are read again, and the decision is
|
||||
// pending until those reads land, so the next one (which DiscoverPage locks
|
||||
// out until then) cannot start under them. A read under way when the
|
||||
// decision ends is replaced by the read made after it, and DiscoverPage's
|
||||
// batch reads wait for that one rather than replace it.
|
||||
// A failed decision waits for the deck read it makes itself. Whether its
|
||||
// Side quest is still pending is then the deck query's to say, from its
|
||||
// newest successful read: once the reads have landed, a Side quest the deck
|
||||
// still has goes back on top of it.
|
||||
export function useDecide() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// send POSTs the decision and checks the answer.
|
||||
async function send(id: string, choice: Choice): Promise<string | null> {
|
||||
const action = choice === 'try' ? 'accept' : 'decline'
|
||||
const path = `${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}`
|
||||
const body = await apiFetch<unknown>(path, { method: 'POST' })
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
!isRecord(body.suggestion) ||
|
||||
body.suggestion.id !== id ||
|
||||
body.suggestion.status !== DECISION_STATUS[choice]
|
||||
) {
|
||||
throw new Error(`POST ${path}: response is not the ${DECISION_STATUS[choice]} suggestion`)
|
||||
}
|
||||
if (body.replenish_job_id !== null && typeof body.replenish_job_id !== 'string') {
|
||||
throw new Error(`POST ${path}: response has no replenish_job_id`)
|
||||
}
|
||||
return body.replenish_job_id
|
||||
}
|
||||
|
||||
return useMutation<string | null, Error, { quest: SideQuest; choice: Choice }>({
|
||||
mutationKey: DECIDE_KEY,
|
||||
mutationFn: async ({ quest, choice }) => {
|
||||
try {
|
||||
return await send(quest.id, choice)
|
||||
} catch (error) {
|
||||
// The deck is read again as the decision's own fetch, replacing any
|
||||
// read already out. A failed read is the deck query's error, shown
|
||||
// with the deck; the decision's own error is the one thrown.
|
||||
await queryClient
|
||||
.getQueryCache()
|
||||
.build<SideQuest[]>(queryClient, { queryKey: DECK_KEY })
|
||||
.fetch(undefined, { cancelRefetch: true })
|
||||
.catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
onMutate: ({ quest, choice }) => {
|
||||
if (choice !== 'try') return
|
||||
// A stale deck can offer a Side quest whose dinner the Quest log has.
|
||||
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) =>
|
||||
dinners === undefined || dinners.some((d) => d.id === quest.dinner_id) ? dinners : dinners.concat({ id: quest.dinner_id, name: quest.name }),
|
||||
)
|
||||
},
|
||||
onSettled: async (_replenishJobId, error, { quest }) => {
|
||||
if (error === null) {
|
||||
await Promise.all([queryClient.invalidateQueries({ queryKey: DECK_KEY }), queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })])
|
||||
return
|
||||
}
|
||||
// A failed decision has read the deck already; any read since is newer.
|
||||
await queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
|
||||
const pending = stillPending(queryClient.getQueryState<SideQuest[]>(DECK_KEY), quest.id)
|
||||
if (pending === null || pending === undefined) return
|
||||
queryClient.setQueryData<SideQuest[]>(DECK_KEY, (deck) => [pending, ...deck!.filter((q) => q.id !== pending.id)])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { stubAnimate, type FakeAnimation } from '../test/animate.ts'
|
||||
import { bump, dragTransform, fadeOut, follow, isAbort, pass, promote, snapBack, tryIt, unfollow } from './motion.ts'
|
||||
|
||||
let animations: FakeAnimation[]
|
||||
|
||||
beforeEach(() => {
|
||||
animations = stubAnimate({ autoFinish: false })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(Element.prototype, 'animate')
|
||||
})
|
||||
|
||||
// card builds a deck card with the parts DeckCard marks.
|
||||
function card() {
|
||||
const article = document.createElement('article')
|
||||
article.innerHTML = '<span data-part="try-stamp"></span><span data-part="pass-stamp"></span><span data-part="shade"></span>'
|
||||
return {
|
||||
article,
|
||||
stamp: article.querySelector<HTMLElement>('[data-part="try-stamp"]')!,
|
||||
passStamp: article.querySelector<HTMLElement>('[data-part="pass-stamp"]')!,
|
||||
shade: article.querySelector('[data-part="shade"]')!,
|
||||
}
|
||||
}
|
||||
|
||||
// settled says whether a promise has settled after pending callbacks run.
|
||||
async function settled(promise: Promise<unknown>) {
|
||||
let done = false
|
||||
void promise.then(
|
||||
() => (done = true),
|
||||
() => (done = true),
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
return done
|
||||
}
|
||||
|
||||
const FLY_OFF = { transform: 'translate(150%,-60px) rotate(28deg)', opacity: 0 }
|
||||
|
||||
describe('motion', () => {
|
||||
it('bumps: scale 1 → .88 → 1.06 → 1, 300 ms ease-out', () => {
|
||||
const button = document.createElement('button')
|
||||
bump(button)
|
||||
expect(animations).toHaveLength(1)
|
||||
expect(animations[0]!.target).toBe(button)
|
||||
expect(animations[0]!.keyframes).toEqual([
|
||||
{ transform: 'scale(1)' },
|
||||
{ transform: 'scale(.88)' },
|
||||
{ transform: 'scale(1.06)' },
|
||||
{ transform: 'scale(1)' },
|
||||
])
|
||||
expect(animations[0]!.options).toEqual({ duration: 300, easing: 'ease-out' })
|
||||
})
|
||||
|
||||
it('Try it pops the stamp and wiggles, then throws the card right', async () => {
|
||||
const { article, stamp } = card()
|
||||
const exit = tryIt(article)
|
||||
|
||||
const [pop, wiggle] = animations
|
||||
expect(pop!.target).toBe(stamp)
|
||||
expect(pop!.keyframes).toEqual([
|
||||
{ opacity: 0, transform: 'rotate(-12deg) scale(1.5)' },
|
||||
{ opacity: 1, transform: 'rotate(-12deg) scale(1)' },
|
||||
])
|
||||
expect(pop!.options).toEqual({ duration: 220, easing: 'cubic-bezier(.34,1.4,.64,1)', fill: 'forwards' })
|
||||
expect(wiggle!.target).toBe(article)
|
||||
expect(wiggle!.keyframes).toEqual(['0deg', '-4deg', '4deg', '-3deg', '2deg', '0deg'].map((a) => ({ transform: `rotate(${a})` })))
|
||||
expect(wiggle!.options).toEqual({ duration: 420, easing: 'ease-in-out' })
|
||||
expect(animations).toHaveLength(2)
|
||||
|
||||
// The throw waits for the wiggle.
|
||||
pop!.finish()
|
||||
wiggle!.finish()
|
||||
await expect.poll(() => animations.length).toBe(3)
|
||||
const fly = animations[2]!
|
||||
expect(fly.target).toBe(article)
|
||||
expect(fly.keyframes).toEqual([
|
||||
{ transform: 'translate(0px,0px) rotate(0deg)' },
|
||||
{ transform: 'translate(-14px,4px) rotate(-3deg)', offset: 0.18 },
|
||||
FLY_OFF,
|
||||
])
|
||||
expect(fly.options).toEqual({ duration: 480, easing: 'cubic-bezier(.55,0,.8,.4)', fill: 'forwards' })
|
||||
|
||||
expect(await settled(exit.finished)).toBe(false)
|
||||
fly.finish()
|
||||
await expect(exit.finished).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('Try it from a dragged transform skips the wiggle and throws in 340 ms', async () => {
|
||||
const { article } = card()
|
||||
const exit = tryIt(article, 'translate(120px,9px) rotate(6.7deg)')
|
||||
await expect.poll(() => animations.length).toBe(2)
|
||||
const fly = animations[1]!
|
||||
expect(fly.target).toBe(article)
|
||||
expect(fly.keyframes).toEqual([{ transform: 'translate(120px,9px) rotate(6.7deg)' }, FLY_OFF])
|
||||
expect(fly.options).toEqual({ duration: 340, easing: 'cubic-bezier(.55,0,.8,.4)', fill: 'forwards' })
|
||||
fly.finish()
|
||||
await expect(exit.finished).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('Pass folds the card over its left edge under a darkening shade', async () => {
|
||||
const { article, shade } = card()
|
||||
const exit = pass(article)
|
||||
|
||||
const [darken, fold] = animations
|
||||
expect(animations).toHaveLength(2)
|
||||
expect(darken!.target).toBe(shade)
|
||||
expect(darken!.keyframes).toEqual([{ opacity: 0 }, { opacity: 0.4, offset: 0.45 }, { opacity: 0.55 }])
|
||||
expect(darken!.options).toEqual({ duration: 720, fill: 'forwards' })
|
||||
expect(fold!.target).toBe(article)
|
||||
expect(fold!.keyframes).toEqual([
|
||||
{ transformOrigin: '0% 50%', transform: 'translate(0px,0px) rotate(0deg) rotateY(0deg)', easing: 'cubic-bezier(.45,0,.85,.55)' },
|
||||
{
|
||||
transformOrigin: '0% 50%',
|
||||
transform: 'translate(0px,0px) rotate(0deg) rotateY(-80deg)',
|
||||
offset: 0.45,
|
||||
easing: 'cubic-bezier(.4,0,.9,.6)',
|
||||
},
|
||||
{ transformOrigin: '0% 50%', transform: 'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)', opacity: 0 },
|
||||
])
|
||||
expect(fold!.options).toEqual({ duration: 720, fill: 'forwards' })
|
||||
|
||||
darken!.finish()
|
||||
expect(await settled(exit.finished)).toBe(false)
|
||||
fold!.finish()
|
||||
await expect(exit.finished).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('Pass from a dragged transform folds from there', () => {
|
||||
const { article } = card()
|
||||
pass(article, 'translate(-130px,6px) rotate(-7deg)')
|
||||
const fold = animations[1]!
|
||||
expect(fold.keyframes.map((k) => k.transform)).toEqual([
|
||||
'translate(-130px,6px) rotate(-7deg) rotateY(0deg)',
|
||||
'translate(-130px,6px) rotate(-7deg) rotateY(-80deg)',
|
||||
'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)',
|
||||
])
|
||||
})
|
||||
|
||||
it('fades out in 200 ms with reduced motion', async () => {
|
||||
const { article } = card()
|
||||
const exit = fadeOut(article)
|
||||
expect(animations).toHaveLength(1)
|
||||
expect(animations[0]!.target).toBe(article)
|
||||
expect(animations[0]!.keyframes).toEqual([{ opacity: 1 }, { opacity: 0 }])
|
||||
expect(animations[0]!.options).toEqual({ duration: 200, fill: 'forwards' })
|
||||
animations[0]!.finish()
|
||||
await expect(exit.finished).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('promotes the top two cards from the depth behind, 300 ms', () => {
|
||||
const cards = [card().article, card().article, card().article]
|
||||
promote(cards)
|
||||
expect(animations.map((a) => a.target)).toEqual([cards[0], cards[1]])
|
||||
expect(animations.map((a) => a.keyframes)).toEqual([
|
||||
[{ transform: 'translateY(8px) scale(0.95)' }, { transform: 'translateY(0px) scale(1)' }],
|
||||
[{ transform: 'translateY(16px) scale(0.9)' }, { transform: 'translateY(8px) scale(0.95)' }],
|
||||
])
|
||||
for (const a of animations) expect(a.options).toEqual({ duration: 300, easing: 'cubic-bezier(.2,.8,.2,1)' })
|
||||
})
|
||||
|
||||
it('cancel stops an exit and never starts its next step', async () => {
|
||||
const { article } = card()
|
||||
const exit = tryIt(article)
|
||||
animations[1]!.finish()
|
||||
exit.cancel()
|
||||
const error: unknown = await exit.finished.catch((e: unknown) => e)
|
||||
expect(isAbort(error)).toBe(true)
|
||||
expect(animations).toHaveLength(2)
|
||||
expect(animations.map((a) => a.playState)).toEqual(['idle', 'idle'])
|
||||
})
|
||||
|
||||
it('drags: translate(dx, dy·0.3) rotate(dx/18 deg)', () => {
|
||||
expect(dragTransform(90, 20)).toBe('translate(90px,6px) rotate(5deg)')
|
||||
expect(dragTransform(-36, -10)).toBe('translate(-36px,-3px) rotate(-2deg)')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[50, '0.5', '0'],
|
||||
[-50, '0', '0.5'],
|
||||
[160, '1', '0'],
|
||||
[-160, '0', '1'],
|
||||
[0, '0', '0'],
|
||||
])('follows a drag of %i px with the stamps at Try it! %s, Pass %s', (dx, tryOpacity, passOpacity) => {
|
||||
const { article, stamp, passStamp } = card()
|
||||
expect(follow(article, dx, 0)).toBe(dragTransform(dx, 0))
|
||||
expect(article.style.transform).toBe(dragTransform(dx, 0))
|
||||
expect(stamp.style.opacity).toBe(tryOpacity)
|
||||
expect(passStamp.style.opacity).toBe(passOpacity)
|
||||
expect(animations).toEqual([])
|
||||
})
|
||||
|
||||
it('unfollows at once, back to the stylesheet', () => {
|
||||
const { article, stamp, passStamp } = card()
|
||||
follow(article, -80, 12)
|
||||
unfollow(article)
|
||||
expect(article.getAttribute('style')).toBe('')
|
||||
expect(stamp.getAttribute('style')).toBe('')
|
||||
expect(passStamp.getAttribute('style')).toBe('')
|
||||
expect(animations).toEqual([])
|
||||
})
|
||||
|
||||
it('snaps back from the drag transform to rest, 420 ms spring, and hides the stamps', () => {
|
||||
const { article, stamp, passStamp } = card()
|
||||
const from = follow(article, 70, 30)
|
||||
snapBack(article, from)
|
||||
expect(article.style.transform).toBe('')
|
||||
expect(stamp.style.opacity).toBe('')
|
||||
expect(passStamp.style.opacity).toBe('')
|
||||
expect(animations).toHaveLength(1)
|
||||
expect(animations[0]!.target).toBe(article)
|
||||
expect(animations[0]!.keyframes).toEqual([{ transform: 'translate(70px,9px) rotate(3.888888888888889deg)' }, { transform: 'translate(0px,0px) rotate(0deg)' }])
|
||||
expect(animations[0]!.options).toEqual({ duration: 420, easing: 'cubic-bezier(.34,1.4,.64,1)' })
|
||||
})
|
||||
|
||||
it('fails fast on a card without its parts', () => {
|
||||
expect(() => tryIt(document.createElement('article'))).toThrow('deck card has no try-stamp')
|
||||
expect(() => pass(document.createElement('article'))).toThrow('deck card has no shade')
|
||||
expect(() => follow(document.createElement('article'), 10, 0)).toThrow('deck card has no try-stamp')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
// The deck's motion, from the handoff's Motion §2 (Try it), §3 (Pass),
|
||||
// §4 (swipe), §5 (deck advance) and §6 (reduced motion), with the Web
|
||||
// Animations API. The exits take the card's starting transform so the swipe
|
||||
// gesture can throw a dragged card from where it was let go.
|
||||
|
||||
const SPRING = 'cubic-bezier(.34,1.4,.64,1)'
|
||||
const THROW = 'cubic-bezier(.55,0,.8,.4)'
|
||||
const SOFT = 'cubic-bezier(.2,.8,.2,1)'
|
||||
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
|
||||
|
||||
// REST is the top card's transform when nothing moves it, written as the
|
||||
// translate + rotate a drag gives it so the exits interpolate function by
|
||||
// function.
|
||||
export const REST = 'translate(0px,0px) rotate(0deg)'
|
||||
|
||||
// DEPTH_TRANSFORM is each deck depth's transform, as the stylesheet places it.
|
||||
export const DEPTH_TRANSFORM = ['translateY(0px) scale(1)', 'translateY(8px) scale(0.95)', 'translateY(16px) scale(0.9)'] as const
|
||||
|
||||
const FLY_OFF = 'translate(150%,-60px) rotate(28deg)'
|
||||
const PASS_ORIGIN = '0% 50%'
|
||||
|
||||
// An Exit is a card leaving the deck: finished resolves once it is off
|
||||
// screen and rejects with an AbortError if cancel stops it first. cancel
|
||||
// also undoes a finished exit, putting the card back.
|
||||
export type Exit = {
|
||||
finished: Promise<void>
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return window.matchMedia(REDUCED_MOTION_QUERY).matches
|
||||
}
|
||||
|
||||
// isAbort says whether an exit's finished rejected because it was canceled.
|
||||
export function isAbort(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === 'AbortError'
|
||||
}
|
||||
|
||||
// bump is the press bump of the Try it and Pass buttons and the Quest log pill.
|
||||
export function bump(element: Element): Animation {
|
||||
return element.animate(
|
||||
[{ transform: 'scale(1)' }, { transform: 'scale(.88)' }, { transform: 'scale(1.06)' }, { transform: 'scale(1)' }],
|
||||
{ duration: 300, easing: 'ease-out' },
|
||||
)
|
||||
}
|
||||
|
||||
// tryIt pops the "Try it!" stamp and throws the card off to the right. From
|
||||
// rest it wiggles first and winds up before the throw; from a dragged
|
||||
// transform it skips both and throws faster.
|
||||
export function tryIt(card: HTMLElement, from?: string): Exit {
|
||||
const exit = recorder()
|
||||
exit.run(part(card, 'try-stamp'), [
|
||||
{ opacity: 0, transform: 'rotate(-12deg) scale(1.5)' },
|
||||
{ opacity: 1, transform: 'rotate(-12deg) scale(1)' },
|
||||
], { duration: 220, easing: SPRING, fill: 'forwards' })
|
||||
const wiggled =
|
||||
from === undefined
|
||||
? exit.run(card, [
|
||||
{ transform: 'rotate(0deg)' },
|
||||
{ transform: 'rotate(-4deg)' },
|
||||
{ transform: 'rotate(4deg)' },
|
||||
{ transform: 'rotate(-3deg)' },
|
||||
{ transform: 'rotate(2deg)' },
|
||||
{ transform: 'rotate(0deg)' },
|
||||
], { duration: 420, easing: 'ease-in-out' }).finished
|
||||
: Promise.resolve()
|
||||
const finished = wiggled.then(() =>
|
||||
from === undefined
|
||||
? exit.run(card, [
|
||||
{ transform: REST },
|
||||
{ transform: 'translate(-14px,4px) rotate(-3deg)', offset: 0.18 },
|
||||
{ transform: FLY_OFF, opacity: 0 },
|
||||
], { duration: 480, easing: THROW, fill: 'forwards' }).finished
|
||||
: exit.run(card, [{ transform: from }, { transform: FLY_OFF, opacity: 0 }], { duration: 340, easing: THROW, fill: 'forwards' })
|
||||
.finished,
|
||||
)
|
||||
return exit.done(finished)
|
||||
}
|
||||
|
||||
// pass folds the card over its left edge like a page, darkening it, then
|
||||
// drops it off to the left. A dragged card folds from where it was let go.
|
||||
export function pass(card: HTMLElement, from: string = REST): Exit {
|
||||
const exit = recorder()
|
||||
exit.run(part(card, 'shade'), [{ opacity: 0 }, { opacity: 0.4, offset: 0.45 }, { opacity: 0.55 }], {
|
||||
duration: 720,
|
||||
fill: 'forwards',
|
||||
})
|
||||
const finished = exit.run(card, [
|
||||
{ transformOrigin: PASS_ORIGIN, transform: `${from} rotateY(0deg)`, easing: 'cubic-bezier(.45,0,.85,.55)' },
|
||||
{ transformOrigin: PASS_ORIGIN, transform: `${from} rotateY(-80deg)`, offset: 0.45, easing: 'cubic-bezier(.4,0,.9,.6)' },
|
||||
{ transformOrigin: PASS_ORIGIN, transform: 'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)', opacity: 0 },
|
||||
], { duration: 720, fill: 'forwards' }).finished
|
||||
return exit.done(finished)
|
||||
}
|
||||
|
||||
// dragTransform is the top card's transform dragged dx, dy from where the
|
||||
// pointer went down.
|
||||
export function dragTransform(dx: number, dy: number): string {
|
||||
return `translate(${dx}px,${dy * 0.3}px) rotate(${dx / 18}deg)`
|
||||
}
|
||||
|
||||
// follow moves a dragged card to its drag transform and shows each stamp as
|
||||
// far as the drag has gone toward its side, fully at 100 px. It returns the
|
||||
// transform, for the exit or snap-back the release starts.
|
||||
export function follow(card: HTMLElement, dx: number, dy: number): string {
|
||||
const transform = dragTransform(dx, dy)
|
||||
card.style.transform = transform
|
||||
part(card, 'try-stamp').style.opacity = String(clamp(dx / 100))
|
||||
part(card, 'pass-stamp').style.opacity = String(clamp(-dx / 100))
|
||||
return transform
|
||||
}
|
||||
|
||||
// unfollow puts a dragged card and its stamps back where the stylesheet
|
||||
// places them, at once.
|
||||
export function unfollow(card: HTMLElement) {
|
||||
card.style.removeProperty('transform')
|
||||
part(card, 'try-stamp').style.removeProperty('opacity')
|
||||
part(card, 'pass-stamp').style.removeProperty('opacity')
|
||||
}
|
||||
|
||||
// snapBack springs a card let go short of a decision back to rest from its
|
||||
// drag transform, and hides its stamps.
|
||||
export function snapBack(card: HTMLElement, from: string): Animation {
|
||||
unfollow(card)
|
||||
return card.animate([{ transform: from }, { transform: REST }], { duration: 420, easing: SPRING })
|
||||
}
|
||||
|
||||
// fadeOut is both exits under prefers-reduced-motion: reduce.
|
||||
export function fadeOut(card: HTMLElement): Exit {
|
||||
const exit = recorder()
|
||||
return exit.done(exit.run(card, [{ opacity: 1 }, { opacity: 0 }], { duration: 200, fill: 'forwards' }).finished)
|
||||
}
|
||||
|
||||
// promote moves the cards left after an exit, top first, from the depth
|
||||
// behind to their own.
|
||||
export function promote(cards: HTMLElement[]): Animation[] {
|
||||
return cards.slice(0, DEPTH_TRANSFORM.length - 1).map((card, depth) =>
|
||||
card.animate([{ transform: DEPTH_TRANSFORM[depth + 1] }, { transform: DEPTH_TRANSFORM[depth] }], {
|
||||
duration: 300,
|
||||
easing: SOFT,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function clamp(value: number): number {
|
||||
return Math.min(1, Math.max(0, value))
|
||||
}
|
||||
|
||||
// part is the card's element marked data-part="name", which DeckCard renders.
|
||||
function part(card: HTMLElement, name: string): HTMLElement {
|
||||
const element = card.querySelector<HTMLElement>(`[data-part="${name}"]`)
|
||||
if (element === null) throw new Error(`deck card has no ${name}`)
|
||||
return element
|
||||
}
|
||||
|
||||
// recorder keeps every animation an exit starts, so cancel can stop them all.
|
||||
// A step that would start after cancel aborts instead.
|
||||
function recorder() {
|
||||
const animations: Animation[] = []
|
||||
let canceled = false
|
||||
return {
|
||||
run(element: Element, keyframes: Keyframe[], options: KeyframeAnimationOptions): Animation {
|
||||
if (canceled) throw new DOMException('The exit was canceled.', 'AbortError')
|
||||
const animation = element.animate(keyframes, options)
|
||||
animations.push(animation)
|
||||
return animation
|
||||
},
|
||||
done(finished: Promise<unknown>): Exit {
|
||||
return {
|
||||
finished: finished.then(() => undefined),
|
||||
cancel: () => {
|
||||
canceled = true
|
||||
animations.forEach((animation) => animation.cancel())
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
+11
-4
@@ -1,6 +1,6 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router'
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router'
|
||||
import './styles/tokens.css'
|
||||
import './styles/base.css'
|
||||
import App from './App.tsx'
|
||||
@@ -14,10 +14,17 @@ if (!root) {
|
||||
|
||||
const api = createApiClient(window.location, createNetworkStatus(window), createVerifiedSession(window.localStorage))
|
||||
|
||||
// A data router, so pages can block navigation (useBlocker); App's own
|
||||
// <Routes> match every path under its splat route.
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '*',
|
||||
element: <App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />,
|
||||
},
|
||||
])
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />
|
||||
</BrowserRouter>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { Route, Routes } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { createApiClient, createVerifiedSession } from '../api/index.ts'
|
||||
import App from '../App.tsx'
|
||||
import { AuthBootstrap } from '../AuthBootstrap.tsx'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { NotificationsStep, notificationsUnavailable, type EnableNotifications } from './NotificationsStep.tsx'
|
||||
import styles from './Onboarding.module.css'
|
||||
|
||||
const IPHONE_SAFARI =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1'
|
||||
|
||||
const COMPLETED_AT = '2026-10-06T21:30:00.000Z'
|
||||
|
||||
// requestPermission stands in for the browser's Notification API, which
|
||||
// jsdom lacks, so a test can prove nothing asks for permission.
|
||||
let requestPermission: Mock<() => Promise<NotificationPermission>>
|
||||
|
||||
beforeEach(() => {
|
||||
requestPermission = vi.fn<() => Promise<NotificationPermission>>(() => Promise.resolve('granted'))
|
||||
vi.stubGlobal('Notification', { permission: 'default', requestPermission })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(navigator, 'maxTouchPoints')
|
||||
Reflect.deleteProperty(navigator, 'standalone')
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
// A PATCH the fake server holds until the test answers it.
|
||||
type Held = { body: unknown; answer: (response: Response) => void }
|
||||
|
||||
// stubFirstRunServer stands in for the server on first run: signed in,
|
||||
// onboarding not finished. Each PATCH of the household settings is held in
|
||||
// `held` until the test answers it.
|
||||
function stubFirstRunServer() {
|
||||
const held: Held[] = []
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const path = String(input)
|
||||
const method = init?.method ?? 'GET'
|
||||
if (method === 'GET' && path === '/api/v1/me') return Promise.resolve(jsonResponse(200, ME))
|
||||
if (method === 'GET' && path === '/api/v1/settings/household') {
|
||||
return Promise.resolve(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }))
|
||||
}
|
||||
if (method === 'PATCH' && path === '/api/v1/settings/household') {
|
||||
return new Promise<Response>((resolve) => {
|
||||
held.push({ body: JSON.parse(String(init?.body)), answer: resolve })
|
||||
})
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${method} ${path}`)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return { fetchMock, held }
|
||||
}
|
||||
|
||||
function completed(request: Held) {
|
||||
request.answer(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: COMPLETED_AT }))
|
||||
}
|
||||
|
||||
// renderApp renders the whole app at step 3, so the first-run redirect is
|
||||
// what decides where finishing lands.
|
||||
async function renderApp() {
|
||||
mockViewport(390)
|
||||
const server = stubFirstRunServer()
|
||||
const api = createApiClient(
|
||||
fakeLocation('/onboarding/notifications'),
|
||||
createNetworkStatus(window),
|
||||
createVerifiedSession(localStorage),
|
||||
)
|
||||
render(
|
||||
<InDataRouter path="/onboarding/notifications">
|
||||
<App api={api} serviceWorker={undefined} />
|
||||
</InDataRouter>,
|
||||
)
|
||||
await screen.findByRole('heading', { level: 1, name: 'Stay in the loop' })
|
||||
return server
|
||||
}
|
||||
|
||||
// renderStep renders step 3 alone with the given enableNotifications, and a
|
||||
// Home that says it was reached. AuthBootstrap reads /api/v1/me for the CSRF
|
||||
// token every write needs.
|
||||
async function renderStep(enableNotifications?: EnableNotifications) {
|
||||
const server = stubFirstRunServer()
|
||||
render(
|
||||
<InDataRouter path="/onboarding/notifications">
|
||||
<ApiProviders>
|
||||
<AuthBootstrap>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/onboarding/notifications"
|
||||
element={<NotificationsStep enableNotifications={enableNotifications} />}
|
||||
/>
|
||||
<Route path="/" element={<h1>Home</h1>} />
|
||||
<Route path="/onboarding/regulars" element={<h1>Regulars step</h1>} />
|
||||
</Routes>
|
||||
</AuthBootstrap>
|
||||
</ApiProviders>
|
||||
</InDataRouter>,
|
||||
)
|
||||
await screen.findByRole('heading', { level: 1, name: 'Stay in the loop' })
|
||||
return server
|
||||
}
|
||||
|
||||
function turnOn() {
|
||||
return screen.getByRole('button', { name: 'Turn on notifications' })
|
||||
}
|
||||
|
||||
function later() {
|
||||
return screen.getByRole('button', { name: 'Maybe later' })
|
||||
}
|
||||
|
||||
describe('onboarding step 3', () => {
|
||||
it('explains what MealQuest notifies about, with all three progress pills filled', async () => {
|
||||
await renderApp()
|
||||
|
||||
expect(screen.getByText('Step 3 of 3')).toBeInTheDocument()
|
||||
const pills = Array.from(document.getElementsByClassName(styles.pill!))
|
||||
expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, true, true])
|
||||
const topics = within(screen.getByRole('list')).getAllByRole('listitem')
|
||||
expect(topics.map((topic) => topic.textContent)).toEqual([
|
||||
expect.stringMatching(/^Plan ready/),
|
||||
expect.stringMatching(/^Supply run ready/),
|
||||
expect.stringMatching(/^Tonight's dinner/),
|
||||
expect.stringMatching(/^New Side quests/),
|
||||
])
|
||||
expect(turnOn()).toBeEnabled()
|
||||
expect(later()).toBeEnabled()
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('never asks for notification permission on render', async () => {
|
||||
await renderApp()
|
||||
expect(requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sizes both actions as tap targets and fixes them to the bottom', async () => {
|
||||
await renderApp()
|
||||
expectMinHitArea(turnOn())
|
||||
expectMinHitArea(later())
|
||||
expect(declaredStyle(turnOn()).height).toBe('52px')
|
||||
expect(declaredStyle(later()).height).toBe('52px')
|
||||
expect(declaredStyle(turnOn().parentElement!.parentElement!).position).toBe('fixed')
|
||||
})
|
||||
|
||||
it('shows no install hint outside iOS Safari', async () => {
|
||||
await renderApp()
|
||||
expect(screen.queryByText('Tap Share, then Add to Home Screen to get notifications')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the install hint in an iOS Safari tab', async () => {
|
||||
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(IPHONE_SAFARI)
|
||||
Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 })
|
||||
Object.defineProperty(navigator, 'standalone', { configurable: true, value: false })
|
||||
vi.stubGlobal('matchMedia', (query: string) => ({ matches: false, media: query }) as MediaQueryList)
|
||||
await renderStep()
|
||||
expect(screen.getByRole('note')).toHaveTextContent('Tap Share, then Add to Home Screen to get notifications')
|
||||
})
|
||||
|
||||
it('"Maybe later" finishes onboarding and goes Home, ending the first-run redirect', async () => {
|
||||
const server = await renderApp()
|
||||
fireEvent.click(later())
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
|
||||
expect(turnOn()).toBeDisabled()
|
||||
expect(later()).toBeDisabled()
|
||||
completed(server.held[0]!)
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
expect(requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('"Turn on notifications" finishes onboarding and goes Home while notifications are unavailable', async () => {
|
||||
const server = await renderApp()
|
||||
fireEvent.click(turnOn())
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
|
||||
completed(server.held[0]!)
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
expect(requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('"Turn on notifications" asks the injected enableNotifications first, then finishes', async () => {
|
||||
let answer: (result: 'enabled') => void = () => {}
|
||||
const enable = vi.fn<EnableNotifications>(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
answer = resolve
|
||||
}),
|
||||
)
|
||||
const server = await renderStep(enable)
|
||||
expect(enable).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(turnOn())
|
||||
expect(enable).toHaveBeenCalledOnce()
|
||||
expect(turnOn()).toBeDisabled()
|
||||
expect(later()).toBeDisabled()
|
||||
// Onboarding is not finished until enableNotifications has answered.
|
||||
await Promise.resolve()
|
||||
expect(server.held).toHaveLength(0)
|
||||
|
||||
answer('enabled')
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
|
||||
completed(server.held[0]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('"Maybe later" does not ask the injected enableNotifications', async () => {
|
||||
const enable = vi.fn<EnableNotifications>(() => Promise.resolve('enabled'))
|
||||
const server = await renderStep(enable)
|
||||
fireEvent.click(later())
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
completed(server.held[0]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(enable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('says why and stays when enableNotifications fails', async () => {
|
||||
const server = await renderStep(() => Promise.reject(new Error('push service down')))
|
||||
fireEvent.click(turnOn())
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not turn on notifications: push service down')
|
||||
expect(server.held).toHaveLength(0)
|
||||
expect(turnOn()).toBeEnabled()
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says why and stays when the server refuses to finish onboarding, then finishes on retry', async () => {
|
||||
const server = await renderApp()
|
||||
fireEvent.click(later())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(errorResponse(500, 'internal'))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not finish setting up: internal message')
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
|
||||
expect(later()).toBeEnabled()
|
||||
|
||||
fireEvent.click(later())
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
completed(server.held[1]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('onboarding step 3 with too few Regulars', () => {
|
||||
it('says why and offers a way back to add Regulars', async () => {
|
||||
const server = await renderStep()
|
||||
fireEvent.click(later())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(
|
||||
jsonResponse(400, {
|
||||
error: {
|
||||
code: 'invalid_settings',
|
||||
message: 'invalid household settings: onboarding_completed: add at least 3 Regulars first (have 2)',
|
||||
retryable: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Could not finish setting up: invalid household settings: onboarding_completed: add at least 3 Regulars first (have 2)',
|
||||
)
|
||||
const addRegulars = screen.getByRole('button', { name: 'Add Regulars' })
|
||||
expectMinHitArea(addRegulars)
|
||||
fireEvent.click(addRegulars)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Regulars step' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers no way back for any other refusal', async () => {
|
||||
const server = await renderStep()
|
||||
fireEvent.click(later())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(errorResponse(500, 'internal'))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not finish setting up: internal message')
|
||||
expect(screen.queryByRole('button', { name: 'Add Regulars' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('notificationsUnavailable', () => {
|
||||
it('answers "unavailable" without asking for permission', async () => {
|
||||
await expect(notificationsUnavailable()).resolves.toBe('unavailable')
|
||||
expect(requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState } from 'react'
|
||||
import { CalendarDays, ShoppingBasket, Sparkles, UtensilsCrossed, type LucideIcon } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { InstallHint } from '../pwa/InstallHint.tsx'
|
||||
import { needsMoreRegulars, useCompleteOnboarding } from '../settings/api.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { OnboardingStep } from './OnboardingStep.tsx'
|
||||
import styles from './Onboarding.module.css'
|
||||
|
||||
// NotificationsResult is how asking to turn on notifications ended.
|
||||
export type NotificationsResult = 'enabled' | 'denied' | 'unavailable'
|
||||
|
||||
export type EnableNotifications = () => Promise<NotificationsResult>
|
||||
|
||||
// TODO(Feature 19): Web Push. Until it is wired, notifications cannot be
|
||||
// turned on, and nothing asks the browser for permission.
|
||||
export function notificationsUnavailable(): Promise<NotificationsResult> {
|
||||
return Promise.resolve('unavailable')
|
||||
}
|
||||
|
||||
const topics: { icon: LucideIcon; name: string; meaning: string }[] = [
|
||||
{
|
||||
icon: CalendarDays,
|
||||
name: 'Plan ready',
|
||||
meaning: "The week's Draft board is ready for the household to adjust.",
|
||||
},
|
||||
{
|
||||
icon: ShoppingBasket,
|
||||
name: 'Supply run ready',
|
||||
meaning: "The week's shopping list, grouped by dinner, is ready to shop.",
|
||||
},
|
||||
{
|
||||
icon: UtensilsCrossed,
|
||||
name: "Tonight's dinner",
|
||||
meaning: "A reminder before dinner of what's on the plan tonight.",
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
name: 'New Side quests',
|
||||
meaning: 'Fresh dinners to Try it or Pass, picked from your Regulars.',
|
||||
},
|
||||
]
|
||||
|
||||
export type NotificationsStepProps = {
|
||||
// Asks to turn on notifications when the household taps "Turn on
|
||||
// notifications" — never on render.
|
||||
enableNotifications?: EnableNotifications
|
||||
}
|
||||
|
||||
// NotificationsStep is onboarding step 3: what MealQuest notifies about, the
|
||||
// iOS install hint, and finishing onboarding. Either button records that
|
||||
// onboarding is finished and goes Home. When the server refuses because the
|
||||
// household has fewer than three Regulars, the reason shows with a way back
|
||||
// to step 2 to add them.
|
||||
export function NotificationsStep({ enableNotifications = notificationsUnavailable }: NotificationsStepProps) {
|
||||
const navigate = useNavigate()
|
||||
const complete = useCompleteOnboarding()
|
||||
const [enabling, setEnabling] = useState(false)
|
||||
const [problem, setProblem] = useState<string | null>(null)
|
||||
const [fewRegulars, setFewRegulars] = useState(false)
|
||||
const busy = enabling || complete.isPending
|
||||
|
||||
async function finish() {
|
||||
try {
|
||||
await complete.mutateAsync()
|
||||
} catch (error) {
|
||||
setProblem(`Could not finish setting up: ${(error as Error).message}`)
|
||||
setFewRegulars(needsMoreRegulars(error as Error))
|
||||
return
|
||||
}
|
||||
void navigate('/', { replace: true })
|
||||
}
|
||||
|
||||
function clearProblem() {
|
||||
setProblem(null)
|
||||
setFewRegulars(false)
|
||||
}
|
||||
|
||||
async function turnOn() {
|
||||
clearProblem()
|
||||
setEnabling(true)
|
||||
try {
|
||||
await enableNotifications()
|
||||
} catch (error) {
|
||||
setProblem(`Could not turn on notifications: ${(error as Error).message}`)
|
||||
return
|
||||
} finally {
|
||||
setEnabling(false)
|
||||
}
|
||||
await finish()
|
||||
}
|
||||
|
||||
function later() {
|
||||
clearProblem()
|
||||
void finish()
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingStep
|
||||
step={3}
|
||||
title="Stay in the loop"
|
||||
action={
|
||||
<div className={styles.actions}>
|
||||
<Button className={styles.action} disabled={busy} onClick={() => void turnOn()}>
|
||||
Turn on notifications
|
||||
</Button>
|
||||
<Button variant="outline" className={styles.action} disabled={busy} onClick={later}>
|
||||
Maybe later
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className={styles.helper}>MealQuest can let the household know when there's something to look at:</p>
|
||||
<ul className={styles.terms}>
|
||||
{topics.map(({ icon, name, meaning }) => (
|
||||
<li key={name} className={styles.term}>
|
||||
<span className={styles.termIcon}>
|
||||
<Icon icon={icon} size="nav" />
|
||||
</span>
|
||||
<p className={styles.termText}>
|
||||
<span className={styles.termName}>{name}</span>
|
||||
{meaning}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.hint}>
|
||||
<InstallHint />
|
||||
</div>
|
||||
{problem && (
|
||||
<p role="alert" className={styles.problem}>
|
||||
{problem}
|
||||
</p>
|
||||
)}
|
||||
{fewRegulars && (
|
||||
<Button variant="outline" className={styles.recover} onClick={() => void navigate('/onboarding/regulars')}>
|
||||
Add Regulars
|
||||
</Button>
|
||||
)}
|
||||
</OnboardingStep>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/* Onboarding, handoff mock 1h: a full screen outside the app shell with
|
||||
the step's action fixed to the bottom. */
|
||||
.screen {
|
||||
min-height: 100dvh;
|
||||
box-sizing: border-box;
|
||||
/* Room for the fixed 52 px action and its padding. */
|
||||
padding: var(--space-6) var(--space-4) calc(52px + 2 * var(--space-6));
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.column {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.filled {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 30px 0 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 8px 0 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 30px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.helper {
|
||||
margin: 12px 0 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: var(--space-4) var(--space-4) var(--space-6);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
height: 52px;
|
||||
margin: 0 auto;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
/* Step 3: two stacked actions. */
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* Room for the second fixed action and the gap above it. */
|
||||
.screen:has(.actions) {
|
||||
padding-bottom: calc(2 * 52px + var(--space-3) + 2 * var(--space-6));
|
||||
}
|
||||
|
||||
/* Step 1: what MealQuest's words mean; step 3: what it notifies about. */
|
||||
.terms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
margin: var(--space-6) 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.term {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.termIcon {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent-200);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.termText {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.termName {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Step 2: the add pill, with the "+" inside it. */
|
||||
.add {
|
||||
position: relative;
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
/* Beats the TextInput's own padding whatever order the sheets load in. */
|
||||
.add .addInput {
|
||||
width: 100%;
|
||||
padding-right: 52px;
|
||||
}
|
||||
|
||||
/* 44 px hit box around the 36 px grape face, inset 2 px in the 48 px pill. */
|
||||
.addButton {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.addButton:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.addFace {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
transition: transform 100ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.addButton:disabled .addFace {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.addButton:active:not(:disabled) .addFace {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
margin: var(--space-6) 0 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.pending {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.pick {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
border: 1.5px dashed var(--color-neutral-400);
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pick:active {
|
||||
background: var(--color-neutral-200);
|
||||
}
|
||||
|
||||
.problem {
|
||||
margin: var(--space-3) 0 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
/* Step 3: back to step 2 when the server wants more Regulars. */
|
||||
.recover {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Step 3: the iOS install hint, when it shows. */
|
||||
.hint {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.hint:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.addFace {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Navigate, Outlet, useLocation } from 'react-router'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { LoadingScreen } from '../AuthBootstrap.tsx'
|
||||
import { useHouseholdSettings } from '../settings/api.ts'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import styles from '../pages/FullScreen.module.css'
|
||||
|
||||
export const ONBOARDING_PATH = '/onboarding'
|
||||
|
||||
function inOnboarding(pathname: string): boolean {
|
||||
return pathname === ONBOARDING_PATH || pathname.startsWith(`${ONBOARDING_PATH}/`)
|
||||
}
|
||||
|
||||
// OnboardingGate reads the household settings before any page: while
|
||||
// onboarding_completed_at is null, every route outside /onboarding
|
||||
// redirects there.
|
||||
//
|
||||
// The service worker never caches the settings, so offline on the saved
|
||||
// pages (see AuthBootstrap) they cannot be read: the saved pages open as they
|
||||
// are, since onboarding needs the server anyway, and the settings are read
|
||||
// again when the browser is back online.
|
||||
export function OnboardingGate() {
|
||||
const api = useApi()
|
||||
const { pathname } = useLocation()
|
||||
const settings = useHouseholdSettings()
|
||||
const offline = settings.isError && settings.error instanceof TypeError && api.session.verified()
|
||||
const { refetch } = settings
|
||||
|
||||
useEffect(() => {
|
||||
if (!offline) return
|
||||
return api.network.subscribe(() => {
|
||||
if (!api.network.offline()) void refetch()
|
||||
})
|
||||
}, [offline, api, refetch])
|
||||
|
||||
if (settings.data !== undefined) {
|
||||
if (settings.data.onboarding_completed_at === null && !inOnboarding(pathname)) {
|
||||
return <Navigate to={ONBOARDING_PATH} replace />
|
||||
}
|
||||
return <Outlet />
|
||||
}
|
||||
if (offline) return <Outlet />
|
||||
if (settings.isError) {
|
||||
return (
|
||||
<main className={styles.screen}>
|
||||
<ErrorBanner onRetry={() => void refetch()}>MealQuest could not load: {settings.error.message}</ErrorBanner>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
return <LoadingScreen />
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { createApiClient, createVerifiedSession } from '../api/index.ts'
|
||||
import App from '../App.tsx'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea, hitArea } from '../test/hitArea.ts'
|
||||
import styles from './Onboarding.module.css'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
type Dinner = DinnerBody
|
||||
|
||||
// A request the fake server holds until the test answers it.
|
||||
type Held = { method: string; path: string; body: unknown; answer: (response: Response) => void }
|
||||
|
||||
// stubDinnersServer stands in for the server a household meets on first
|
||||
// run: signed in, onboarding not finished and the given Regulars. GET answers at once;
|
||||
// each POST and DELETE is held in `held` until the test answers it, so the
|
||||
// test sees the optimistic list first. Once failReads is set, every GET of
|
||||
// the Regulars fails, so the test sees the list the client keeps by itself.
|
||||
function stubDinnersServer(regulars: Dinner[] = []) {
|
||||
const held: Held[] = []
|
||||
const reads = { fail: false }
|
||||
const fetchMock: Mock<typeof fetch> = vi.fn<typeof fetch>((input, init) => {
|
||||
const path = String(input)
|
||||
const method = init?.method ?? 'GET'
|
||||
if (method === 'GET' && path === '/api/v1/me') return Promise.resolve(jsonResponse(200, ME))
|
||||
if (method === 'GET' && path === '/api/v1/settings/household') {
|
||||
return Promise.resolve(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }))
|
||||
}
|
||||
if (method === 'GET' && path === '/api/v1/dinners?status=regular') {
|
||||
if (reads.fail) return Promise.resolve(errorResponse(500, 'internal'))
|
||||
return Promise.resolve(jsonResponse(200, { dinners: regulars }))
|
||||
}
|
||||
if ((method === 'POST' && path === '/api/v1/dinners') || (method === 'DELETE' && path.startsWith('/api/v1/dinners/'))) {
|
||||
return new Promise<Response>((resolve) => {
|
||||
held.push({ method, path, body: init?.body === undefined ? undefined : JSON.parse(String(init.body)), answer: resolve })
|
||||
})
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${method} ${path}`)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return { fetchMock, held, regulars, reads }
|
||||
}
|
||||
|
||||
// created answers a held POST /api/v1/dinners as the server does, adding the
|
||||
// Regular to the list later GETs read.
|
||||
function created(server: ReturnType<typeof stubDinnersServer>, request: Held, id: string) {
|
||||
const { name } = request.body as { name: string }
|
||||
server.regulars.push(dinnerBody(id, name))
|
||||
request.answer(jsonResponse(201, { ...dinnerBody(id, name, { grounding_status: 'pending' }), grounding_job_id: 'job-1' }))
|
||||
}
|
||||
|
||||
// renderAt renders the whole app, so /me supplies the CSRF token every
|
||||
// write needs and OnboardingGate reads the household settings.
|
||||
function renderAt(path: string) {
|
||||
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
render(
|
||||
<InDataRouter path={path}>
|
||||
<App api={api} serviceWorker={undefined} />
|
||||
</InDataRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
async function regularsStep(regulars: Dinner[] = []) {
|
||||
const server = stubDinnersServer(regulars)
|
||||
renderAt('/onboarding/regulars')
|
||||
// Common picks show once the Regulars have loaded.
|
||||
await screen.findByRole('heading', { level: 2, name: 'Common picks' })
|
||||
expect(screen.getByRole('heading', { level: 2, name: `Your regulars · ${regulars.length}` })).toBeInTheDocument()
|
||||
return server
|
||||
}
|
||||
|
||||
function input() {
|
||||
return screen.getByRole('textbox', { name: 'Dinner name' })
|
||||
}
|
||||
|
||||
function regularsList() {
|
||||
return screen.getByRole('heading', { level: 2, name: /^Your regulars/ }).nextElementSibling as HTMLElement
|
||||
}
|
||||
|
||||
function regularNames() {
|
||||
return within(regularsList())
|
||||
.queryAllByRole('listitem')
|
||||
.map((item) => item.textContent)
|
||||
}
|
||||
|
||||
function next() {
|
||||
return screen.getByRole('button', { name: 'Next: notifications' })
|
||||
}
|
||||
|
||||
describe('onboarding step 1', () => {
|
||||
it('welcomes the household, explains the words and starts with step 2', async () => {
|
||||
stubDinnersServer()
|
||||
renderAt('/onboarding')
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Step 1 of 3')).toBeInTheDocument()
|
||||
const terms = within(screen.getByRole('list')).getAllByRole('listitem')
|
||||
expect(terms.map((term) => term.textContent)).toEqual([
|
||||
expect.stringMatching(/^Regulars/),
|
||||
expect.stringMatching(/^Side quests/),
|
||||
expect.stringMatching(/^Quest log/),
|
||||
])
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: "Let's start" }))
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('fills one progress pill of three', async () => {
|
||||
stubDinnersServer()
|
||||
renderAt('/onboarding')
|
||||
await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })
|
||||
const pills = Array.from(document.getElementsByClassName(styles.pill!))
|
||||
expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, false, false])
|
||||
})
|
||||
})
|
||||
|
||||
describe('onboarding step 2 (mock 1h)', () => {
|
||||
it('shows the step as the mock does', async () => {
|
||||
await regularsStep([dinnerBody('d1', 'Beef tacos')])
|
||||
|
||||
expect(screen.getByText('Step 2 of 3')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Add the dinners your household makes on repeat. MealQuest builds suggestions from these.'),
|
||||
).toBeInTheDocument()
|
||||
expect(input()).toHaveAttribute('placeholder', 'e.g. Chicken enchiladas')
|
||||
expect(regularNames()).toEqual(['Beef tacos'])
|
||||
expect(screen.getByRole('button', { name: 'Remove Beef tacos' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Common picks' })).toBeInTheDocument()
|
||||
// A common pick the household already has is not offered again.
|
||||
expect(screen.queryByRole('button', { name: 'Add Beef tacos' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Chili' })).toHaveTextContent('+Chili')
|
||||
|
||||
const pills = Array.from(document.getElementsByClassName(styles.pill!))
|
||||
expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, true, false])
|
||||
expect(declaredStyle(pills[0]!).height).toBe('6px')
|
||||
})
|
||||
|
||||
it('does not offer a common pick the server would refuse as a duplicate of a Regular', async () => {
|
||||
await regularsStep([dinnerBody('d1', 'Homemade chili'), dinnerBody('d2', 'Easy Chicken Stir Fry')])
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Add Chili' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Add Chicken stir-fry' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Beef tacos' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sizes the input pill, "+" button, picks and Next as the mock does', async () => {
|
||||
await regularsStep()
|
||||
expect(hitArea(input()).height).toBe(48)
|
||||
const add = screen.getByRole('button', { name: 'Add Regular' })
|
||||
expectMinHitArea(add)
|
||||
expect(declaredStyle(add.firstElementChild!).width).toBe('36px')
|
||||
expect(declaredStyle(add.firstElementChild!).height).toBe('36px')
|
||||
expectMinHitArea(screen.getByRole('button', { name: 'Add Chili' }))
|
||||
expect(declaredStyle(next()).height).toBe('52px')
|
||||
expect(declaredStyle(next().parentElement!).position).toBe('fixed')
|
||||
})
|
||||
|
||||
it('adds a Regular on Enter, showing it before the server answers', async () => {
|
||||
const server = await regularsStep()
|
||||
fireEvent.change(input(), { target: { value: ' Chicken enchiladas ' } })
|
||||
// Enter in the input submits its form.
|
||||
fireEvent.submit(input())
|
||||
|
||||
expect(input()).toHaveValue('')
|
||||
await vi.waitFor(() => expect(regularNames()).toEqual(['Chicken enchiladas']))
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Your regulars · 1' })).toBeInTheDocument()
|
||||
// Not removable until the server has given it an id.
|
||||
expect(screen.queryByRole('button', { name: 'Remove Chicken enchiladas' })).not.toBeInTheDocument()
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]).toMatchObject({ method: 'POST', path: '/api/v1/dinners', body: { name: 'Chicken enchiladas' } })
|
||||
created(server, server.held[0]!, 'd1')
|
||||
expect(await screen.findByRole('button', { name: 'Remove Chicken enchiladas' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds a Regular with the "+" button', async () => {
|
||||
const server = await regularsStep()
|
||||
const add = screen.getByRole('button', { name: 'Add Regular' })
|
||||
expect(add).toBeDisabled()
|
||||
fireEvent.change(input(), { target: { value: 'Shrimp scampi' } })
|
||||
fireEvent.click(add)
|
||||
|
||||
await vi.waitFor(() => expect(regularNames()).toEqual(['Shrimp scampi']))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]).toMatchObject({ method: 'POST', body: { name: 'Shrimp scampi' } })
|
||||
})
|
||||
|
||||
it('adds a common pick on tap and stops offering it', async () => {
|
||||
const server = await regularsStep()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
|
||||
|
||||
await vi.waitFor(() => expect(regularNames()).toEqual(['Chili']))
|
||||
expect(screen.queryByRole('button', { name: 'Add Chili' })).not.toBeInTheDocument()
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]).toMatchObject({ method: 'POST', path: '/api/v1/dinners', body: { name: 'Chili' } })
|
||||
})
|
||||
|
||||
it('takes an add back out and says why when the server refuses it', async () => {
|
||||
const server = await regularsStep([dinnerBody('d1', 'Chili')])
|
||||
fireEvent.change(input(), { target: { value: 'chili' } })
|
||||
fireEvent.submit(input())
|
||||
await vi.waitFor(() => expect(regularNames()).toEqual(['Chili', 'chili']))
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add chili: duplicate_dinner message')
|
||||
expect(regularNames()).toEqual(['Chili'])
|
||||
})
|
||||
|
||||
it('says why an add was refused while another add is still unanswered', async () => {
|
||||
const server = await regularsStep()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
expect(regularNames()).toEqual(['Burgers', 'Chili'])
|
||||
|
||||
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
|
||||
expect(regularNames()).toEqual(['Chili'])
|
||||
|
||||
// The other add's answer does not hide the refusal.
|
||||
created(server, server.held[1]!, 'd1')
|
||||
await screen.findByRole('button', { name: 'Remove Chili' })
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
|
||||
expect(regularNames()).toEqual(['Chili'])
|
||||
})
|
||||
|
||||
it('says why each of two overlapping adds was refused', async () => {
|
||||
const server = await regularsStep()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
|
||||
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
|
||||
server.held[1]!.answer(errorResponse(500, 'internal'))
|
||||
await vi.waitFor(() =>
|
||||
expect(screen.getAllByRole('alert').map((alert) => alert.textContent)).toEqual([
|
||||
'Could not add Burgers: duplicate_dinner message',
|
||||
'Could not add Chili: internal message',
|
||||
]),
|
||||
)
|
||||
expect(regularNames()).toEqual([])
|
||||
})
|
||||
|
||||
it('says why each of two overlapping adds of the same name was refused', async () => {
|
||||
const server = await regularsStep()
|
||||
fireEvent.change(input(), { target: { value: 'Lasagne' } })
|
||||
fireEvent.submit(input())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
fireEvent.change(input(), { target: { value: 'Lasagne' } })
|
||||
fireEvent.submit(input())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
expect(regularNames()).toEqual(['Lasagne', 'Lasagne'])
|
||||
|
||||
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Lasagne: duplicate_dinner message')
|
||||
expect(regularNames()).toEqual(['Lasagne'])
|
||||
|
||||
// The second add's refusal shows beside the first instead of replacing it.
|
||||
server.held[1]!.answer(errorResponse(500, 'internal'))
|
||||
await vi.waitFor(() =>
|
||||
expect(screen.getAllByRole('alert').map((alert) => alert.textContent)).toEqual([
|
||||
'Could not add Lasagne: duplicate_dinner message',
|
||||
'Could not add Lasagne: internal message',
|
||||
]),
|
||||
)
|
||||
expect(regularNames()).toEqual([])
|
||||
})
|
||||
|
||||
it('removes a Regular at once and archives it on the server', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
|
||||
|
||||
await vi.waitFor(() => expect(regularNames()).toEqual(['Pizza night']))
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Your regulars · 1' })).toBeInTheDocument()
|
||||
// Removed, it is a common pick again.
|
||||
expect(screen.getByRole('button', { name: 'Add Beef tacos' })).toBeInTheDocument()
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]).toMatchObject({ method: 'DELETE', path: '/api/v1/dinners/d1' })
|
||||
})
|
||||
|
||||
it('puts a removed Regular back in its place when the server refuses', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(errorResponse(500, 'internal'))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
|
||||
expect(regularNames()).toEqual(['Beef tacos', 'Pizza night'])
|
||||
})
|
||||
|
||||
it('puts a refused removal back and says why while another removal is still unanswered', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
dinnerBody('d3', 'Chili'),
|
||||
])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
expect(regularNames()).toEqual(['Chili'])
|
||||
|
||||
server.held[0]!.answer(errorResponse(500, 'internal'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
|
||||
expect(regularNames()).toEqual(['Beef tacos', 'Chili'])
|
||||
|
||||
// The other removal's answer does not hide the refusal.
|
||||
server.regulars.splice(1, 1)
|
||||
server.held[1]!.answer(new Response(null, { status: 204 }))
|
||||
await vi.waitFor(() => expect(server.fetchMock.mock.calls.filter(([path]) => String(path).includes('status=regular'))).toHaveLength(2))
|
||||
await vi.waitFor(() => expect(regularNames()).toEqual(['Beef tacos', 'Chili']))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
|
||||
})
|
||||
|
||||
it('puts two refused removals back in their places without rereading the list', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
dinnerBody('d3', 'Chili'),
|
||||
])
|
||||
server.reads.fail = true
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
expect(regularNames()).toEqual(['Chili'])
|
||||
|
||||
server.held[0]!.answer(errorResponse(500, 'internal'))
|
||||
await screen.findByRole('alert')
|
||||
server.held[1]!.answer(errorResponse(500, 'internal'))
|
||||
await screen.findByText('Could not remove Pizza night: internal message')
|
||||
// The reread after the last answer fails, so the order is the client's own.
|
||||
expect(await screen.findByText(/Your Regulars could not load/)).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: /^Remove / }).map((button) => button.getAttribute('aria-label'))).toEqual([
|
||||
'Remove Beef tacos',
|
||||
'Remove Pizza night',
|
||||
'Remove Chili',
|
||||
])
|
||||
})
|
||||
|
||||
it('enables "Next: notifications" once the server has saved 3 Regulars', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
])
|
||||
expect(next()).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
|
||||
await screen.findByRole('heading', { level: 2, name: 'Your regulars · 3' })
|
||||
// The third chip shows at once, but Next waits for the server.
|
||||
expect(next()).toBeDisabled()
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
created(server, server.held[0]!, 'd3')
|
||||
await screen.findByRole('button', { name: 'Remove Burgers' })
|
||||
expect(next()).toBeEnabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' }))
|
||||
await vi.waitFor(() => expect(next()).toBeDisabled())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
server.held[1]!.answer(errorResponse(500, 'internal'))
|
||||
await vi.waitFor(() => expect(next()).toBeEnabled())
|
||||
|
||||
fireEvent.click(next())
|
||||
expect(await screen.findByText('Step 3 of 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps "Next: notifications" disabled when the third add is refused', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
|
||||
await screen.findByRole('heading', { level: 2, name: 'Your regulars · 3' })
|
||||
expect(next()).toBeDisabled()
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
|
||||
expect(regularNames()).toEqual(['Beef tacos', 'Pizza night'])
|
||||
expect(next()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('keeps "Next: notifications" disabled while a fourth add is unanswered', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
dinnerBody('d3', 'Chili'),
|
||||
])
|
||||
expect(next()).toBeEnabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
|
||||
await screen.findByRole('heading', { level: 2, name: 'Your regulars · 4' })
|
||||
expect(next()).toBeDisabled()
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
|
||||
await screen.findByRole('alert')
|
||||
expect(next()).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Repeat, ScrollText, Sparkles, type LucideIcon } from 'lucide-react'
|
||||
import { Route, Routes, useNavigate } from 'react-router'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { NotificationsStep } from './NotificationsStep.tsx'
|
||||
import { OnboardingStep } from './OnboardingStep.tsx'
|
||||
import { RegularsStep } from './RegularsStep.tsx'
|
||||
import styles from './Onboarding.module.css'
|
||||
|
||||
// OnboardingPage is /onboarding and its steps: the welcome, the Regulars
|
||||
// (/onboarding/regulars) and notifications (/onboarding/notifications).
|
||||
export function OnboardingPage() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<WelcomeStep />} />
|
||||
<Route path="regulars" element={<RegularsStep />} />
|
||||
<Route path="notifications" element={<NotificationsStep />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
const terms: { icon: LucideIcon; name: string; meaning: string }[] = [
|
||||
{
|
||||
icon: Repeat,
|
||||
name: 'Regulars',
|
||||
meaning: 'The dinners our kitchen makes on repeat. You add a few next.',
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
name: 'Side quests',
|
||||
meaning: 'New dinners MealQuest suggests from your Regulars. Try it or Pass.',
|
||||
},
|
||||
{
|
||||
icon: ScrollText,
|
||||
name: 'Quest log',
|
||||
meaning: 'The Side quests you said Try it to, waiting for a night on the plan.',
|
||||
},
|
||||
]
|
||||
|
||||
function WelcomeStep() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<OnboardingStep
|
||||
step={1}
|
||||
title="Welcome to MealQuest"
|
||||
action={
|
||||
<Button className={styles.action} onClick={() => void navigate('/onboarding/regulars')}>
|
||||
Let's start
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className={styles.helper}>MealQuest plans the household's dinners around what you already love to cook.</p>
|
||||
<ul className={styles.terms}>
|
||||
{terms.map(({ icon, name, meaning }) => (
|
||||
<li key={name} className={styles.term}>
|
||||
<span className={styles.termIcon}>
|
||||
<Icon icon={icon} size="nav" />
|
||||
</span>
|
||||
<p className={styles.termText}>
|
||||
<span className={styles.termName}>{name}</span>
|
||||
{meaning}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</OnboardingStep>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import styles from './Onboarding.module.css'
|
||||
|
||||
export const ONBOARDING_STEPS = 3
|
||||
|
||||
export type OnboardingStepProps = {
|
||||
// 1-based; the progress pills fill up to it.
|
||||
step: number
|
||||
title: string
|
||||
children: ReactNode
|
||||
// The step's primary action, fixed to the bottom of the screen.
|
||||
action?: ReactNode
|
||||
}
|
||||
|
||||
// OnboardingStep is the frame every onboarding step shares: progress pills,
|
||||
// the "Step N of 3" kicker and the Bagel title.
|
||||
export function OnboardingStep({ step, title, children, action }: OnboardingStepProps) {
|
||||
return (
|
||||
<main className={styles.screen}>
|
||||
<div className={styles.column}>
|
||||
<div className={styles.progress} aria-hidden="true">
|
||||
{Array.from({ length: ONBOARDING_STEPS }, (_, i) => (
|
||||
<span key={i} className={[styles.pill, i < step && styles.filled].filter(Boolean).join(' ')} />
|
||||
))}
|
||||
</div>
|
||||
<p className={styles.kicker}>
|
||||
Step {step} of {ONBOARDING_STEPS}
|
||||
</p>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
{children}
|
||||
</div>
|
||||
{action && <div className={styles.footer}>{action}</div>}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user