Merge pull request 'Promote dev to main: Feature 12 (regular dinners and onboarding)' (#98) from release/2026-10-07 into main
CI / ci (push) Successful in 2m49s
CI / ci (push) Successful in 2m49s
Reviewed-on: #98 Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
This commit was merged in pull request #98.
This commit is contained in:
+42
-5
@@ -14,8 +14,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
"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/auth"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
"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/forwarded"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
"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/household"
|
||||||
@@ -75,7 +78,10 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -88,6 +94,18 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|
||||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -105,7 +123,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, runner, photos, frontend.Assets())
|
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, runner, photos, frontend.Assets())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -121,6 +139,12 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
if err := runner.Start(jobsCtx); err != nil {
|
if err := runner.Start(jobsCtx); err != nil {
|
||||||
return err
|
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{
|
srv := &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
Handler: router,
|
Handler: router,
|
||||||
@@ -202,7 +226,7 @@ func seedDefaults(ctx context.Context, db *store.Store, defaultTZ string) error
|
|||||||
return household.SeedNotificationPrefs(ctx, db)
|
return household.SeedNotificationPrefs(ctx, db)
|
||||||
}
|
}
|
||||||
|
|
||||||
// jobKinds enqueues every job kind the server runs.
|
// jobKinds enqueues the framework's job kinds.
|
||||||
type jobKinds struct {
|
type jobKinds struct {
|
||||||
builtins *jobs.Builtins
|
builtins *jobs.Builtins
|
||||||
}
|
}
|
||||||
@@ -221,8 +245,9 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
|
|||||||
return runner, kinds, nil
|
return runner, kinds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerJobKinds registers every job kind's handler on runner. Each
|
// registerJobKinds registers the framework's job kinds on runner. A feature
|
||||||
// feature's job kinds are registered here.
|
// service registers its own kinds when it is built, before the runner
|
||||||
|
// starts (dinners.NewService registers dinners.KindGroundDinner).
|
||||||
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
||||||
builtins, err := jobs.RegisterBuiltins(runner)
|
builtins, err := jobs.RegisterBuiltins(runner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -231,6 +256,18 @@ func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
|||||||
return &jobKinds{builtins: builtins}, nil
|
return &jobKinds{builtins: builtins}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizer runs normalize_recipe with the AI settings current at each
|
||||||
|
// run, sharing guard.
|
||||||
|
func normalizer(ai *aisettings.Service, guard *harness.Guard) recipes.Normalizer {
|
||||||
|
return func(ctx context.Context, scope string, in skills.RecipeText) (skills.NormalizedRecipe, error) {
|
||||||
|
cfg, err := ai.HarnessConfig(ctx, guard)
|
||||||
|
if err != nil {
|
||||||
|
return skills.NormalizedRecipe{}, err
|
||||||
|
}
|
||||||
|
return skills.NormalizeRecipe(ctx, cfg, scope, in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// logListening logs the startup line. Only the public URL's origin is logged
|
// logListening logs the startup line. Only the public URL's origin is logged
|
||||||
// so no credential-bearing URL component can reach the log.
|
// so no credential-bearing URL component can reach the log.
|
||||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||||
|
|||||||
+140
-2
@@ -96,7 +96,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
|
|||||||
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
|
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
|
||||||
| `recipe_steps` | recipe_id, position, text. |
|
| `recipe_steps` | recipe_id, position, text. |
|
||||||
| `photos` | source_url, local_path, width, height, sha256, fetched_at. |
|
| `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 whenever a dinner is renamed, so only the latest `ground_dinner` job may write it and no candidate offered before the rename grounds it), updated_by. |
|
||||||
| `suggestion_batches` | trigger, status, ai_run_id. |
|
| `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, 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. |
|
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. |
|
||||||
@@ -178,6 +178,99 @@ automatic** (D-021).
|
|||||||
restores `household.DefaultPersonaPrompt` (CTXD PROMPTS.MD §4). No
|
restores `household.DefaultPersonaPrompt` (CTXD PROMPTS.MD §4). No
|
||||||
response holds the key. `Service.HarnessConfig` reads the settings for
|
response holds the key. `Service.HarnessConfig` reads the settings for
|
||||||
each skill run, so a saved change applies to the next run.
|
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,
|
||||||
|
clearing the pasted `recipe_url` (the recipe replaces it), 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 dinner raises the revision, and
|
||||||
|
renaming a `pending` one enqueues a job for the new name, clearing its
|
||||||
|
`recipe_url` when the normalized name changes.
|
||||||
|
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. `candidate_id` is
|
||||||
|
an opaque id the server resolves to the page it last offered that dinner
|
||||||
|
(kept in memory, so a restart means fetching the candidates again) at
|
||||||
|
the `grounding_revision` it was searched at, so a candidate offered
|
||||||
|
before any rename is refused even if the name is changed back; the
|
||||||
|
page's URL, which may carry a token, is never returned or cached. An
|
||||||
|
unknown dinner is 404, recipe search not set up 409 `not_configured` and
|
||||||
|
a search failure 502 `search_failed`; any other failure, such as a
|
||||||
|
stored recipe that cannot be read, is 500, logged with a fixed reason
|
||||||
|
and the dinner id only — never a candidate's URL or dependency error
|
||||||
|
text.
|
||||||
|
`POST /api/v1/dinners/{id}/ground` takes exactly one of `{candidate_id}`,
|
||||||
|
`{recipe_url}` (both grounded with `GroundURL`, setting `grounded`) or `{manual: {ingredients[], steps[], minutes,
|
||||||
|
method}}` (1–100 ingredient lines, up to 100 steps, each non-blank and at
|
||||||
|
most 500 characters; minutes 1–1440). A manual recipe is stored as typed
|
||||||
|
— no model runs — as a `recipes` row whose source_url is
|
||||||
|
`mealquest:manual:{recipe_id}` and whose raw_jsonld is a schema.org
|
||||||
|
Recipe built from it, and sets `manual` with its method and minutes, no
|
||||||
|
photo and the dinner's difficulty unchanged. Either clears the dinner's
|
||||||
|
pasted `recipe_url`: the recipe chosen replaces it. It answers 200 with
|
||||||
|
the dinner; an invalid body is 400, an unknown dinner 404, a page that cannot
|
||||||
|
ground it 422 `ungroundable` (the dinner is unchanged), recipe search not
|
||||||
|
set up 409 `not_configured`, a search or model failure 502
|
||||||
|
`search_failed` / `ai_failed` (only the reason code is logged, never the
|
||||||
|
provider's text), and grounding a candidate or link that runs past 90
|
||||||
|
seconds (fetch, photo and `normalize_recipe`, all in the request) 504
|
||||||
|
`ground_timeout`, leaving the dinner unchanged. An archived dinner is 409
|
||||||
|
`dinner_archived`. A candidate or link is written only if the dinner is
|
||||||
|
still as it was before grounding began: one renamed or grounded some
|
||||||
|
other way meanwhile is 409 `ground_conflict`, and one archived meanwhile
|
||||||
|
409 `dinner_archived`, either leaving the dinner unchanged. A method,
|
||||||
|
minutes or difficulty a member edits meanwhile is kept over the recipe's.
|
||||||
|
Any other failure grounding a candidate or link, or recording it on the
|
||||||
|
dinner, is 500, logged with a fixed reason and the dinner id only —
|
||||||
|
never the link, which can carry a personal token, or dependency error
|
||||||
|
text.
|
||||||
- `/health` — liveness.
|
- `/health` — liveness.
|
||||||
- Everything else serves the embedded PWA with SPA fallback.
|
- Everything else serves the embedded PWA with SPA fallback.
|
||||||
|
|
||||||
@@ -346,7 +439,10 @@ automatic** (D-021).
|
|||||||
Rank's per-candidate reasons, photo or normalize failures). Failures
|
Rank's per-candidate reasons, photo or normalize failures). Failures
|
||||||
that are not the candidates' — search, model provider, database — are
|
that are not the candidates' — search, model provider, database — are
|
||||||
returned as they are. `GroundURL(ctx, url)` does the same for one pasted
|
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
|
item, so one failure never affects its siblings. A recipe is usable only
|
||||||
with ingredients, an image and, if given, a positive total time.
|
with ingredients, an image and, if given, a positive total time.
|
||||||
|
|
||||||
@@ -422,6 +518,48 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
|||||||
`/settings/household`, `/settings/regulars` and `/settings/calendar` come from
|
`/settings/household`, `/settings/regulars` and `/settings/calendar` come from
|
||||||
CTXD; the other routes are defined in the UI handoff.
|
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,
|
||||||
|
`web/src/dinners/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"). "Maybe
|
||||||
|
later", and "Turn on notifications" once notifications are enabled, send
|
||||||
|
`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 and says plainly
|
||||||
|
that notifications are not on yet, offering "Finish setting up" (which
|
||||||
|
completes onboarding as above) instead of a silent success. 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
|
## Boundaries and gotchas
|
||||||
|
|
||||||
- **Secrets never reach the client** — API keys, OAuth tokens and the Chat
|
- **Secrets never reach the client** — API keys, OAuth tokens and the Chat
|
||||||
|
|||||||
@@ -1,10 +1,70 @@
|
|||||||
package harness
|
package harness
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestSharedDedupeCases runs web/src/dinners/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("../../../web/src/dinners/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) {
|
func TestNormalize(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name, want string
|
name, want string
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
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
|
`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.
|
not combining marks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
|
||||||
|
PWA_TABLE = "../../../web/src/dinners/letterFolds.json"
|
||||||
|
|
||||||
EXTRA = {
|
EXTRA = {
|
||||||
"ß": "ss", "ẞ": "ss", "æ": "ae", "Æ": "ae", "œ": "oe", "Œ": "oe",
|
"ß": "ss", "ẞ": "ss", "æ": "ae", "Æ": "ae", "œ": "oe", "Œ": "oe",
|
||||||
"þ": "th", "Þ": "th", "ð": "d", "Ð": "d", "ı": "i", "ʼn": "n",
|
"þ": "th", "Þ": "th", "ð": "d", "Ð": "d", "ı": "i", "ʼn": "n",
|
||||||
@@ -110,6 +116,9 @@ def main():
|
|||||||
with open("folds_table.go", "w", encoding="utf-8") as f:
|
with open("folds_table.go", "w", encoding="utf-8") as f:
|
||||||
f.write("\n".join(out) + "\n")
|
f.write("\n".join(out) + "\n")
|
||||||
subprocess.run(["gofmt", "-w", "folds_table.go"], check=True)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+32
-1
@@ -106,13 +106,22 @@ func TestDinners(t *testing.T) {
|
|||||||
Cuisine: str("Mexican"),
|
Cuisine: str("Mexican"),
|
||||||
IsFavorite: 0,
|
IsFavorite: 0,
|
||||||
RecipeID: str(recipe.ID),
|
RecipeID: str(recipe.ID),
|
||||||
|
RecipeUrl: str("https://example.com/tacos"),
|
||||||
PhotoID: str(photo.ID),
|
PhotoID: str(photo.ID),
|
||||||
GroundingStatus: "grounded",
|
GroundingStatus: "grounded",
|
||||||
UpdatedBy: str("alex@example.com"),
|
UpdatedBy: str("alex@example.com"),
|
||||||
CreatedAt: created,
|
CreatedAt: created,
|
||||||
UpdatedAt: 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)
|
got, err := q.CreateDinner(ctx, create)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateDinner: %v", err)
|
t.Fatalf("CreateDinner: %v", err)
|
||||||
@@ -181,6 +190,7 @@ func TestDinners(t *testing.T) {
|
|||||||
Difficulty: str("medium"),
|
Difficulty: str("medium"),
|
||||||
Cuisine: sql.NullString{},
|
Cuisine: sql.NullString{},
|
||||||
RecipeID: sql.NullString{},
|
RecipeID: sql.NullString{},
|
||||||
|
RecipeUrl: str("https://example.com/tacos"),
|
||||||
PhotoID: str(photo.ID),
|
PhotoID: str(photo.ID),
|
||||||
GroundingStatus: "failed",
|
GroundingStatus: "failed",
|
||||||
UpdatedBy: str("sam@example.com"),
|
UpdatedBy: str("sam@example.com"),
|
||||||
@@ -197,6 +207,7 @@ func TestDinners(t *testing.T) {
|
|||||||
Cuisine: update.Cuisine, IsFavorite: 0, RecipeID: update.RecipeID,
|
Cuisine: update.Cuisine, IsFavorite: 0, RecipeID: update.RecipeID,
|
||||||
PhotoID: update.PhotoID, GroundingStatus: update.GroundingStatus,
|
PhotoID: update.PhotoID, GroundingStatus: update.GroundingStatus,
|
||||||
UpdatedBy: update.UpdatedBy, CreatedAt: created, UpdatedAt: updated,
|
UpdatedBy: update.UpdatedBy, CreatedAt: created, UpdatedAt: updated,
|
||||||
|
RecipeUrl: create.RecipeUrl, // UpdateDinner leaves the recipe link
|
||||||
}
|
}
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("UpdateDinner returned %+v, want %+v", 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})
|
_, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ID: "missing", IsFavorite: 1, UpdatedAt: updated})
|
||||||
mustNoRows(t, "SetDinnerFavorite missing", err)
|
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"
|
const archivedAt = "2026-10-05T12:00:00.000Z"
|
||||||
got, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{
|
got, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{
|
||||||
ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: archivedAt,
|
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, "archived", listIDs("archived"), create.ID)
|
||||||
assertIDs(t, "tried after archive", listIDs("tried"))
|
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})
|
_, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt})
|
||||||
mustNoRows(t, "ArchiveDinner missing", err)
|
mustNoRows(t, "ArchiveDinner missing", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+369
-9
@@ -16,7 +16,7 @@ SET status = 'archived',
|
|||||||
updated_by = ?,
|
updated_by = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
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 {
|
type ArchiveDinnerParams struct {
|
||||||
@@ -46,21 +46,37 @@ func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (D
|
|||||||
&i.UpdatedBy,
|
&i.UpdatedBy,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.RecipeUrl,
|
||||||
|
&i.GroundingError,
|
||||||
|
&i.GroundingRevision,
|
||||||
)
|
)
|
||||||
return i, err
|
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
|
const createDinner = `-- name: CreateDinner :one
|
||||||
INSERT INTO dinners (
|
INSERT INTO dinners (
|
||||||
id, name, normalized_name, status, origin, method, minutes_total,
|
id, name, normalized_name, status, origin, method, minutes_total,
|
||||||
difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status,
|
difficulty, cuisine, is_favorite, recipe_id, recipe_url, photo_id,
|
||||||
updated_by, created_at, updated_at
|
grounding_status, updated_by, created_at, updated_at
|
||||||
) VALUES (
|
) 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 {
|
type CreateDinnerParams struct {
|
||||||
@@ -75,6 +91,7 @@ type CreateDinnerParams struct {
|
|||||||
Cuisine sql.NullString
|
Cuisine sql.NullString
|
||||||
IsFavorite int64
|
IsFavorite int64
|
||||||
RecipeID sql.NullString
|
RecipeID sql.NullString
|
||||||
|
RecipeUrl sql.NullString
|
||||||
PhotoID sql.NullString
|
PhotoID sql.NullString
|
||||||
GroundingStatus string
|
GroundingStatus string
|
||||||
UpdatedBy sql.NullString
|
UpdatedBy sql.NullString
|
||||||
@@ -95,6 +112,7 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din
|
|||||||
arg.Cuisine,
|
arg.Cuisine,
|
||||||
arg.IsFavorite,
|
arg.IsFavorite,
|
||||||
arg.RecipeID,
|
arg.RecipeID,
|
||||||
|
arg.RecipeUrl,
|
||||||
arg.PhotoID,
|
arg.PhotoID,
|
||||||
arg.GroundingStatus,
|
arg.GroundingStatus,
|
||||||
arg.UpdatedBy,
|
arg.UpdatedBy,
|
||||||
@@ -119,12 +137,85 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din
|
|||||||
&i.UpdatedBy,
|
&i.UpdatedBy,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.RecipeUrl,
|
||||||
|
&i.GroundingError,
|
||||||
|
&i.GroundingRevision,
|
||||||
)
|
)
|
||||||
return i, err
|
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
|
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 = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -148,12 +239,164 @@ func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) {
|
|||||||
&i.UpdatedBy,
|
&i.UpdatedBy,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.RecipeUrl,
|
||||||
|
&i.GroundingError,
|
||||||
|
&i.GroundingRevision,
|
||||||
)
|
)
|
||||||
return i, err
|
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,
|
||||||
|
recipe_url = NULL,
|
||||||
|
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. Like
|
||||||
|
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
||||||
|
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
|
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 = ?
|
WHERE status = ?
|
||||||
ORDER BY normalized_name, id
|
ORDER BY normalized_name, id
|
||||||
`
|
`
|
||||||
@@ -184,6 +427,68 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
|
|||||||
&i.UpdatedBy,
|
&i.UpdatedBy,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&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 {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -204,7 +509,7 @@ SET is_favorite = ?,
|
|||||||
updated_by = ?,
|
updated_by = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
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 {
|
type SetDinnerFavoriteParams struct {
|
||||||
@@ -240,6 +545,52 @@ func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoritePa
|
|||||||
&i.UpdatedBy,
|
&i.UpdatedBy,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&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
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -255,12 +606,14 @@ SET name = ?,
|
|||||||
difficulty = ?,
|
difficulty = ?,
|
||||||
cuisine = ?,
|
cuisine = ?,
|
||||||
recipe_id = ?,
|
recipe_id = ?,
|
||||||
|
recipe_url = ?,
|
||||||
photo_id = ?,
|
photo_id = ?,
|
||||||
grounding_status = ?,
|
grounding_status = ?,
|
||||||
|
grounding_revision = ?,
|
||||||
updated_by = ?,
|
updated_by = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
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 {
|
type UpdateDinnerParams struct {
|
||||||
@@ -273,8 +626,10 @@ type UpdateDinnerParams struct {
|
|||||||
Difficulty sql.NullString
|
Difficulty sql.NullString
|
||||||
Cuisine sql.NullString
|
Cuisine sql.NullString
|
||||||
RecipeID sql.NullString
|
RecipeID sql.NullString
|
||||||
|
RecipeUrl sql.NullString
|
||||||
PhotoID sql.NullString
|
PhotoID sql.NullString
|
||||||
GroundingStatus string
|
GroundingStatus string
|
||||||
|
GroundingRevision int64
|
||||||
UpdatedBy sql.NullString
|
UpdatedBy sql.NullString
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
ID string
|
ID string
|
||||||
@@ -292,8 +647,10 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
|
|||||||
arg.Difficulty,
|
arg.Difficulty,
|
||||||
arg.Cuisine,
|
arg.Cuisine,
|
||||||
arg.RecipeID,
|
arg.RecipeID,
|
||||||
|
arg.RecipeUrl,
|
||||||
arg.PhotoID,
|
arg.PhotoID,
|
||||||
arg.GroundingStatus,
|
arg.GroundingStatus,
|
||||||
|
arg.GroundingRevision,
|
||||||
arg.UpdatedBy,
|
arg.UpdatedBy,
|
||||||
arg.UpdatedAt,
|
arg.UpdatedAt,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
@@ -316,6 +673,9 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
|
|||||||
&i.UpdatedBy,
|
&i.UpdatedBy,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.RecipeUrl,
|
||||||
|
&i.GroundingError,
|
||||||
|
&i.GroundingRevision,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ type Dinner struct {
|
|||||||
UpdatedBy sql.NullString
|
UpdatedBy sql.NullString
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
|
RecipeUrl sql.NullString
|
||||||
|
GroundingError sql.NullString
|
||||||
|
GroundingRevision int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type HouseholdSetting struct {
|
type HouseholdSetting struct {
|
||||||
|
|||||||
@@ -21,9 +21,14 @@ type Querier interface {
|
|||||||
// Drops references to the runs created before the cutoff, so the purge
|
// Drops references to the runs created before the cutoff, so the purge
|
||||||
// can delete them.
|
// can delete them.
|
||||||
ClearWeekPlanAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
ClearWeekPlanAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
||||||
|
// How many Regulars the household has; archived dinners are not counted.
|
||||||
|
CountRegularDinners(ctx context.Context) (int64, error)
|
||||||
CreateAIRun(ctx context.Context, arg CreateAIRunParams) error
|
CreateAIRun(ctx context.Context, arg CreateAIRunParams) error
|
||||||
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
|
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
|
||||||
CreateJob(ctx context.Context, arg CreateJobParams) 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)
|
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
|
||||||
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
||||||
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
||||||
@@ -31,6 +36,17 @@ type Querier interface {
|
|||||||
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||||
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||||
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
|
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
|
||||||
|
// Fails every unarchived dinner still pending whose grounding job for its
|
||||||
|
// current grounding_revision is no longer queued or running: the process
|
||||||
|
// stopped before the job, or before its handler recorded a result. An
|
||||||
|
// archived dinner is left as it is; archiving stopped its grounding. Run at
|
||||||
|
// boot, after the job runner fails the jobs a previous process left running.
|
||||||
|
FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error)
|
||||||
|
// Records why the background grounding of an unarchived dinner still
|
||||||
|
// pending at the grounding_revision it was grounded for failed; a dinner
|
||||||
|
// grounded some other way, renamed or archived meanwhile is left as it is
|
||||||
|
// (0 rows).
|
||||||
|
FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error)
|
||||||
// Fails every job left running, at boot before any worker starts.
|
// Fails every job left running, at boot before any worker starts.
|
||||||
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
|
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
|
||||||
// Returns sql.ErrNoRows until the row exists.
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
@@ -51,6 +67,22 @@ type Querier interface {
|
|||||||
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
|
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
|
||||||
// Returns sql.ErrNoRows until the row exists.
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
|
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
|
||||||
|
// Records a dinner's grounding by hand (grounded or manual) and clears any
|
||||||
|
// grounding_error and pasted recipe_url: the recipe chosen replaces the
|
||||||
|
// link. It writes only an unarchived dinner still as it was observed
|
||||||
|
// before grounding (same name, normalized_name, grounding_status,
|
||||||
|
// grounding_revision and recipe_id), so a dinner renamed, archived or
|
||||||
|
// grounded some other way meanwhile is left as it is. That dinner, or an
|
||||||
|
// unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
|
||||||
|
// no longer as observed is kept: a member edited it while it was grounding.
|
||||||
|
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
|
||||||
|
// Records the background grounding of an unarchived dinner still pending at
|
||||||
|
// the grounding_revision it was grounded for; a dinner grounded some other
|
||||||
|
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
|
||||||
|
// minutes_total or difficulty already set is kept: a pending dinner has
|
||||||
|
// them only when a member edited them while it was grounding. Like
|
||||||
|
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
||||||
|
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
|
||||||
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
||||||
// The planned dinners dated from from_date up to but not including
|
// The planned dinners dated from from_date up to but not including
|
||||||
// to_date, oldest first. Days with no dinner are left out.
|
// to_date, oldest first. Days with no dinner are left out.
|
||||||
@@ -60,12 +92,17 @@ type Querier interface {
|
|||||||
ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error)
|
ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error)
|
||||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||||
|
// Every dinner a new or renamed dinner must not repeat, with the
|
||||||
|
// source_url of the recipe it is grounded in (NULL while it has none).
|
||||||
|
ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error)
|
||||||
// Stores a partial result of a running job.
|
// Stores a partial result of a running job.
|
||||||
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
||||||
// An unknown id returns sql.ErrNoRows.
|
// An unknown id returns sql.ErrNoRows.
|
||||||
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
||||||
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
|
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
|
||||||
// An unknown id returns sql.ErrNoRows.
|
// 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)
|
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
|
||||||
// Creates the single row (id 1) or replaces every column except id and
|
// Creates the single row (id 1) or replaces every column except id and
|
||||||
// created_at.
|
// created_at.
|
||||||
|
|||||||
@@ -10,6 +10,67 @@ import (
|
|||||||
"database/sql"
|
"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
|
const createRecipeIngredient = `-- name: CreateRecipeIngredient :exec
|
||||||
INSERT INTO recipe_ingredients (
|
INSERT INTO recipe_ingredients (
|
||||||
id, recipe_id, position, raw_text, name, qty, unit, note,
|
id, recipe_id, position, raw_text, name, qty, unit, note,
|
||||||
|
|||||||
@@ -0,0 +1,577 @@
|
|||||||
|
// 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"
|
||||||
|
"crypto/rand"
|
||||||
|
"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{}
|
||||||
|
|
||||||
|
// offerKey keys the candidate ids Candidates offers, so they are
|
||||||
|
// opaque and stable for this process.
|
||||||
|
offerKey []byte
|
||||||
|
// offersMu guards offers and offerSearches. offers holds, by dinner
|
||||||
|
// id, the candidates last offered by Candidates until they expire, so
|
||||||
|
// a candidate id is resolved here and its URL never leaves the server.
|
||||||
|
// offerSearches holds the Candidates searches running, by dinner id,
|
||||||
|
// so clearing a dinner's offers keeps them from storing stale ones.
|
||||||
|
offersMu sync.Mutex
|
||||||
|
offers map[string]*offerSet
|
||||||
|
offerSearches map[string]map[*offerSearch]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{}), offerKey: make([]byte, 32), offers: make(map[string]*offerSet), offerSearches: make(map[string]map[*offerSearch]struct{})}
|
||||||
|
rand.Read(s.offerKey) // never fails: it crashes the program instead
|
||||||
|
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 raises its
|
||||||
|
// grounding_revision and drops the candidates offered for its old name:
|
||||||
|
// each was offered at an earlier revision, so none of them grounds it.
|
||||||
|
// Renaming a dinner still pending also enqueues a fresh KindGroundDinner
|
||||||
|
// job that grounds the new name, and clears its recipe_url when the
|
||||||
|
// normalized name changes: 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 renamed, 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 name != cur.Name {
|
||||||
|
params.GroundingRevision++
|
||||||
|
}
|
||||||
|
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{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
renamed = row.GroundingRevision != cur.GroundingRevision
|
||||||
|
regrounding = renamed && cur.GroundingStatus == GroundingPending
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Dinner{}, err
|
||||||
|
}
|
||||||
|
if renamed {
|
||||||
|
updateCommitted()
|
||||||
|
s.clearOffers(row.ID, row.GroundingRevision)
|
||||||
|
}
|
||||||
|
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, and its candidates no
|
||||||
|
// longer ground it.
|
||||||
|
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)
|
||||||
|
s.clearOffers(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,76 @@
|
|||||||
|
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#lookup"
|
||||||
|
// Each leak holds a character no ULID has (Crockford base32 has no I, L,
|
||||||
|
// O, U, "-", "." or "/"), so a dinner's id never matches one.
|
||||||
|
leaks := []string{link, "c.example", "stew-key", "abcdefghijklmno", "sig", "zyxwvut", "lookup"}
|
||||||
|
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,73 @@
|
|||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOfferLifetime sets how long candidates ground a dinner until t ends.
|
||||||
|
// A test calling it must not run in parallel.
|
||||||
|
func SetOfferLifetime(t *testing.T, d time.Duration) {
|
||||||
|
old := offerLifetime
|
||||||
|
offerLifetime = d
|
||||||
|
t.Cleanup(func() { offerLifetime = old })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUpdateCommitted runs f in Update once a rename is committed, before
|
||||||
|
// the dinner's offers are cleared, until t ends. A test calling it must
|
||||||
|
// not run in parallel.
|
||||||
|
func SetUpdateCommitted(t *testing.T, f func()) {
|
||||||
|
old := updateCommitted
|
||||||
|
updateCommitted = f
|
||||||
|
t.Cleanup(func() { updateCommitted = old })
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdvanceClock moves the service's clock d ahead of real time until t
|
||||||
|
// ends, without firing any timer. A test calling it must not run in
|
||||||
|
// parallel.
|
||||||
|
func AdvanceClock(t *testing.T, d time.Duration) {
|
||||||
|
old := now
|
||||||
|
now = func() time.Time { return old().Add(d) }
|
||||||
|
t.Cleanup(func() { now = old })
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasOffers reports whether the service holds candidates offered for
|
||||||
|
// dinner id.
|
||||||
|
func (s *Service) HasOffers(id string) bool {
|
||||||
|
s.offersMu.Lock()
|
||||||
|
defer s.offersMu.Unlock()
|
||||||
|
_, ok := s.offers[id]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreOffers stores urls as dinner id's offers, as a Candidates search
|
||||||
|
// that read the dinner at grounding_revision revision would on finishing.
|
||||||
|
func (s *Service) StoreOffers(id string, urls map[string]string, revision int64) {
|
||||||
|
keep, done := s.trackOffers(id)
|
||||||
|
defer done()
|
||||||
|
keep(urls, revision)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfferCount is the number of dinners whose candidates the service holds.
|
||||||
|
func (s *Service) OfferCount() int {
|
||||||
|
s.offersMu.Lock()
|
||||||
|
defer s.offersMu.Unlock()
|
||||||
|
return len(s.offers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,804 @@
|
|||||||
|
package dinners
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"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")
|
||||||
|
|
||||||
|
// ErrCandidatesFailed wraps any unexpected failure finding a dinner's
|
||||||
|
// candidates. Its text may quote a candidate's URL and dependency errors,
|
||||||
|
// so it is never logged.
|
||||||
|
var ErrCandidatesFailed = errors.New("dinners: finding recipe candidates 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 an opaque id for the page, resolved by the server:
|
||||||
|
// Fix.CandidateID grounds it. The page's URL is never returned, as it
|
||||||
|
// may carry a token.
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// offerLifetime is how long the candidates Candidates offers a dinner
|
||||||
|
// ground it; tests shorten it.
|
||||||
|
var offerLifetime = 30 * time.Minute
|
||||||
|
|
||||||
|
// now is the clock offer lifetimes are measured on; tests advance it.
|
||||||
|
var now = time.Now
|
||||||
|
|
||||||
|
// updateCommitted runs in Update once a rename is committed, before the
|
||||||
|
// dinner's offers are cleared; tests use it to act in between.
|
||||||
|
var updateCommitted = func() {}
|
||||||
|
|
||||||
|
// offerSet is the candidates last offered for a dinner: the page URL of
|
||||||
|
// each candidate id, found by searching the dinner's name at its
|
||||||
|
// grounding_revision revision. They ground the dinner until deadline, and
|
||||||
|
// only while it is still at that revision, which every rename raises;
|
||||||
|
// expiry drops the set from offers once deadline has passed.
|
||||||
|
type offerSet struct {
|
||||||
|
urls map[string]string
|
||||||
|
revision int64
|
||||||
|
deadline time.Time
|
||||||
|
expiry *time.Timer
|
||||||
|
}
|
||||||
|
|
||||||
|
// offerSearch is a Candidates search running for a dinner. It stores no
|
||||||
|
// offers found at a grounding_revision below below, which clearing the
|
||||||
|
// dinner's offers raises.
|
||||||
|
type offerSearch struct {
|
||||||
|
below int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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. They
|
||||||
|
// replace the dinner's earlier candidates: one not offered again no longer
|
||||||
|
// grounds it. They ground it for offerLifetime, and not at all once it is
|
||||||
|
// grounded by hand, renamed or archived, even while this search runs. A
|
||||||
|
// missing dinner is ErrNotFound; any other failure wraps
|
||||||
|
// ErrCandidatesFailed, and a search that fails also wraps
|
||||||
|
// recipes.ErrSearchFailed.
|
||||||
|
func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error) {
|
||||||
|
// Tracked before the dinner is read: grounding, renaming or archiving
|
||||||
|
// it after the read keeps this search from storing what it found.
|
||||||
|
keep, done := s.trackOffers(id)
|
||||||
|
defer done()
|
||||||
|
row, err := s.get(ctx, id)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: dinner %s: %w", ErrCandidatesFailed, id, err)
|
||||||
|
}
|
||||||
|
offers, err := s.grounder.Candidates(ctx, row.Name, row.Name, MaxCandidates)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: dinner %s: %w", ErrCandidatesFailed, id, err)
|
||||||
|
}
|
||||||
|
out := make([]Candidate, len(offers))
|
||||||
|
urls := make(map[string]string, len(offers))
|
||||||
|
for i, o := range offers {
|
||||||
|
cid := s.candidateID(row.ID, o.URL)
|
||||||
|
urls[cid] = o.URL
|
||||||
|
out[i] = Candidate{CandidateID: cid, Title: o.Title, SiteName: o.SiteName, Minutes: o.Minutes}
|
||||||
|
if o.PhotoID != "" {
|
||||||
|
u := PhotoURL(o.PhotoID)
|
||||||
|
out[i].PhotoURL = &u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row.Status != StatusArchived {
|
||||||
|
keep(urls, row.GroundingRevision)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackOffers registers a Candidates search for dinner id. It returns keep,
|
||||||
|
// which stores urls, found for the dinner at grounding_revision revision,
|
||||||
|
// as its offers for offerLifetime unless clearOffers has run for it since
|
||||||
|
// with a revision above that, or offers found at a later revision are
|
||||||
|
// stored, and done, to call when the search ends.
|
||||||
|
func (s *Service) trackOffers(id string) (keep func(urls map[string]string, revision int64), done func()) {
|
||||||
|
search := &offerSearch{}
|
||||||
|
s.offersMu.Lock()
|
||||||
|
if s.offerSearches[id] == nil {
|
||||||
|
s.offerSearches[id] = make(map[*offerSearch]struct{})
|
||||||
|
}
|
||||||
|
s.offerSearches[id][search] = struct{}{}
|
||||||
|
s.offersMu.Unlock()
|
||||||
|
keep = func(urls map[string]string, revision int64) {
|
||||||
|
s.offersMu.Lock()
|
||||||
|
defer s.offersMu.Unlock()
|
||||||
|
if revision < search.below {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if set := s.offers[id]; set != nil && set.revision > revision {
|
||||||
|
// Found at a later revision, so the earlier search's offers
|
||||||
|
// are already stale.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.dropOffers(id)
|
||||||
|
set := &offerSet{urls: urls, revision: revision, deadline: now().Add(offerLifetime)}
|
||||||
|
// expiry waits for offersMu, so it finds set stored even when it
|
||||||
|
// fires at once, and drops set only, never offers stored later.
|
||||||
|
set.expiry = time.AfterFunc(offerLifetime, func() {
|
||||||
|
s.offersMu.Lock()
|
||||||
|
defer s.offersMu.Unlock()
|
||||||
|
if s.offers[id] == set {
|
||||||
|
delete(s.offers, id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
s.offers[id] = set
|
||||||
|
}
|
||||||
|
done = func() {
|
||||||
|
s.offersMu.Lock()
|
||||||
|
delete(s.offerSearches[id], search)
|
||||||
|
if len(s.offerSearches[id]) == 0 {
|
||||||
|
delete(s.offerSearches, id)
|
||||||
|
}
|
||||||
|
s.offersMu.Unlock()
|
||||||
|
}
|
||||||
|
return keep, done
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearOffers drops dinner id's offers found at a grounding_revision below
|
||||||
|
// below and keeps every Candidates search running for it from storing
|
||||||
|
// such offers, so none of them grounds it. Offers found at below or above
|
||||||
|
// are kept. Call it with math.MaxInt64 once the dinner is grounded by hand
|
||||||
|
// or archived, and with its new revision once it is renamed: a search
|
||||||
|
// that read the rename meanwhile keeps its offers.
|
||||||
|
func (s *Service) clearOffers(id string, below int64) {
|
||||||
|
s.offersMu.Lock()
|
||||||
|
defer s.offersMu.Unlock()
|
||||||
|
if set := s.offers[id]; set != nil && set.revision < below {
|
||||||
|
s.dropOffers(id)
|
||||||
|
}
|
||||||
|
for search := range s.offerSearches[id] {
|
||||||
|
search.below = max(search.below, below)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropOffers drops dinner id's offers and stops their expiry. The caller
|
||||||
|
// holds offersMu.
|
||||||
|
func (s *Service) dropOffers(id string) {
|
||||||
|
if set := s.offers[id]; set != nil {
|
||||||
|
set.expiry.Stop()
|
||||||
|
delete(s.offers, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidateID is the opaque id of the page at rawURL as a candidate for
|
||||||
|
// dinner id: the same page is offered under the same id until the service
|
||||||
|
// restarts, and the id reveals nothing of the URL.
|
||||||
|
func (s *Service) candidateID(id, rawURL string) string {
|
||||||
|
mac := hmac.New(sha256.New, s.offerKey)
|
||||||
|
mac.Write([]byte(id + "\x00" + rawURL))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16])
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidateURL is the page URL of candidate cid last offered for dinner d.
|
||||||
|
// A cid not among the dinner's live offers, or whose page is not a valid
|
||||||
|
// recipe link, is a *ValidationError. Offers past their deadline, or found
|
||||||
|
// at a grounding_revision other than d's, are not live, even before they
|
||||||
|
// are dropped: d was renamed since they were found, if only to its name
|
||||||
|
// then.
|
||||||
|
func (s *Service) candidateURL(d db.Dinner, cid string) (string, error) {
|
||||||
|
var raw string
|
||||||
|
var ok bool
|
||||||
|
s.offersMu.Lock()
|
||||||
|
if set := s.offers[d.ID]; set != nil && set.revision == d.GroundingRevision && now().Before(set.deadline) {
|
||||||
|
raw, ok = set.urls[cid]
|
||||||
|
}
|
||||||
|
s.offersMu.Unlock()
|
||||||
|
if ok {
|
||||||
|
if u, err := checkRecipeURL(raw); err == nil {
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", invalid("candidate_id", "is not a candidate from recipe-candidates; reload the candidates")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
if f.CandidateID != nil {
|
||||||
|
if link, err = s.candidateURL(cur, *f.CandidateID); err != nil {
|
||||||
|
return Dinner{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groundCtx, cancel := context.WithTimeout(ctx, groundTimeout)
|
||||||
|
defer cancel()
|
||||||
|
gr, err := s.grounder.GroundURL(groundCtx, link)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() == nil && errors.Is(groundCtx.Err(), context.DeadlineExceeded) {
|
||||||
|
return Dinner{}, ErrGroundTimeout
|
||||||
|
}
|
||||||
|
return Dinner{}, fmt.Errorf("%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)
|
||||||
|
}
|
||||||
|
s.clearOffers(cur.ID, math.MaxInt64)
|
||||||
|
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; the
|
||||||
|
// dinner's candidates then no longer ground it. 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)
|
||||||
|
}
|
||||||
|
s.clearOffers(row.ID, math.MaxInt64)
|
||||||
|
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:
|
||||||
|
// Ground resolves the candidate to its page.
|
||||||
|
return "", 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
@@ -68,8 +68,18 @@ type SettingsPatch struct {
|
|||||||
SuggestionsTime *string
|
SuggestionsTime *string
|
||||||
ChatAutosendDow *int
|
ChatAutosendDow *int
|
||||||
ChatAutosendTime *string
|
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.
|
// Problem is one invalid field of a SettingsPatch, named by its JSON key.
|
||||||
type Problem struct {
|
type Problem struct {
|
||||||
Field string
|
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
|
// Update validates patch, applies it, records updatedBy (the signed-in
|
||||||
// member's subject, which must not be empty) and publishes the new settings
|
// member's subject, which must not be empty) and publishes the new settings
|
||||||
// to every subscriber. An invalid patch is a *ValidationError and changes
|
// 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) {
|
func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updatedBy string) (Settings, error) {
|
||||||
if updatedBy == "" {
|
if updatedBy == "" {
|
||||||
return Settings{}, errors.New("household: update settings: no member to record as updated_by")
|
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)
|
setString(&p.SuggestionsTime, patch.SuggestionsTime)
|
||||||
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
|
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
|
||||||
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
|
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)
|
row, err = q.UpsertHouseholdSettings(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("household: update settings: %w", err)
|
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))
|
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 {
|
if len(problems) > 0 {
|
||||||
return &ValidationError{Problems: problems}
|
return &ValidationError{Problems: problems}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ package household
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"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/internal/store"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
"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
|
// seededService returns a settings service over a migrated temp database
|
||||||
// seeded with zone America/New_York.
|
// seeded with zone America/New_York.
|
||||||
func seededService(t *testing.T) *SettingsService {
|
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()
|
t.Helper()
|
||||||
st := migratedStore(t, t.TempDir())
|
st := migratedStore(t, t.TempDir())
|
||||||
if err := SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
if err := SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||||
t.Fatal(err)
|
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 }
|
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) {
|
func TestUpdateRequiresMember(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -259,6 +348,7 @@ func TestUpdateValidation(t *testing.T) {
|
|||||||
{"dow 7", SettingsPatch{PlanDraftDow: ptr(7)}, []string{"plan_draft_dow"}},
|
{"dow 7", SettingsPatch{PlanDraftDow: ptr(7)}, []string{"plan_draft_dow"}},
|
||||||
{"dow -1", SettingsPatch{SuggestionsDow: ptr(-1)}, []string{"suggestions_dow"}},
|
{"dow -1", SettingsPatch{SuggestionsDow: ptr(-1)}, []string{"suggestions_dow"}},
|
||||||
{"dow 100", SettingsPatch{ChatAutosendDow: ptr(100)}, []string{"chat_autosend_dow"}},
|
{"dow 100", SettingsPatch{ChatAutosendDow: ptr(100)}, []string{"chat_autosend_dow"}},
|
||||||
|
{"onboarding undone", SettingsPatch{OnboardingCompleted: ptr(false)}, []string{"onboarding_completed"}},
|
||||||
{
|
{
|
||||||
"every problem is reported",
|
"every problem is reported",
|
||||||
SettingsPatch{Timezone: ptr("Nowhere"), DinnerTime: ptr("5pm"), PlanDraftDow: ptr(9)},
|
SettingsPatch{Timezone: ptr("Nowhere"), DinnerTime: ptr("5pm"), PlanDraftDow: ptr(9)},
|
||||||
|
|||||||
+104
-34
@@ -25,6 +25,13 @@ var (
|
|||||||
// ErrTotalTimeNotPositive rejects a recipe whose page gives a total
|
// ErrTotalTimeNotPositive rejects a recipe whose page gives a total
|
||||||
// time of zero minutes, which normalize_recipe refuses.
|
// time of zero minutes, which normalize_recipe refuses.
|
||||||
ErrTotalTimeNotPositive = errors.New("recipes: recipe total time is not positive")
|
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
|
// 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) == "" {
|
if strings.TrimSpace(dishName) == "" {
|
||||||
return Grounded{}, errors.New("recipes: ground: dish name is empty")
|
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 {
|
if err != nil {
|
||||||
return Grounded{}, fmt.Errorf("recipes: ground %q: %w", dishName, err)
|
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
|
var reasons []error
|
||||||
if rankErr != nil {
|
if found.rankErr != nil {
|
||||||
reasons = append(reasons, rankErr)
|
reasons = append(reasons, found.rankErr)
|
||||||
}
|
}
|
||||||
for _, r := range ranked {
|
for _, r := range found.ranked {
|
||||||
if sr, ok := stored[r.Candidate.URL]; ok {
|
if sr, ok := found.stored[r.Candidate.URL]; ok {
|
||||||
return sr.grounded, nil
|
return sr.grounded, nil
|
||||||
}
|
}
|
||||||
gr, reason, err := g.ground(ctx, r.Candidate.URL, r.Recipe)
|
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}
|
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
|
// GroundURL grounds the recipe on the page at rawURL, a pasted URL, as
|
||||||
// Ground does a candidate. An already stored rawURL is returned without
|
// Ground does a candidate. An already stored rawURL is returned without
|
||||||
// fetching it. A page that cannot be fetched, holds no usable recipe, or
|
// 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)
|
gr, reason, err := g.ground(ctx, rawURL, r)
|
||||||
if err != nil {
|
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 {
|
if reason != nil {
|
||||||
return ungroundable(reason)
|
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
|
return Grounded{}, fmt.Errorf("normalize: %w", err), nil
|
||||||
}
|
}
|
||||||
if 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) {
|
if len(norm.Ingredients) != len(r.Ingredients) {
|
||||||
return Grounded{}, nil, fmt.Errorf("normalize: %d ingredients for %d lines", 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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)) },
|
"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.
|
// PhotoSizes length as {id}-{size}.jpg under PhotoDir, with a photos row.
|
||||||
// Photos are deduplicated by the SHA-256 of the downloaded bytes. A
|
// Photos are deduplicated by the SHA-256 of the downloaded bytes. A
|
||||||
// PhotoCache is safe for concurrent use.
|
// PhotoCache is safe for concurrent use.
|
||||||
type PhotoCache struct {
|
type PhotoCache struct {
|
||||||
fetcher *Fetcher
|
images ImageFetcher
|
||||||
store *store.Store
|
store *store.Store
|
||||||
// dir is the absolute photo directory.
|
// dir is the absolute photo directory.
|
||||||
dir string
|
dir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPhotoCache returns a PhotoCache storing photos under dataDir, creating
|
// NewPhotoCache returns a PhotoCache downloading images with images and
|
||||||
// the photo directory if it is absent.
|
// storing photos under dataDir, creating the photo directory if it is
|
||||||
func NewPhotoCache(fetcher *Fetcher, st *store.Store, dataDir string) (*PhotoCache, error) {
|
// absent.
|
||||||
if fetcher == nil {
|
func NewPhotoCache(images ImageFetcher, st *store.Store, dataDir string) (*PhotoCache, error) {
|
||||||
return nil, errors.New("recipes: nil fetcher")
|
if images == nil {
|
||||||
|
return nil, errors.New("recipes: nil image fetcher")
|
||||||
}
|
}
|
||||||
if st == nil {
|
if st == nil {
|
||||||
return nil, errors.New("recipes: nil store")
|
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 {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
return nil, fmt.Errorf("recipes: create photo directory: %w", err)
|
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,
|
// 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
|
// whose shorter side is under PhotoMinShortSide. It records nothing, so a
|
||||||
// caller can try another image before opening a transaction.
|
// caller can try another image before opening a transaction.
|
||||||
func (c *PhotoCache) DownloadPhoto(ctx context.Context, rawURL string) (DownloadedPhoto, error) {
|
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 {
|
if err != nil {
|
||||||
return DownloadedPhoto{}, err
|
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
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ const (
|
|||||||
func newAIRouter(t *testing.T) http.Handler {
|
func newAIRouter(t *testing.T) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t),
|
||||||
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
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, as is any other failure finding candidates, which
|
||||||
|
// wraps dinners.ErrCandidatesFailed.
|
||||||
|
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.ErrCandidatesFailed):
|
||||||
|
// The error may quote a candidate's URL, which can carry a
|
||||||
|
// personal token, and dependency errors, so only the reason and
|
||||||
|
// dinner are logged.
|
||||||
|
logger.ErrorContext(r.Context(), action, "reason", "candidates_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,618 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const dinnersPath = "/api/v1/dinners"
|
||||||
|
|
||||||
|
// newDinnersRouter returns a router whose dinners service runs over the
|
||||||
|
// returned store. Its job runner is not started, so grounding jobs stay
|
||||||
|
// queued.
|
||||||
|
func newDinnersRouter(t *testing.T) (http.Handler, *store.Store) {
|
||||||
|
t.Helper()
|
||||||
|
e := newDinnersEnv(t)
|
||||||
|
return e.h, e.st
|
||||||
|
}
|
||||||
|
|
||||||
|
// dinnersEnv is a router whose dinners service, job runner and grounder
|
||||||
|
// share one store, grounding with fake search, pages, images and model.
|
||||||
|
type dinnersEnv struct {
|
||||||
|
h http.Handler
|
||||||
|
st *store.Store
|
||||||
|
runner *jobs.Runner
|
||||||
|
search *recipestest.Search
|
||||||
|
pages *recipestest.Pages
|
||||||
|
images *recipestest.Images
|
||||||
|
model *llm.Fake
|
||||||
|
// logs holds everything the router and services log.
|
||||||
|
logs *logBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// logBuffer is a bytes.Buffer safe for concurrent log writes.
|
||||||
|
type logBuffer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *logBuffer) Write(p []byte) (int, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.buf.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *logBuffer) String() string {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDinnersEnv(t *testing.T) *dinnersEnv {
|
||||||
|
t.Helper()
|
||||||
|
st := newTestStore(t)
|
||||||
|
e := &dinnersEnv{
|
||||||
|
st: st,
|
||||||
|
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
||||||
|
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
|
||||||
|
images: &recipestest.Images{JPEGs: map[string][]byte{}},
|
||||||
|
model: &llm.Fake{},
|
||||||
|
logs: &logBuffer{},
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(e.logs, nil))
|
||||||
|
runner, err := jobs.NewRunner(st, logger, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e.runner = runner
|
||||||
|
g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model)
|
||||||
|
svc, err := dinners.NewService(st, g, runner, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||||
|
svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// start starts the env's job runner, stopping it when the test ends.
|
||||||
|
func (e *dinnersEnv) start(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
if err := e.runner.Start(ctx); err != nil {
|
||||||
|
cancel()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cancel()
|
||||||
|
e.runner.Wait()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// callDinners sends a signed-in request with body (none when empty).
|
||||||
|
func callDinners(t *testing.T, h http.Handler, method, target, body string) (*httptest.ResponseRecorder, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
var r io.Reader
|
||||||
|
if body != "" {
|
||||||
|
r = strings.NewReader(body)
|
||||||
|
}
|
||||||
|
return serve(t, h, signedIn(method, target, r))
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDinner adds a Regular and returns its id.
|
||||||
|
func createDinner(t *testing.T, h http.Handler, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
rec, got := callDinners(t, h, http.MethodPost, dinnersPath, body)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("POST %s = %d: %s", body, rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
return got["id"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listNames returns the names GET /dinners?status=status lists, in order.
|
||||||
|
func listNames(t *testing.T, h http.Handler, status string) []string {
|
||||||
|
t.Helper()
|
||||||
|
rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"?status="+status, "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET status=%s = %d: %s", status, rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
list, ok := body["dinners"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("GET status=%s has no dinners list: %v", status, body)
|
||||||
|
}
|
||||||
|
names := make([]string, len(list))
|
||||||
|
for i, d := range list {
|
||||||
|
names[i] = d.(map[string]any)["name"].(string)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateDinnerByName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
router, st := newDinnersRouter(t)
|
||||||
|
|
||||||
|
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":" Chicken Tikka Masala "}`)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
for k, v := range map[string]any{
|
||||||
|
"name": "Chicken Tikka Masala",
|
||||||
|
"status": "regular",
|
||||||
|
"origin": "regular",
|
||||||
|
"grounding_status": "pending",
|
||||||
|
"is_favorite": false,
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
// Each secret holds a character no ULID has (Crockford base32 has no I,
|
||||||
|
// L, O, U, "-", "." or "/"), so the dinner's id never matches one.
|
||||||
|
for _, resp := range []struct{ name, body string }{{"create", created}, {"list", listed}, {"duplicate", rec.Body.String()}} {
|
||||||
|
lower := strings.ToLower(resp.body)
|
||||||
|
for _, secret := range []string{link, "c.example", "recipes/stew", "stew-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,558 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidateID returns the candidate_id GET recipe-candidates offers dinner
|
||||||
|
// id for the page titled title.
|
||||||
|
func candidateID(t *testing.T, h http.Handler, id, title string) string {
|
||||||
|
t.Helper()
|
||||||
|
rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET candidates = %d: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
for _, c := range body["candidates"].([]any) {
|
||||||
|
if c := c.(map[string]any); c["title"] == title {
|
||||||
|
cid := c["candidate_id"].(string)
|
||||||
|
if strings.Contains(cid, "://") {
|
||||||
|
t.Fatalf("candidate_id = %q, want an opaque id, not the page URL", cid)
|
||||||
|
}
|
||||||
|
return cid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("candidates = %v, want one titled %q", body, title)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if cid, _ := first["candidate_id"].(string); cid == "" || strings.Contains(cid, "a.example") {
|
||||||
|
t.Errorf("candidate_id = %#v, want an opaque id", first["candidate_id"])
|
||||||
|
}
|
||||||
|
want := map[string]any{
|
||||||
|
"candidate_id": first["candidate_id"],
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetRecipeCandidatesStoredLookupFailure: a candidate whose stored
|
||||||
|
// recipe cannot be read, or is stored incomplete, is a 500 logged by reason
|
||||||
|
// and dinner id, never with the candidate's URL, which can carry a personal
|
||||||
|
// token, or the database error.
|
||||||
|
func TestGetRecipeCandidatesStoredLookupFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
const (
|
||||||
|
token = "personal-token-abc123"
|
||||||
|
host = "secret-candidate.example"
|
||||||
|
page = "https://" + host + "/curry?access_token=" + token
|
||||||
|
)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, sabotage string
|
||||||
|
leaks []string
|
||||||
|
}{
|
||||||
|
// Renaming a column the lookup selects fails the read.
|
||||||
|
{"lookup", `ALTER TABLE recipes RENAME COLUMN yield TO yield_gone`, []string{"no such column", "yield"}},
|
||||||
|
// A recipe stored without its photo, method or difficulty is
|
||||||
|
// incomplete.
|
||||||
|
{"incomplete", `INSERT INTO recipes (id, source_url, title, raw_jsonld, fetched_at, created_at, updated_at)
|
||||||
|
VALUES ('r-incomplete', '` + page + `', 'Curry', '{}', 'now', 'now', 'now')`, []string{"r-incomplete", "no photo"}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newDinnersEnv(t)
|
||||||
|
e.search.Results["Chicken curry"] = []recipes.Candidate{{URL: page}}
|
||||||
|
id := createDinner(t, e.h, `{"name":"Chicken curry"}`) // runner not started: stays pending
|
||||||
|
if _, err := e.st.Writer.ExecContext(t.Context(), tc.sabotage); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
||||||
|
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
|
||||||
|
t.Errorf("GET = %d %s, want 500 internal", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
logs := e.logs.String()
|
||||||
|
for _, secret := range append([]string{page, host, token, "access_token"}, tc.leaks...) {
|
||||||
|
if strings.Contains(rec.Body.String(), secret) {
|
||||||
|
t.Errorf("response contains %q: %s", secret, rec.Body)
|
||||||
|
}
|
||||||
|
if strings.Contains(logs, secret) {
|
||||||
|
t.Errorf("logs contain %q: %s", secret, logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(logs, "reason=candidates_failed") || !strings.Contains(logs, "dinner_id="+id) {
|
||||||
|
t.Errorf("logs = %q, want reason=candidates_failed and the dinner id", 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))
|
||||||
|
|
||||||
|
cid := candidateID(t, e.h, id, "Weeknight Curry")
|
||||||
|
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"candidate_id":"`+cid+`"}`)
|
||||||
|
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"] == cid && c["photo_url"] != photo {
|
||||||
|
t.Errorf("grounded candidate photo_url = %v, want %s", c["photo_url"], photo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecipeCandidatesHideTokenURLs: a candidate page whose URL carries a
|
||||||
|
// token in its path or query is offered by an opaque id alone, which
|
||||||
|
// grounds the dinner in that page.
|
||||||
|
func TestRecipeCandidatesHideTokenURLs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newDinnersEnv(t)
|
||||||
|
const (
|
||||||
|
pathToken = "path-token-abc123"
|
||||||
|
queryToken = "query-token-def456"
|
||||||
|
pathPage = "https://secret-path.example/u/" + pathToken + "/chicken-curry"
|
||||||
|
queryPage = "https://secret-query.example/curry?access_token=" + queryToken
|
||||||
|
)
|
||||||
|
e.search.Results["Chicken curry"] = []recipes.Candidate{{URL: pathPage}, {URL: queryPage}}
|
||||||
|
e.pages.Bodies[pathPage] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
|
||||||
|
e.pages.Bodies[queryPage] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25)
|
||||||
|
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if list, _ := body["candidates"].([]any); len(list) != 2 {
|
||||||
|
t.Fatalf("candidates = %v, want 2", body)
|
||||||
|
}
|
||||||
|
for _, secret := range []string{pathPage, queryPage, "secret-path.example", "secret-query.example", pathToken, queryToken, "access_token"} {
|
||||||
|
if strings.Contains(rec.Body.String(), secret) {
|
||||||
|
t.Errorf("candidates response contains %q: %s", secret, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||||
|
cid := candidateID(t, e.h, id, "Weeknight Curry")
|
||||||
|
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"candidate_id":"`+cid+`"}`)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), queryToken) {
|
||||||
|
t.Errorf("ground response contains the token: %s", rec.Body)
|
||||||
|
}
|
||||||
|
var source string
|
||||||
|
if err := e.st.Reader.QueryRow(`SELECT source_url FROM recipes WHERE id = ?`, d["recipe_id"]).Scan(&source); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d["grounding_status"] != "grounded" || source != queryPage {
|
||||||
|
t.Errorf("dinner = %v in recipe %s, want grounded in %s", d, source, queryPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPostGroundRejectsCandidateNotOffered: a candidate_id is only one
|
||||||
|
// offered for that dinner; a page URL, or another dinner's candidate, is
|
||||||
|
// refused.
|
||||||
|
func TestPostGroundRejectsCandidateNotOffered(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newDinnersEnv(t).withRecipes(t)
|
||||||
|
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
|
||||||
|
other := createDinner(t, e.h, `{"name":"Pad thai"}`)
|
||||||
|
cid := candidateID(t, e.h, id, "Weeknight Curry")
|
||||||
|
for _, tc := range []struct{ name, dinner, cid string }{
|
||||||
|
{"page URL", id, curry2Page},
|
||||||
|
{"another dinner's", other, cid},
|
||||||
|
} {
|
||||||
|
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+tc.dinner+"/ground", `{"candidate_id":"`+tc.cid+`"}`)
|
||||||
|
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_dinner" {
|
||||||
|
t.Errorf("%s: %d %v, want 400 invalid_dinner", tc.name, rec.Code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 offered", `{"candidate_id":"c1"}`, "invalid_dinner"},
|
||||||
|
{"candidate a page URL", `{"candidate_id":"https://a.example/x"}`, "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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||||
newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestRunner(t),
|
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t),
|
||||||
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)))
|
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||||
newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const (
|
|||||||
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestRunner(t),
|
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestRunner(t),
|
||||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"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/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/forwarded"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
"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/household"
|
||||||
@@ -34,12 +35,15 @@ type RouteMounter interface {
|
|||||||
// /media; settings serves /api/v1/settings/household; search serves
|
// /media; settings serves /api/v1/settings/household; search serves
|
||||||
// /api/v1/settings/recipe-search and its /test; ai serves
|
// /api/v1/settings/recipe-search and its /test; ai serves
|
||||||
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
|
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
|
||||||
|
// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE
|
||||||
|
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
|
||||||
|
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
|
||||||
// runner serves
|
// runner serves
|
||||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||||
// served for every other GET. /health, /auth and the web build are public.
|
// 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.
|
// 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, ai *aisettings.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||||
if settings == nil {
|
if settings == nil {
|
||||||
return nil, errors.New("server: nil household settings service")
|
return nil, errors.New("server: nil household settings service")
|
||||||
}
|
}
|
||||||
@@ -49,6 +53,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
if ai == nil {
|
if ai == nil {
|
||||||
return nil, errors.New("server: nil AI settings service")
|
return nil, errors.New("server: nil AI settings service")
|
||||||
}
|
}
|
||||||
|
if dinnerSvc == nil {
|
||||||
|
return nil, errors.New("server: nil dinners service")
|
||||||
|
}
|
||||||
if runner == nil {
|
if runner == nil {
|
||||||
return nil, errors.New("server: nil job runner")
|
return nil, errors.New("server: nil job runner")
|
||||||
}
|
}
|
||||||
@@ -92,6 +99,13 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai))
|
apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai))
|
||||||
apiV1.Get("/settings/ai/models", getAIModels(logger, ai))
|
apiV1.Get("/settings/ai/models", getAIModels(logger, ai))
|
||||||
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
|
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
|
||||||
|
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
|
||||||
|
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
|
||||||
|
apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc))
|
||||||
|
apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc))
|
||||||
|
apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc))
|
||||||
|
apiV1.Get("/dinners/{id}/recipe-candidates", getDinnerCandidates(logger, dinnerSvc))
|
||||||
|
apiV1.Post("/dinners/{id}/ground", postDinnerGround(logger, dinnerSvc))
|
||||||
apiV1.Post("/jobs", postJob(logger, runner))
|
apiV1.Post("/jobs", postJob(logger, runner))
|
||||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||||
media.Get("/photos/{name}", getPhoto(logger, photos))
|
media.Get("/photos/{name}", getPhoto(logger, photos))
|
||||||
|
|||||||
@@ -17,9 +17,12 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"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/aisettings"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"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"
|
||||||
|
"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/secretbox"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||||
@@ -43,7 +46,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
|
|||||||
"index.html": {Data: []byte(fixtureIndex)},
|
"index.html": {Data: []byte(fixtureIndex)},
|
||||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||||
}
|
}
|
||||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), runner, newTestPhotos(t), assets)
|
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), runner, newTestPhotos(t), assets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -139,6 +142,24 @@ func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
|
|||||||
return search
|
return search
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newTestDinners returns a dinners service over a migrated temp database,
|
||||||
|
// with its own unstarted job runner and a grounder that finds nothing.
|
||||||
|
func newTestDinners(t *testing.T) *dinners.Service {
|
||||||
|
t.Helper()
|
||||||
|
st := newTestStore(t)
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
runner, err := jobs.NewRunner(st, logger, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||||
|
svc, err := dinners.NewService(st, g, runner, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
// newTestBox returns a secret box under a random key.
|
// newTestBox returns a secret box under a random key.
|
||||||
func newTestBox(t *testing.T) *secretbox.Box {
|
func newTestBox(t *testing.T) *secretbox.Box {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -170,7 +191,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil AI settings service")
|
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +200,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,7 +209,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted nil photo files")
|
t.Fatal("NewRouter accepted nil photo files")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,16 +218,25 @@ func TestNewRouterRequiresSettings(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil settings service")
|
t.Fatal("NewRouter accepted a nil settings service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewRouterRequiresDinners(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
|
t.Fatal("NewRouter accepted a nil dinners service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewRouterRequiresJobRunner(t *testing.T) {
|
func TestNewRouterRequiresJobRunner(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil job runner")
|
t.Fatal("NewRouter accepted a nil job runner")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ type settingsPatch struct {
|
|||||||
SuggestionsTime *string `json:"suggestions_time"`
|
SuggestionsTime *string `json:"suggestions_time"`
|
||||||
ChatAutosendDow *int `json:"chat_autosend_dow"`
|
ChatAutosendDow *int `json:"chat_autosend_dow"`
|
||||||
ChatAutosendTime *string `json:"chat_autosend_time"`
|
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 {
|
func getHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc {
|
||||||
@@ -110,7 +113,7 @@ func decodePatch[T any](body io.Reader) (T, error) {
|
|||||||
if errors.As(err, &typeErr) {
|
if errors.As(err, &typeErr) {
|
||||||
return patch, fmt.Errorf("%s: want a JSON %s", typeErr.Field, typeErr.Type)
|
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
|
return patch, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
"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/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
"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) {
|
func TestPatchHouseholdSettingsRejected(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -136,6 +195,8 @@ func TestPatchHouseholdSettingsRejected(t *testing.T) {
|
|||||||
{"null value", `{"timezone":null}`, "invalid_request", "timezone"},
|
{"null value", `{"timezone":null}`, "invalid_request", "timezone"},
|
||||||
{"unknown field", `{"dinner":"17:30"}`, "invalid_request", "dinner"},
|
{"unknown field", `{"dinner":"17:30"}`, "invalid_request", "dinner"},
|
||||||
{"read-only field", `{"updated_by":"someone"}`, "invalid_request", "updated_by"},
|
{"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"},
|
{"not an object", `["timezone"]`, "invalid_request", "JSON object"},
|
||||||
{"null body", `null`, "invalid_request", "JSON object"},
|
{"null body", `null`, "invalid_request", "JSON object"},
|
||||||
{"trailing data", `{"dinner_time":"18:00"} {}`, "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))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||||
newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
|||||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||||
t.Fatal(err)
|
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" {
|
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision" {
|
||||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding", got)
|
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,8 +196,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
|||||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||||
t.Fatal(err)
|
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" {
|
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision" {
|
||||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding", got)
|
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision", got)
|
||||||
}
|
}
|
||||||
if !tableExists(t, s, "dinners") {
|
if !tableExists(t, s, "dinners") {
|
||||||
t.Error("table from 0001_init was not created")
|
t.Error("table from 0001_init was not created")
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 0008_dinner_recipe_url: a Regular added from a pasted recipe link keeps
|
||||||
|
-- that link until grounding resolves it to a recipe (recipe_id). Nothing
|
||||||
|
-- else records the link: recipes rows exist only for fetched pages.
|
||||||
|
|
||||||
|
ALTER TABLE dinners ADD COLUMN recipe_url TEXT;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- 0009_dinner_grounding_error: a dinner whose grounding failed keeps why,
|
||||||
|
-- as one of the reason codes package dinners defines (no_recipe_found,
|
||||||
|
-- recipe_unusable, search_not_configured, search_failed, ai_failed,
|
||||||
|
-- interrupted, grounding_error). It is NULL unless grounding_status is
|
||||||
|
-- failed.
|
||||||
|
|
||||||
|
ALTER TABLE dinners ADD COLUMN grounding_error TEXT;
|
||||||
@@ -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;
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
-- name: CreateDinner :one
|
-- name: CreateDinner :one
|
||||||
INSERT INTO dinners (
|
INSERT INTO dinners (
|
||||||
id, name, normalized_name, status, origin, method, minutes_total,
|
id, name, normalized_name, status, origin, method, minutes_total,
|
||||||
difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status,
|
difficulty, cuisine, is_favorite, recipe_id, recipe_url, photo_id,
|
||||||
updated_by, created_at, updated_at
|
grounding_status, updated_by, created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?, ?, ?,
|
||||||
?, ?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?, ?,
|
||||||
?, ?, ?
|
?, ?, ?, ?
|
||||||
)
|
)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
@@ -19,6 +19,20 @@ SELECT * FROM dinners
|
|||||||
WHERE status = ?
|
WHERE status = ?
|
||||||
ORDER BY normalized_name, id;
|
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
|
-- name: UpdateDinner :one
|
||||||
-- An unknown id returns sql.ErrNoRows.
|
-- An unknown id returns sql.ErrNoRows.
|
||||||
UPDATE dinners
|
UPDATE dinners
|
||||||
@@ -31,8 +45,10 @@ SET name = ?,
|
|||||||
difficulty = ?,
|
difficulty = ?,
|
||||||
cuisine = ?,
|
cuisine = ?,
|
||||||
recipe_id = ?,
|
recipe_id = ?,
|
||||||
|
recipe_url = ?,
|
||||||
photo_id = ?,
|
photo_id = ?,
|
||||||
grounding_status = ?,
|
grounding_status = ?,
|
||||||
|
grounding_revision = ?,
|
||||||
updated_by = ?,
|
updated_by = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
@@ -55,3 +71,100 @@ SET is_favorite = ?,
|
|||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
RETURNING *;
|
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. Like
|
||||||
|
-- GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
||||||
|
UPDATE dinners
|
||||||
|
SET recipe_id = sqlc.arg(recipe_id),
|
||||||
|
recipe_url = NULL,
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|||||||
@@ -69,3 +69,15 @@ ORDER BY position, id;
|
|||||||
-- name: DeleteRecipeSteps :exec
|
-- name: DeleteRecipeSteps :exec
|
||||||
DELETE FROM recipe_steps
|
DELETE FROM recipe_steps
|
||||||
WHERE recipe_id = ?;
|
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 *;
|
||||||
|
|||||||
+116
-13
@@ -1,4 +1,4 @@
|
|||||||
import { act, render, screen } from '@testing-library/react'
|
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createApiClient, createVerifiedSession } from './api/index.ts'
|
import { createApiClient, createVerifiedSession } from './api/index.ts'
|
||||||
import { createNetworkStatus } from './pwa/networkStatus.ts'
|
import { createNetworkStatus } from './pwa/networkStatus.ts'
|
||||||
@@ -7,6 +7,7 @@ import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stub
|
|||||||
import App, { routes } from './App.tsx'
|
import App, { routes } from './App.tsx'
|
||||||
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||||
import { InDataRouter } from './test/dataRouter.tsx'
|
import { InDataRouter } from './test/dataRouter.tsx'
|
||||||
|
import { dinnerBody } from './test/dinners.ts'
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
Reflect.deleteProperty(window, 'matchMedia')
|
Reflect.deleteProperty(window, 'matchMedia')
|
||||||
@@ -27,7 +28,20 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The routes that render a built screen rather than a placeholder page.
|
// The routes that render a built screen rather than a placeholder page.
|
||||||
const SCREENS = { '/settings': true, '/settings/ai': true, '/settings/household': true }
|
const SCREENS = {
|
||||||
|
'/onboarding': true,
|
||||||
|
'/settings': true,
|
||||||
|
'/settings/ai': true,
|
||||||
|
'/settings/household': true,
|
||||||
|
'/settings/regulars': true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIGNED_IN answers what every route reads first: the session, then the
|
||||||
|
// household settings, onboarding finished.
|
||||||
|
const SIGNED_IN = {
|
||||||
|
'/api/v1/me': () => jsonResponse(200, ME),
|
||||||
|
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||||
|
}
|
||||||
|
|
||||||
describe('App routes', () => {
|
describe('App routes', () => {
|
||||||
it('registers every handoff route plus the extra settings pages', () => {
|
it('registers every handoff route plus the extra settings pages', () => {
|
||||||
@@ -54,7 +68,7 @@ describe('App routes', () => {
|
|||||||
'%s renders its placeholder page inside the shell',
|
'%s renders its placeholder page inside the shell',
|
||||||
async (path, title) => {
|
async (path, title) => {
|
||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
stubFetch(jsonResponse(200, ME))
|
stubFetchByPath(SIGNED_IN)
|
||||||
renderAt(path)
|
renderAt(path)
|
||||||
const heading = await screen.findByRole('heading', { level: 1, name: title })
|
const heading = await screen.findByRole('heading', { level: 1, name: title })
|
||||||
expect(screen.getByRole('main')).toContainElement(heading)
|
expect(screen.getByRole('main')).toContainElement(heading)
|
||||||
@@ -77,7 +91,7 @@ describe('App routes', () => {
|
|||||||
it('/settings/ai renders the AI assistant screen inside the shell', async () => {
|
it('/settings/ai renders the AI assistant screen inside the shell', async () => {
|
||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
stubFetchByPath({
|
stubFetchByPath({
|
||||||
'/api/v1/me': () => jsonResponse(200, ME),
|
...SIGNED_IN,
|
||||||
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||||
'/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }),
|
'/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }),
|
||||||
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||||
@@ -90,21 +104,73 @@ describe('App routes', () => {
|
|||||||
|
|
||||||
it('/settings/household renders the household settings screen inside the shell', async () => {
|
it('/settings/household renders the household settings screen inside the shell', async () => {
|
||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
stubFetchByPath({
|
stubFetchByPath(SIGNED_IN)
|
||||||
'/api/v1/me': () => jsonResponse(200, ME),
|
|
||||||
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
|
||||||
})
|
|
||||||
renderAt('/settings/household')
|
renderAt('/settings/household')
|
||||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Household' })
|
const heading = await screen.findByRole('heading', { level: 1, name: 'Household' })
|
||||||
expect(screen.getByRole('main')).toContainElement(heading)
|
expect(screen.getByRole('main')).toContainElement(heading)
|
||||||
expect(await screen.findByLabelText('Dinner time')).toHaveValue('17:30')
|
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', () => {
|
describe('App bootstrap', () => {
|
||||||
it('shows the skeleton until /me loads, then the routes', async () => {
|
it('shows the skeleton until /me loads, then the routes', async () => {
|
||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||||
renderAt('/plan')
|
renderAt('/plan')
|
||||||
|
|
||||||
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
|
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
|
||||||
@@ -138,7 +204,7 @@ describe('App bootstrap', () => {
|
|||||||
|
|
||||||
it('reports a /me failure with a retry instead of rendering the routes', async () => {
|
it('reports a /me failure with a retry instead of rendering the routes', async () => {
|
||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME))
|
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||||
renderAt('/')
|
renderAt('/')
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||||
@@ -158,7 +224,7 @@ describe('App offline bootstrap', () => {
|
|||||||
// visitOnline is an earlier online visit on this device: /me answers with a
|
// visitOnline is an earlier online visit on this device: /me answers with a
|
||||||
// member, then the app is closed.
|
// member, then the app is closed.
|
||||||
async function visitOnline() {
|
async function visitOnline() {
|
||||||
stubFetch(jsonResponse(200, ME))
|
stubFetchByPath(SIGNED_IN)
|
||||||
const { unmount } = renderAt('/')
|
const { unmount } = renderAt('/')
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||||
unmount()
|
unmount()
|
||||||
@@ -170,6 +236,13 @@ describe('App offline bootstrap', () => {
|
|||||||
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine)
|
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A test that emits an offline event leaves TanStack Query's online
|
||||||
|
// manager, shared by every test, offline until an online event.
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
window.dispatchEvent(new Event('online'))
|
||||||
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['/plan', 'Plan'],
|
['/plan', 'Plan'],
|
||||||
['/dinner/01J9ZK', 'Dinner'],
|
['/dinner/01J9ZK', 'Dinner'],
|
||||||
@@ -184,6 +257,31 @@ describe('App offline bootstrap', () => {
|
|||||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens the saved page when the browser has emitted an offline event', async () => {
|
||||||
|
await visitOnline()
|
||||||
|
// /me is still loading, with the app mounted, when the browser emits
|
||||||
|
// its offline event; only then does /me fail.
|
||||||
|
let failMe!: () => void
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn<typeof fetch>().mockImplementationOnce(
|
||||||
|
() => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))),
|
||||||
|
).mockRejectedValue(new TypeError('Failed to fetch')),
|
||||||
|
)
|
||||||
|
renderAt('/plan')
|
||||||
|
await vi.waitFor(() => expect(failMe).toBeDefined())
|
||||||
|
onLine = false
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event('offline'))
|
||||||
|
})
|
||||||
|
act(() => failMe())
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
|
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
|
||||||
await visitOnline()
|
await visitOnline()
|
||||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||||
@@ -226,12 +324,17 @@ describe('App offline bootstrap', () => {
|
|||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
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/)
|
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
|
onLine = true
|
||||||
act(() => {
|
act(() => {
|
||||||
window.dispatchEvent(new Event('online'))
|
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())
|
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
|
||||||
expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|||||||
+12
-1
@@ -3,11 +3,14 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
|||||||
import { Route, Routes } from 'react-router'
|
import { Route, Routes } from 'react-router'
|
||||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||||
|
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
||||||
|
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
|
||||||
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
||||||
import { OfflinePill } from './pwa/OfflinePill.tsx'
|
import { OfflinePill } from './pwa/OfflinePill.tsx'
|
||||||
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
||||||
import { AISettingsPage } from './settings/AISettingsPage.tsx'
|
import { AISettingsPage } from './settings/AISettingsPage.tsx'
|
||||||
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
|
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
|
||||||
|
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
|
||||||
import { SettingsPage } from './settings/SettingsPage.tsx'
|
import { SettingsPage } from './settings/SettingsPage.tsx'
|
||||||
import { AppShell } from './shell/AppShell.tsx'
|
import { AppShell } from './shell/AppShell.tsx'
|
||||||
|
|
||||||
@@ -35,17 +38,25 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
|||||||
'/settings': <SettingsPage />,
|
'/settings': <SettingsPage />,
|
||||||
'/settings/ai': <AISettingsPage />,
|
'/settings/ai': <AISettingsPage />,
|
||||||
'/settings/household': <HouseholdSettingsPage />,
|
'/settings/household': <HouseholdSettingsPage />,
|
||||||
|
'/settings/regulars': <RegularsSettingsPage />,
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
|
// 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() {
|
export function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route element={<OnboardingGate />}>
|
||||||
|
<Route path={`${ONBOARDING_PATH}/*`} element={<OnboardingPage />} />
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
{routes.map(({ path, title }) => (
|
{routes
|
||||||
|
.filter(({ path }) => path !== ONBOARDING_PATH)
|
||||||
|
.map(({ path, title }) => (
|
||||||
<Route key={path} path={path} element={screens[path] ?? <PlaceholderPage title={title} />} />
|
<Route key={path} path={path} element={screens[path] ?? <PlaceholderPage title={title} />} />
|
||||||
))}
|
))}
|
||||||
</Route>
|
</Route>
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ export function AuthBootstrap({ children }: { children: ReactNode }) {
|
|||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
return <LoadingScreen />
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadingScreen is the full-screen skeleton shown while MealQuest loads.
|
||||||
|
export function LoadingScreen() {
|
||||||
return (
|
return (
|
||||||
<main className={styles.screen} aria-busy="true" aria-label="Loading MealQuest">
|
<main className={styles.screen} aria-busy="true" aria-label="Loading MealQuest">
|
||||||
<div className={styles.loading}>
|
<div className={styles.loading}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
function oneOf<T extends string>(where: string, field: string, values: readonly T[], value: unknown): T {
|
||||||
|
if (typeof value !== 'string' || !(values as readonly string[]).includes(value)) {
|
||||||
|
throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not one of ${values.join(', ')}`)
|
||||||
|
}
|
||||||
|
return value as T
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullable<T>(value: unknown, parse: (value: unknown) => T): T | null {
|
||||||
|
return value === null ? null : parse(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function wholeNumber(where: string, field: string, value: unknown): number {
|
||||||
|
if (typeof value !== 'number' || !Number.isInteger(value)) throw new Error(`${where}: ${field} is not a whole number`)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const PHOTO_URL = /^\/media\/photos\/([^/]+)-400\.jpg$/
|
||||||
|
|
||||||
|
// photoId reads the photo id out of a photo_url, which the server always
|
||||||
|
// sends as /media/photos/{id}-400.jpg or null.
|
||||||
|
function photoId(where: string, value: unknown): string | undefined {
|
||||||
|
if (value === null) return undefined
|
||||||
|
const match = typeof value === 'string' ? PHOTO_URL.exec(value) : null
|
||||||
|
if (!match?.[1]) throw new Error(`${where}: photo_url ${JSON.stringify(value)} is not a stored photo`)
|
||||||
|
return decodeURIComponent(match[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDinner(where: string, body: unknown): Regular {
|
||||||
|
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
|
||||||
|
throw new Error(`${where}: response is not a dinner`)
|
||||||
|
}
|
||||||
|
if (typeof body.is_favorite !== 'boolean') throw new Error(`${where}: is_favorite is not a boolean`)
|
||||||
|
return {
|
||||||
|
id: body.id,
|
||||||
|
name: body.name,
|
||||||
|
method: nullable(body.method, (v) => oneOf(where, 'method', METHODS, v)),
|
||||||
|
minutes_total: nullable(body.minutes_total, (v) => wholeNumber(where, 'minutes_total', v)),
|
||||||
|
difficulty: nullable(body.difficulty, (v) => oneOf(where, 'difficulty', DIFFICULTIES, v)),
|
||||||
|
is_favorite: body.is_favorite,
|
||||||
|
photo_id: photoId(where, body.photo_url),
|
||||||
|
grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCandidate(where: string, body: unknown): RecipeCandidate {
|
||||||
|
if (
|
||||||
|
!isRecord(body) ||
|
||||||
|
typeof body.candidate_id !== 'string' ||
|
||||||
|
typeof body.title !== 'string' ||
|
||||||
|
typeof body.site_name !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error(`${where}: response is not a recipe candidate`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
candidate_id: body.candidate_id,
|
||||||
|
title: body.title,
|
||||||
|
site_name: body.site_name,
|
||||||
|
photo_id: photoId(where, body.photo_url),
|
||||||
|
minutes: nullable(body.minutes, (v) => wholeNumber(where, 'minutes', v)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dinnerPath(id: string): string {
|
||||||
|
return `${DINNERS_PATH}/${encodeURIComponent(id)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// useRegulars is the household's Regulars, in the server's order. While any
|
||||||
|
// of them is still finding its recipe it is read again every
|
||||||
|
// GROUNDING_POLL_MS, except while any change is in flight (whose settling
|
||||||
|
// reads it again anyway), so a poll never overwrites a change being saved.
|
||||||
|
// Watching the changes in flight re-renders the hook as each one starts and
|
||||||
|
// settles, which pauses and resumes the poll.
|
||||||
|
export function useRegulars() {
|
||||||
|
const { apiFetch } = useApi()
|
||||||
|
const changing = useIsMutating({ mutationKey: REGULARS_MUTATION_KEY }) > 0
|
||||||
|
return useQuery({
|
||||||
|
queryKey: REGULARS_LIST_KEY,
|
||||||
|
queryFn: async () => {
|
||||||
|
const where = `GET ${REGULARS_PATH}`
|
||||||
|
const body = await apiFetch<unknown>(REGULARS_PATH)
|
||||||
|
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
|
||||||
|
return body.dinners.map((dinner): CachedRegular => parseDinner(where, dinner))
|
||||||
|
},
|
||||||
|
select: shown,
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const list = query.state.data
|
||||||
|
if (list === undefined || changing) return false
|
||||||
|
return list.some((regular) => regular.grounding_status === 'pending') ? GROUNDING_POLL_MS : false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// changeOptimistically stops Regulars reads in flight, so they cannot
|
||||||
|
// overwrite the optimistic list, then applies change to it.
|
||||||
|
async function changeOptimistically(queryClient: QueryClient, change: (list: CachedRegular[]) => CachedRegular[]) {
|
||||||
|
await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY })
|
||||||
|
setRegulars(queryClient, change)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRegulars(queryClient: QueryClient, change: (list: CachedRegular[]) => CachedRegular[]) {
|
||||||
|
queryClient.setQueryData<CachedRegular[]>(REGULARS_LIST_KEY, (list) => change(list ?? []))
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace puts the server's answer for a dinner in its place in the list.
|
||||||
|
function replace(queryClient: QueryClient, dinner: Regular) {
|
||||||
|
setRegulars(queryClient, (list) => list.map((regular) => (regular.id === dinner.id ? dinner : regular)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// settle refetches the Regulars once the last change in flight is done, so
|
||||||
|
// an earlier change's refetch never drops a later change's optimistic entry.
|
||||||
|
function settle(queryClient: QueryClient) {
|
||||||
|
if (queryClient.isMutating({ mutationKey: REGULARS_MUTATION_KEY }) === 1) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: REGULARS_KEY })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pendingIds = 0
|
||||||
|
|
||||||
|
// useAddRegular adds a Regular by name, 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 './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.
|
||||||
|
// web/src/dinners/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
|
||||||
|
}
|
||||||
+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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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,326 @@
|
|||||||
|
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" says plainly that nothing was turned on while notifications are unavailable', async () => {
|
||||||
|
const server = await renderApp()
|
||||||
|
fireEvent.click(turnOn())
|
||||||
|
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent(
|
||||||
|
"Notifications aren't on yet: MealQuest can't send them until the notification feature ships, so nothing was turned on.",
|
||||||
|
)
|
||||||
|
// Not a silent success: onboarding is not finished behind the household's back.
|
||||||
|
expect(server.held).toHaveLength(0)
|
||||||
|
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Turn on notifications' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Maybe later' })).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
const finish = screen.getByRole('button', { name: 'Finish setting up' })
|
||||||
|
expectMinHitArea(finish)
|
||||||
|
fireEvent.click(finish)
|
||||||
|
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" says the browser blocked them when denied, then finishes on request', async () => {
|
||||||
|
const server = await renderStep(() => Promise.resolve('denied'))
|
||||||
|
fireEvent.click(turnOn())
|
||||||
|
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent("Notifications aren't on: this browser blocked them.")
|
||||||
|
expect(server.held).toHaveLength(0)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Finish setting up' }))
|
||||||
|
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||||
|
completed(server.held[0]!)
|
||||||
|
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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,174 @@
|
|||||||
|
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. This placeholder
|
||||||
|
// is the interim state: "Turn on notifications" answers 'unavailable', and
|
||||||
|
// the step tells the household plainly that nothing was turned on.
|
||||||
|
export function notificationsUnavailable(): Promise<NotificationsResult> {
|
||||||
|
return Promise.resolve('unavailable')
|
||||||
|
}
|
||||||
|
|
||||||
|
// notOnReasons says why notifications are not on after asking, for each
|
||||||
|
// result other than 'enabled'.
|
||||||
|
const notOnReasons: Record<Exclude<NotificationsResult, 'enabled'>, string> = {
|
||||||
|
unavailable:
|
||||||
|
"Notifications aren't on yet: MealQuest can't send them until the notification feature ships, so nothing was turned on. You can finish setting up now.",
|
||||||
|
denied: "Notifications aren't on: this browser blocked them. You can allow them in the browser's settings later.",
|
||||||
|
}
|
||||||
|
|
||||||
|
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. "Maybe later", and "Turn on
|
||||||
|
// notifications" once they are enabled, record that onboarding is finished
|
||||||
|
// and go Home. When they are not enabled, the step says so and offers only
|
||||||
|
// "Finish setting up". 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 [notOn, setNotOn] = useState<string | null>(null)
|
||||||
|
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)
|
||||||
|
let result: NotificationsResult
|
||||||
|
try {
|
||||||
|
result = await enableNotifications()
|
||||||
|
} catch (error) {
|
||||||
|
setProblem(`Could not turn on notifications: ${(error as Error).message}`)
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
setEnabling(false)
|
||||||
|
}
|
||||||
|
if (result !== 'enabled') {
|
||||||
|
setNotOn(notOnReasons[result])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
function later() {
|
||||||
|
clearProblem()
|
||||||
|
void finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnboardingStep
|
||||||
|
step={3}
|
||||||
|
title="Stay in the loop"
|
||||||
|
action={
|
||||||
|
notOn ? (
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Button className={styles.action} disabled={busy} onClick={later}>
|
||||||
|
Finish setting up
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
{notOn && (
|
||||||
|
<p role="status" className={styles.notOn}>
|
||||||
|
{notOn}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{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,266 @@
|
|||||||
|
/* 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: why notifications are not on after asking for them. */
|
||||||
|
.notOn {
|
||||||
|
margin: var(--space-4) 0 0;
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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,53 @@
|
|||||||
|
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. The read runs even while the
|
||||||
|
// browser reports offline, so that case fails fast instead of waiting.
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
import { useAddRegular, useRegulars, useRemoveRegular, type Regular } from '../dinners/api.ts'
|
||||||
|
import { similarNames } from '../dinners/names.ts'
|
||||||
|
import { useRefusals } from '../dinners/useRefusals.ts'
|
||||||
|
import { Button } from '../ui/Button.tsx'
|
||||||
|
import { Chip } from '../ui/Chip.tsx'
|
||||||
|
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||||
|
import { Icon } from '../ui/Icon.tsx'
|
||||||
|
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||||
|
import { TextInput } from '../ui/TextInput.tsx'
|
||||||
|
import { OnboardingStep } from './OnboardingStep.tsx'
|
||||||
|
import styles from './Onboarding.module.css'
|
||||||
|
|
||||||
|
// MIN_REGULARS is how many Regulars the server has saved before the
|
||||||
|
// household moves on.
|
||||||
|
export const MIN_REGULARS = 3
|
||||||
|
|
||||||
|
// commonPicks are offered as one-tap Regulars until the household has them,
|
||||||
|
// or a Regular the server would count as the same dinner.
|
||||||
|
export const commonPicks = [
|
||||||
|
'Beef tacos',
|
||||||
|
'Spaghetti bolognese',
|
||||||
|
'Chicken stir-fry',
|
||||||
|
'Pizza night',
|
||||||
|
'Chili',
|
||||||
|
'Pot roast',
|
||||||
|
'Burgers',
|
||||||
|
'Fried rice',
|
||||||
|
]
|
||||||
|
|
||||||
|
// RegularsStep is onboarding step 2, handoff mock 1h: the household adds
|
||||||
|
// the dinners it makes on repeat. Each add and remove shows at once and is
|
||||||
|
// undone if the server refuses it.
|
||||||
|
export function RegularsStep() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const regulars = useRegulars()
|
||||||
|
const add = useAddRegular()
|
||||||
|
const remove = useRemoveRegular()
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
const { refusals, track } = useRefusals()
|
||||||
|
const list = regulars.data ?? []
|
||||||
|
const picks = commonPicks.filter((pick) => !list.some((regular) => similarNames(regular.name, pick)))
|
||||||
|
// Next waits for every add to be answered, so a refused add's error shows
|
||||||
|
// here and an add the server takes back never counts towards the three.
|
||||||
|
const adding = list.some((regular) => regular.pending)
|
||||||
|
const saved = list.filter((regular) => !regular.pending).length
|
||||||
|
|
||||||
|
function addRegular(name: string) {
|
||||||
|
track(`add:${name.toLowerCase()}`, `add ${name}`, add.mutateAsync({ name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRegular(regular: Regular) {
|
||||||
|
track(`remove:${regular.id}`, `remove ${regular.name}`, remove.mutateAsync(regular))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
const name = draft.trim()
|
||||||
|
if (name === '') return
|
||||||
|
addRegular(name)
|
||||||
|
setDraft('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnboardingStep
|
||||||
|
step={2}
|
||||||
|
title="What's on your usual rotation?"
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
className={styles.action}
|
||||||
|
disabled={adding || saved < MIN_REGULARS}
|
||||||
|
onClick={() => void navigate('/onboarding/notifications')}
|
||||||
|
>
|
||||||
|
Next: notifications
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className={styles.helper}>
|
||||||
|
Add the dinners your household makes on repeat. MealQuest builds suggestions from these.
|
||||||
|
</p>
|
||||||
|
<form className={styles.add} onSubmit={onSubmit}>
|
||||||
|
<TextInput
|
||||||
|
label="Dinner name"
|
||||||
|
labelHidden
|
||||||
|
className={styles.addInput}
|
||||||
|
placeholder="e.g. Chicken enchiladas"
|
||||||
|
autoComplete="off"
|
||||||
|
enterKeyHint="done"
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" className={styles.addButton} aria-label="Add Regular" disabled={draft.trim() === ''}>
|
||||||
|
<span className={styles.addFace}>
|
||||||
|
<Icon icon={Plus} size="nav" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{refusals.map((refusal) => (
|
||||||
|
<p key={refusal.key} role="alert" className={styles.problem}>
|
||||||
|
{refusal.text}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<h2 className={styles.groupLabel}>Your regulars · {list.length}</h2>
|
||||||
|
{regulars.isPending && <Skeleton className={styles.status} />}
|
||||||
|
{regulars.isError && (
|
||||||
|
<div className={styles.status}>
|
||||||
|
<ErrorBanner onRetry={() => void regulars.refetch()}>
|
||||||
|
Your Regulars could not load: {regulars.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{list.length > 0 && (
|
||||||
|
<ul className={styles.chips}>
|
||||||
|
{list.map((regular: Regular) => (
|
||||||
|
<li key={regular.id}>
|
||||||
|
<Chip
|
||||||
|
label={regular.name}
|
||||||
|
className={regular.pending && styles.pending}
|
||||||
|
aria-busy={regular.pending}
|
||||||
|
onRemove={regular.pending ? undefined : () => removeRegular(regular)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{regulars.isSuccess && picks.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2 className={styles.groupLabel}>Common picks</h2>
|
||||||
|
<ul className={styles.chips}>
|
||||||
|
{picks.map((pick) => (
|
||||||
|
<li key={pick}>
|
||||||
|
<button type="button" className={styles.pick} aria-label={`Add ${pick}`} onClick={() => addRegular(pick)}>
|
||||||
|
<span aria-hidden="true">+</span>
|
||||||
|
{pick}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</OnboardingStep>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
/* Settings · Regular dinners. */
|
||||||
|
.page {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-accent-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared {
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 26px;
|
||||||
|
background: var(--color-accent-2-100);
|
||||||
|
color: var(--color-accent-2-900);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The add form: the add row, then the name field a pasted link needs. */
|
||||||
|
.addForm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The add field: the input fills the row beside the grape + button. */
|
||||||
|
.add {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add > :first-child {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add .addInput {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
height: 320px;
|
||||||
|
border-radius: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupLabel {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--color-neutral-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item + .item {
|
||||||
|
border-top: 1px solid var(--color-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Beat the Tag's own tones whatever order the sheets load in. */
|
||||||
|
.meta .finding {
|
||||||
|
background: var(--color-neutral-200);
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta .found {
|
||||||
|
background: var(--color-accent-2-200);
|
||||||
|
color: var(--color-accent-2-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta .needs {
|
||||||
|
background: var(--color-accent-100);
|
||||||
|
color: var(--color-accent-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filled {
|
||||||
|
color: var(--color-accent);
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint,
|
||||||
|
.problem,
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.problem {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-accent-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
color: var(--color-accent-2-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheetTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Beside a dropdown, a minutes field matches its height. */
|
||||||
|
.columns .paired {
|
||||||
|
width: 100%;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border: 1px solid var(--color-neutral-300);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions > button {
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidatesLoading {
|
||||||
|
height: 200px;
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidates {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 72px;
|
||||||
|
padding: 8px 16px 8px 8px;
|
||||||
|
border: 1px solid var(--color-neutral-300);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: start;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate:hover {
|
||||||
|
background: var(--color-neutral-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidatePhoto {
|
||||||
|
flex: none;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidateBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidateTitle {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidateSite {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A form locked while its save is pending. It lays out as if it were not
|
||||||
|
there. */
|
||||||
|
.lock {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||||
|
import { Route, Routes } from 'react-router'
|
||||||
|
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||||
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
|
import { GROUNDING_POLL_MS } from '../dinners/api.ts'
|
||||||
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
|
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||||
|
import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
|
||||||
|
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||||
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
|
import { RegularsSettingsPage } from './RegularsSettingsPage.tsx'
|
||||||
|
|
||||||
|
const REGULARS = '/api/v1/dinners?status=regular'
|
||||||
|
|
||||||
|
type Responder = (body: unknown) => Response | Promise<Response>
|
||||||
|
|
||||||
|
// stubServer stands in for the dinners API: GET of the Regulars answers
|
||||||
|
// the current server.regulars, so a test changes what the next read sees;
|
||||||
|
// every other request is answered by responders, keyed "METHOD path" and
|
||||||
|
// called with the request's decoded JSON body. Any other request fails the
|
||||||
|
// test.
|
||||||
|
function stubServer(regulars: DinnerBody[], responders: Record<string, Responder> = {}) {
|
||||||
|
const server = { regulars, fetchMock: undefined as unknown as Mock<typeof fetch> }
|
||||||
|
const all: Record<string, Responder> = {
|
||||||
|
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||||
|
[`GET ${REGULARS}`]: () => jsonResponse(200, { dinners: server.regulars }),
|
||||||
|
...responders,
|
||||||
|
}
|
||||||
|
server.fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||||
|
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||||
|
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||||
|
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||||
|
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined
|
||||||
|
return Promise.resolve(respond(body))
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', server.fetchMock)
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
// sent lists the requests other than GETs, as "METHOD path" and body.
|
||||||
|
function sent(fetchMock: Mock<typeof fetch>) {
|
||||||
|
return fetchMock.mock.calls
|
||||||
|
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
|
||||||
|
.map(([input, init]) => ({
|
||||||
|
request: `${init?.method} ${String(input)}`,
|
||||||
|
body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function reads(fetchMock: Mock<typeof fetch>) {
|
||||||
|
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === REGULARS)
|
||||||
|
.length
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||||
|
// the page at /settings/regulars once its Regulars have loaded.
|
||||||
|
async function renderPage() {
|
||||||
|
const api = createApiClient(fakeLocation('/settings/regulars'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||||
|
await api.loadMe()
|
||||||
|
render(
|
||||||
|
<ApiProvider client={api}>
|
||||||
|
<QueryClientProvider client={createQueryClient()}>
|
||||||
|
<InDataRouter path="/settings/regulars">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/settings/regulars" element={<RegularsSettingsPage />} />
|
||||||
|
</Routes>
|
||||||
|
</InDataRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ApiProvider>,
|
||||||
|
)
|
||||||
|
await screen.findByRole('heading', { level: 2, name: /^On rotation/ })
|
||||||
|
}
|
||||||
|
|
||||||
|
const TACOS = dinnerBody('d1', 'Beef tacos', {
|
||||||
|
method: 'stovetop',
|
||||||
|
minutes_total: 40,
|
||||||
|
difficulty: 'easy',
|
||||||
|
is_favorite: true,
|
||||||
|
photo_url: '/media/photos/p1-400.jpg',
|
||||||
|
})
|
||||||
|
const CHILI = dinnerBody('d2', 'Chili', { method: 'crockpot', minutes_total: 480, grounding_status: 'pending' })
|
||||||
|
const PIZZA = dinnerBody('d3', 'Pizza night', { grounding_status: 'failed', grounding_error: 'no_recipe_found' })
|
||||||
|
const ROAST = dinnerBody('d4', 'Pot roast', { method: 'oven', grounding_status: 'manual' })
|
||||||
|
|
||||||
|
function rows() {
|
||||||
|
return within(screen.getByRole('list', { name: 'Regulars' })).queryAllByRole('listitem')
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(name: string) {
|
||||||
|
const item = rows().find((li) => within(li).queryByText(name) !== null)
|
||||||
|
if (item === undefined) throw new Error(`no Regular row named ${name}`)
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
function addField() {
|
||||||
|
return screen.getByRole('textbox', { name: 'Add a Regular' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(text: string) {
|
||||||
|
fireEvent.change(addField(), { target: { value: text } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add Regular' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRow(name: string) {
|
||||||
|
fireEvent.click(within(row(name)).getByRole('button', { name: new RegExp(`^${name}`) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RegularsSettingsPage', () => {
|
||||||
|
it('lists each Regular with its photo, method and minutes, favorite heart and grounding badge', async () => {
|
||||||
|
stubServer([TACOS, CHILI, PIZZA, ROAST])
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { level: 1, name: 'Regular dinners' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 4' })).toBeInTheDocument()
|
||||||
|
expect(rows()).toHaveLength(4)
|
||||||
|
|
||||||
|
const tacos = within(row('Beef tacos'))
|
||||||
|
expect(tacos.getByRole('img', { name: 'Beef tacos' })).toHaveAttribute('src', '/media/photos/p1-400.jpg')
|
||||||
|
expect(tacos.getByText('Stovetop · 40 min')).toBeInTheDocument()
|
||||||
|
expect(tacos.getByText('Recipe found')).toBeInTheDocument()
|
||||||
|
expect(tacos.getByRole('button', { name: 'Favorite Beef tacos' })).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
|
||||||
|
const chili = within(row('Chili'))
|
||||||
|
expect(chili.getByText('Crock pot · 480 min')).toBeInTheDocument()
|
||||||
|
expect(chili.getByText('Finding recipe…')).toBeInTheDocument()
|
||||||
|
expect(chili.getByRole('button', { name: 'Favorite Chili' })).toHaveAttribute('aria-pressed', 'false')
|
||||||
|
|
||||||
|
const pizza = within(row('Pizza night'))
|
||||||
|
// No photo yet: the striped placeholder stands in.
|
||||||
|
expect(pizza.getByRole('img', { name: 'Pizza night' })).toHaveTextContent('dish photo')
|
||||||
|
expect(pizza.getByText('Needs a recipe')).toBeInTheDocument()
|
||||||
|
|
||||||
|
expect(within(row('Pot roast')).getByText('Oven')).toBeInTheDocument()
|
||||||
|
expect(within(row('Pot roast')).getByText('Recipe found')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps every control at least 44 px', async () => {
|
||||||
|
stubServer([TACOS])
|
||||||
|
await renderPage()
|
||||||
|
expectMinHitArea(screen.getByRole('button', { name: 'Add Regular' }))
|
||||||
|
expectMinHitArea(screen.getByRole('button', { name: 'Favorite Beef tacos' }))
|
||||||
|
expectMinHitArea(within(row('Beef tacos')).getByRole('button', { name: /^Beef tacos/ }))
|
||||||
|
expectMinHitArea(screen.getByRole('button', { name: 'Back to Settings' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says so when there are no Regulars yet', async () => {
|
||||||
|
stubServer([])
|
||||||
|
await renderPage()
|
||||||
|
expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 0' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/No Regulars yet/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports Regulars that could not load, and retries', async () => {
|
||||||
|
const server = stubServer([], { [`GET ${REGULARS}`]: () => errorResponse(500, 'internal') })
|
||||||
|
const api = createApiClient(fakeLocation('/settings/regulars'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||||
|
render(
|
||||||
|
<ApiProvider client={api}>
|
||||||
|
<QueryClientProvider client={createQueryClient()}>
|
||||||
|
<InDataRouter path="/settings/regulars">
|
||||||
|
<RegularsSettingsPage />
|
||||||
|
</InDataRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ApiProvider>,
|
||||||
|
)
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Your Regulars could not load: internal message')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||||
|
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds a Regular by name, showing it at once and finding its recipe', async () => {
|
||||||
|
const server = stubServer([TACOS], {
|
||||||
|
'POST /api/v1/dinners': (body) => {
|
||||||
|
const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' })
|
||||||
|
server.regulars = [...server.regulars, created]
|
||||||
|
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
add(' Chicken enchiladas ')
|
||||||
|
expect(addField()).toHaveValue('')
|
||||||
|
expect(await screen.findByText('Chicken enchiladas')).toBeInTheDocument()
|
||||||
|
expect(within(row('Chicken enchiladas')).getByText('Finding recipe…')).toBeInTheDocument()
|
||||||
|
expect(sent(server.fetchMock)).toEqual([{ request: 'POST /api/v1/dinners', body: { name: 'Chicken enchiladas' } }])
|
||||||
|
await waitFor(() => expect(row('Chicken enchiladas')).not.toHaveAttribute('aria-busy'))
|
||||||
|
expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds a Regular from a pasted recipe link under the name the household gives it', async () => {
|
||||||
|
const link = 'https://example.com/recipes/stew-key-abcdefghijklmno'
|
||||||
|
const server = stubServer([], {
|
||||||
|
'POST /api/v1/dinners': (body) => {
|
||||||
|
const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' })
|
||||||
|
server.regulars = [created]
|
||||||
|
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
fireEvent.change(addField(), { target: { value: ` ${link} ` } })
|
||||||
|
// A link alone cannot be added: it never names a dinner.
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Regular' })).toBeDisabled()
|
||||||
|
const name = screen.getByRole('textbox', { name: 'Dinner name' })
|
||||||
|
expect(name).toHaveAccessibleDescription(/never names a dinner/)
|
||||||
|
fireEvent.change(name, { target: { value: ' Sunday stew ' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add Regular' }))
|
||||||
|
|
||||||
|
expect(addField()).toHaveValue('')
|
||||||
|
expect(screen.queryByRole('textbox', { name: 'Dinner name' })).not.toBeInTheDocument()
|
||||||
|
expect(await screen.findByText('Sunday stew')).toBeInTheDocument()
|
||||||
|
expect(sent(server.fetchMock)).toEqual([
|
||||||
|
{ request: 'POST /api/v1/dinners', body: { name: 'Sunday stew', recipe_url: link } },
|
||||||
|
])
|
||||||
|
expect(within(row('Sunday stew')).getByText('Finding recipe…')).toBeInTheDocument()
|
||||||
|
expect(document.body).not.toHaveTextContent(/example\.com|abcdefghijklmno|key/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('takes back an add the server refuses and says why', async () => {
|
||||||
|
stubServer([TACOS], { 'POST /api/v1/dinners': () => errorResponse(409, 'duplicate_dinner') })
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
add('Beef tacos!')
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Beef tacos!: duplicate_dinner message')
|
||||||
|
expect(rows()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles the favorite heart', async () => {
|
||||||
|
const server = stubServer([TACOS], {
|
||||||
|
'POST /api/v1/dinners/d1/favorite': () => {
|
||||||
|
server.regulars = [{ ...TACOS, is_favorite: false }]
|
||||||
|
return jsonResponse(200, server.regulars[0])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Favorite Beef tacos' }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('button', { name: 'Favorite Beef tacos' })).toHaveAttribute('aria-pressed', 'false'),
|
||||||
|
)
|
||||||
|
expect(sent(server.fetchMock)).toEqual([{ request: 'POST /api/v1/dinners/d1/favorite', body: undefined }])
|
||||||
|
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
|
||||||
|
expect(screen.getByRole('button', { name: 'Favorite Beef tacos' })).toHaveAttribute('aria-pressed', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('edits a Regular: name, method, minutes and difficulty', async () => {
|
||||||
|
const server = stubServer([TACOS], {
|
||||||
|
'PATCH /api/v1/dinners/d1': (body) => {
|
||||||
|
const edited = { ...TACOS, ...(body as object) }
|
||||||
|
server.regulars = [edited]
|
||||||
|
return jsonResponse(200, edited)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
openRow('Beef tacos')
|
||||||
|
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
|
||||||
|
expect(sheet.getByLabelText('Name')).toHaveValue('Beef tacos')
|
||||||
|
expect(sheet.getByLabelText('Method')).toHaveValue('stovetop')
|
||||||
|
expect(sheet.getByLabelText('Minutes')).toHaveValue(40)
|
||||||
|
expect(sheet.getByLabelText('Difficulty')).toHaveValue('easy')
|
||||||
|
expect(sheet.getByRole('button', { name: 'Save changes' })).toBeDisabled()
|
||||||
|
|
||||||
|
fireEvent.change(sheet.getByLabelText('Name'), { target: { value: 'Fish tacos' } })
|
||||||
|
fireEvent.change(sheet.getByLabelText('Method'), { target: { value: 'grill' } })
|
||||||
|
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '25' } })
|
||||||
|
fireEvent.change(sheet.getByLabelText('Difficulty'), { target: { value: 'medium' } })
|
||||||
|
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||||
|
expect(sent(server.fetchMock)).toEqual([
|
||||||
|
{
|
||||||
|
request: 'PATCH /api/v1/dinners/d1',
|
||||||
|
body: { name: 'Fish tacos', method: 'grill', minutes_total: 25, difficulty: 'medium' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(within(row('Fish tacos')).getByText('Grill · 25 min')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends only the changed fields, refuses bad minutes and shows a refused edit', async () => {
|
||||||
|
const server = stubServer([TACOS], { 'PATCH /api/v1/dinners/d1': () => errorResponse(400, 'invalid_dinner') })
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
openRow('Beef tacos')
|
||||||
|
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
|
||||||
|
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '0' } })
|
||||||
|
expect(sheet.getByText('Minutes must be a whole number from 1 to 1440.')).toBeInTheDocument()
|
||||||
|
expect(sheet.getByRole('button', { name: 'Save changes' })).toBeDisabled()
|
||||||
|
|
||||||
|
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '45' } })
|
||||||
|
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
|
||||||
|
expect(await sheet.findByRole('alert')).toHaveTextContent('Not saved: invalid_dinner message')
|
||||||
|
expect(sent(server.fetchMock)).toEqual([{ request: 'PATCH /api/v1/dinners/d1', body: { minutes_total: 45 } }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes a Regular only once confirmed', async () => {
|
||||||
|
const server = stubServer([TACOS, ROAST], {
|
||||||
|
'DELETE /api/v1/dinners/d1': () => {
|
||||||
|
server.regulars = [ROAST]
|
||||||
|
return jsonResponse(200, { ...TACOS, status: 'archived' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
openRow('Beef tacos')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Remove from Regulars' }))
|
||||||
|
const confirm = within(screen.getByRole('dialog', { name: 'Remove Beef tacos?' }))
|
||||||
|
fireEvent.click(confirm.getByRole('button', { name: 'Keep it' }))
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Edit Beef tacos' })).toBeInTheDocument()
|
||||||
|
expect(sent(server.fetchMock)).toEqual([])
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Remove from Regulars' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
|
await waitFor(() => expect(rows()).toHaveLength(1))
|
||||||
|
expect(within(rows()[0]!).getByText('Pot roast')).toBeInTheDocument()
|
||||||
|
expect(sent(server.fetchMock)).toEqual([{ request: 'DELETE /api/v1/dinners/d1', body: undefined }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts a refused removal back and says why', async () => {
|
||||||
|
stubServer([TACOS], { 'DELETE /api/v1/dinners/d1': () => errorResponse(500, 'internal') })
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
openRow('Beef tacos')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Remove from Regulars' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
|
||||||
|
expect(rows()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Fix recipe', () => {
|
||||||
|
const CANDIDATES = {
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
candidate_id: 'https://example.com/pizza',
|
||||||
|
title: 'Weeknight pizza',
|
||||||
|
site_name: 'Example Kitchen',
|
||||||
|
photo_url: '/media/photos/p7-400.jpg',
|
||||||
|
minutes: 45,
|
||||||
|
},
|
||||||
|
{ candidate_id: 'https://cook.test/pizza', title: 'Sheet-pan pizza', site_name: 'Cook Test', photo_url: null, minutes: null },
|
||||||
|
{ candidate_id: 'https://food.test/p', title: 'Pizza margherita', site_name: 'Food Test', photo_url: null, minutes: 30 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function grounded(server: { regulars: DinnerBody[] }, overrides: Partial<DinnerBody>) {
|
||||||
|
const fixed = { ...PIZZA, grounding_error: null, ...overrides }
|
||||||
|
server.regulars = [fixed]
|
||||||
|
return jsonResponse(200, fixed)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixSheet() {
|
||||||
|
return within(screen.getByRole('dialog', { name: 'Fix recipe' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
it('opens from a Needs a recipe row and picks one of up to three matches', async () => {
|
||||||
|
const server = stubServer([PIZZA], {
|
||||||
|
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
|
||||||
|
'POST /api/v1/dinners/d3/ground': () =>
|
||||||
|
grounded(server, { grounding_status: 'grounded', photo_url: '/media/photos/p7-400.jpg', method: 'oven', minutes_total: 45 }),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
openRow('Pizza night')
|
||||||
|
const matches = within(await fixSheet().findByRole('list', { name: 'Recipe matches' }))
|
||||||
|
const options = matches.getAllByRole('button')
|
||||||
|
expect(options.map((option) => option.getAttribute('aria-label'))).toEqual([
|
||||||
|
'Use Weeknight pizza from Example Kitchen',
|
||||||
|
'Use Sheet-pan pizza from Cook Test',
|
||||||
|
'Use Pizza margherita from Food Test',
|
||||||
|
])
|
||||||
|
expect(within(options[0]!).getByRole('img', { name: 'Weeknight pizza' })).toHaveAttribute('src', '/media/photos/p7-400.jpg')
|
||||||
|
expect(within(options[0]!).getByText('Example Kitchen')).toBeInTheDocument()
|
||||||
|
expectMinHitArea(options[0]!)
|
||||||
|
|
||||||
|
fireEvent.click(options[0]!)
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||||
|
expect(sent(server.fetchMock)).toEqual([
|
||||||
|
{ request: 'POST /api/v1/dinners/d3/ground', body: { candidate_id: 'https://example.com/pizza' } },
|
||||||
|
])
|
||||||
|
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
|
||||||
|
expect(within(row('Pizza night')).getByText('Oven · 45 min')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says so when no match was found', async () => {
|
||||||
|
stubServer([PIZZA], { 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, { candidates: [] }) })
|
||||||
|
await renderPage()
|
||||||
|
openRow('Pizza night')
|
||||||
|
expect(await fixSheet().findByText('No matches were found. Paste a recipe link or type it in.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('grounds a pasted recipe link, and says why a link was refused', async () => {
|
||||||
|
let answers = 0
|
||||||
|
const server = stubServer([PIZZA], {
|
||||||
|
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
|
||||||
|
'POST /api/v1/dinners/d3/ground': () =>
|
||||||
|
++answers === 1 ? errorResponse(422, 'ungroundable') : grounded(server, { grounding_status: 'grounded' }),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
openRow('Pizza night')
|
||||||
|
fireEvent.click(fixSheet().getByRole('radio', { name: 'Paste a link' }))
|
||||||
|
fireEvent.change(fixSheet().getByLabelText('Recipe link'), { target: { value: ' https://example.com/blog/pizza ' } })
|
||||||
|
fireEvent.click(fixSheet().getByRole('button', { name: 'Use this recipe' }))
|
||||||
|
expect(await fixSheet().findByRole('alert')).toHaveTextContent('Not fixed: ungroundable message')
|
||||||
|
|
||||||
|
fireEvent.change(fixSheet().getByLabelText('Recipe link'), { target: { value: 'https://example.com/pizza-2' } })
|
||||||
|
fireEvent.click(fixSheet().getByRole('button', { name: 'Use this recipe' }))
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||||
|
expect(sent(server.fetchMock)).toEqual([
|
||||||
|
{ request: 'POST /api/v1/dinners/d3/ground', body: { recipe_url: 'https://example.com/blog/pizza' } },
|
||||||
|
{ request: 'POST /api/v1/dinners/d3/ground', body: { recipe_url: 'https://example.com/pizza-2' } },
|
||||||
|
])
|
||||||
|
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves ingredients and steps typed in', async () => {
|
||||||
|
const server = stubServer([PIZZA], {
|
||||||
|
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
|
||||||
|
'POST /api/v1/dinners/d3/ground': () =>
|
||||||
|
grounded(server, { grounding_status: 'manual', method: 'oven', minutes_total: 35 }),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
openRow('Pizza night')
|
||||||
|
fireEvent.click(fixSheet().getByRole('radio', { name: 'Type it in' }))
|
||||||
|
const save = fixSheet().getByRole('button', { name: 'Save recipe' })
|
||||||
|
expect(save).toBeDisabled()
|
||||||
|
fireEvent.change(fixSheet().getByLabelText('Ingredients, one per line'), {
|
||||||
|
target: { value: '1 pizza dough\n\n 1 cup tomato sauce \n2 cups mozzarella' },
|
||||||
|
})
|
||||||
|
fireEvent.change(fixSheet().getByLabelText('Steps, one per line'), {
|
||||||
|
target: { value: 'Heat the oven to 250°C.\nTop the dough and bake 12 minutes.\n' },
|
||||||
|
})
|
||||||
|
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: 'oven' } })
|
||||||
|
fireEvent.change(fixSheet().getByLabelText('Minutes'), { target: { value: '35' } })
|
||||||
|
fireEvent.click(save)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||||
|
expect(sent(server.fetchMock)).toEqual([
|
||||||
|
{
|
||||||
|
request: 'POST /api/v1/dinners/d3/ground',
|
||||||
|
body: {
|
||||||
|
manual: {
|
||||||
|
ingredients: ['1 pizza dough', '1 cup tomato sauce', '2 cups mozzarella'],
|
||||||
|
steps: ['Heat the oven to 250°C.', 'Top the dough and bake 12 minutes.'],
|
||||||
|
minutes: 35,
|
||||||
|
method: 'oven',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
|
||||||
|
expect(within(row('Pizza night')).getByText('Oven · 35 min')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves a typed-in recipe only once its method and minutes are set', async () => {
|
||||||
|
const server = stubServer([PIZZA], {
|
||||||
|
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
openRow('Pizza night')
|
||||||
|
fireEvent.click(fixSheet().getByRole('radio', { name: 'Type it in' }))
|
||||||
|
const save = fixSheet().getByRole('button', { name: 'Save recipe' })
|
||||||
|
fireEvent.change(fixSheet().getByLabelText('Ingredients, one per line'), { target: { value: '1 pizza dough' } })
|
||||||
|
expect(fixSheet().getByText('Choose a method and minutes to save.')).toBeInTheDocument()
|
||||||
|
expect(save).toBeDisabled()
|
||||||
|
|
||||||
|
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: 'oven' } })
|
||||||
|
expect(save).toBeDisabled()
|
||||||
|
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: '' } })
|
||||||
|
fireEvent.change(fixSheet().getByLabelText('Minutes'), { target: { value: '35' } })
|
||||||
|
expect(save).toBeDisabled()
|
||||||
|
|
||||||
|
// Even a submit that skips the button sends nothing.
|
||||||
|
fireEvent.submit(save.closest('form')!)
|
||||||
|
expect(sent(server.fetchMock)).toEqual([])
|
||||||
|
|
||||||
|
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: 'oven' } })
|
||||||
|
expect(fixSheet().queryByText('Choose a method and minutes to save.')).not.toBeInTheDocument()
|
||||||
|
expect(save).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches to editing the Regular', async () => {
|
||||||
|
stubServer([PIZZA], { 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES) })
|
||||||
|
await renderPage()
|
||||||
|
openRow('Pizza night')
|
||||||
|
fireEvent.click(fixSheet().getByRole('button', { name: 'Edit or remove Pizza night' }))
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Edit Pizza night' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes grounding status while any Regular is finding its recipe, then stops', async () => {
|
||||||
|
// Only intervals are faked: TanStack Query polls with setInterval, and
|
||||||
|
// Testing Library's waits keep their real timers.
|
||||||
|
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
||||||
|
const server = stubServer([TACOS, CHILI])
|
||||||
|
await renderPage()
|
||||||
|
expect(within(row('Chili')).getByText('Finding recipe…')).toBeInTheDocument()
|
||||||
|
expect(reads(server.fetchMock)).toBe(1)
|
||||||
|
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
|
||||||
|
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
|
||||||
|
expect(within(row('Chili')).getByText('Finding recipe…')).toBeInTheDocument()
|
||||||
|
|
||||||
|
server.regulars = [TACOS, { ...CHILI, grounding_status: 'grounded', photo_url: '/media/photos/p2-400.jpg' }]
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
|
||||||
|
expect(await within(row('Chili')).findByText('Recipe found')).toBeInTheDocument()
|
||||||
|
expect(within(row('Chili')).getByRole('img', { name: 'Chili' })).toHaveAttribute('src', '/media/photos/p2-400.jpg')
|
||||||
|
expect(reads(server.fetchMock)).toBe(3)
|
||||||
|
|
||||||
|
// Nothing is pending any more, so nothing is read again.
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
|
||||||
|
expect(reads(server.fetchMock)).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
// held answers a request only when release is called, so a test can look
|
||||||
|
// at the page while a save is in flight.
|
||||||
|
function held(answer: () => Response) {
|
||||||
|
let release = () => {}
|
||||||
|
const response = new Promise<Response>((resolve) => {
|
||||||
|
release = () => resolve(answer())
|
||||||
|
})
|
||||||
|
return { respond: () => response, release: () => release() }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('pauses the refresh while an edit is being saved, then resumes', async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
||||||
|
const edited = { ...TACOS, name: 'Fish tacos' }
|
||||||
|
const patch = held(() => jsonResponse(200, edited))
|
||||||
|
const server = stubServer([TACOS, CHILI], { 'PATCH /api/v1/dinners/d1': patch.respond })
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
openRow('Beef tacos')
|
||||||
|
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
|
||||||
|
fireEvent.change(sheet.getByLabelText('Name'), { target: { value: 'Fish tacos' } })
|
||||||
|
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
|
||||||
|
await waitFor(() => expect(sent(server.fetchMock)).toHaveLength(1))
|
||||||
|
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
|
||||||
|
expect(reads(server.fetchMock)).toBe(1)
|
||||||
|
|
||||||
|
server.regulars = [edited, CHILI]
|
||||||
|
patch.release()
|
||||||
|
// Settling reads the list once, then the refresh carries on.
|
||||||
|
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
|
||||||
|
await waitFor(() => expect(reads(server.fetchMock)).toBe(3))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pauses the refresh while a recipe fix is being saved, then resumes', async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
||||||
|
const fixed = { ...PIZZA, grounding_status: 'grounded' as const, grounding_error: null }
|
||||||
|
const ground = held(() => jsonResponse(200, fixed))
|
||||||
|
const server = stubServer([CHILI, PIZZA], {
|
||||||
|
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, { candidates: [] }),
|
||||||
|
'POST /api/v1/dinners/d3/ground': ground.respond,
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
openRow('Pizza night')
|
||||||
|
const sheet = within(screen.getByRole('dialog', { name: 'Fix recipe' }))
|
||||||
|
fireEvent.click(sheet.getByRole('radio', { name: 'Paste a link' }))
|
||||||
|
fireEvent.change(sheet.getByLabelText('Recipe link'), { target: { value: 'https://example.com/pizza' } })
|
||||||
|
fireEvent.click(sheet.getByRole('button', { name: 'Use this recipe' }))
|
||||||
|
await waitFor(() => expect(sent(server.fetchMock)).toHaveLength(1))
|
||||||
|
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
|
||||||
|
expect(reads(server.fetchMock)).toBe(1)
|
||||||
|
|
||||||
|
server.regulars = [CHILI, fixed]
|
||||||
|
ground.release()
|
||||||
|
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
|
||||||
|
await waitFor(() => expect(reads(server.fetchMock)).toBe(3))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not poll when no Regular is finding its recipe', async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
||||||
|
const server = stubServer([TACOS, PIZZA])
|
||||||
|
await renderPage()
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
|
||||||
|
expect(reads(server.fetchMock)).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
import { ChevronLeft, Heart, Plus } from 'lucide-react'
|
||||||
|
import { useId, useState, type FormEvent, type ReactNode } from 'react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
import {
|
||||||
|
DIFFICULTIES,
|
||||||
|
METHODS,
|
||||||
|
useAddRegular,
|
||||||
|
useEditRegular,
|
||||||
|
useGroundRegular,
|
||||||
|
useRecipeCandidates,
|
||||||
|
useRegulars,
|
||||||
|
useRemoveRegular,
|
||||||
|
useToggleFavorite,
|
||||||
|
type Difficulty,
|
||||||
|
type GroundingStatus,
|
||||||
|
type ManualRecipe,
|
||||||
|
type Method,
|
||||||
|
type NewRegular,
|
||||||
|
type RecipeFix,
|
||||||
|
type Regular,
|
||||||
|
type RegularPatch,
|
||||||
|
} from '../dinners/api.ts'
|
||||||
|
import { useRefusals } from '../dinners/useRefusals.ts'
|
||||||
|
import { Button } from '../ui/Button.tsx'
|
||||||
|
import { Card } from '../ui/Card.tsx'
|
||||||
|
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||||
|
import { Icon } from '../ui/Icon.tsx'
|
||||||
|
import { IconButton } from '../ui/IconButton.tsx'
|
||||||
|
import { ListRow } from '../ui/ListRow.tsx'
|
||||||
|
import { Photo } from '../ui/Photo.tsx'
|
||||||
|
import { Segmented } from '../ui/Segmented.tsx'
|
||||||
|
import { Select } from '../ui/Select.tsx'
|
||||||
|
import { Sheet } from '../ui/Sheet.tsx'
|
||||||
|
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||||
|
import { Tag } from '../ui/Tag.tsx'
|
||||||
|
import { TextInput } from '../ui/TextInput.tsx'
|
||||||
|
import styles from './RegularsSettingsPage.module.css'
|
||||||
|
|
||||||
|
// The most minutes a dinner may take, as the dinners API allows: a day.
|
||||||
|
const MAX_MINUTES = 1440
|
||||||
|
|
||||||
|
export const methodLabels: Record<Method, string> = {
|
||||||
|
stovetop: 'Stovetop',
|
||||||
|
oven: 'Oven',
|
||||||
|
crockpot: 'Crock pot',
|
||||||
|
grill: 'Grill',
|
||||||
|
other: 'Other',
|
||||||
|
}
|
||||||
|
|
||||||
|
const difficultyLabels: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
|
||||||
|
|
||||||
|
// A pasted link is added as a recipe link under a name the household gives;
|
||||||
|
// anything else is a dinner name.
|
||||||
|
const LINK = /^https?:\/\//i
|
||||||
|
|
||||||
|
// Settings · Regular dinners: the household's Regulars, each with its photo,
|
||||||
|
// method and minutes, favorite heart and whether its recipe was found. The
|
||||||
|
// field at the top adds one by name, or by pasted recipe link with a name
|
||||||
|
// the household gives it; tapping one
|
||||||
|
// edits or removes it, or fixes its recipe when none was found.
|
||||||
|
export function RegularsSettingsPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<IconButton label="Back to Settings" onClick={() => void navigate('/settings')}>
|
||||||
|
<Icon icon={ChevronLeft} size="nav" />
|
||||||
|
</IconButton>
|
||||||
|
<div>
|
||||||
|
<p className={styles.kicker}>Settings</p>
|
||||||
|
<h1 className={styles.title}>Regular dinners</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<p className={styles.shared}>Changes apply to everyone in the household.</p>
|
||||||
|
<RegularsManager />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which sheet is open, for which Regular.
|
||||||
|
type Open = { sheet: 'edit' | 'fix'; id: string } | null
|
||||||
|
|
||||||
|
function RegularsManager() {
|
||||||
|
const regulars = useRegulars()
|
||||||
|
const add = useAddRegular()
|
||||||
|
const remove = useRemoveRegular()
|
||||||
|
const favorite = useToggleFavorite()
|
||||||
|
const { refusals, track } = useRefusals()
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
const [linkName, setLinkName] = useState('')
|
||||||
|
const [open, setOpen] = useState<Open>(null)
|
||||||
|
const headingId = useId()
|
||||||
|
const linkHintId = useId()
|
||||||
|
const isLink = LINK.test(draft.trim())
|
||||||
|
const ready = draft.trim() !== '' && (!isLink || linkName.trim() !== '')
|
||||||
|
const list = regulars.data ?? []
|
||||||
|
// A Regular removed meanwhile closes its sheet.
|
||||||
|
const opened = open && list.find((regular) => regular.id === open.id && !regular.pending)
|
||||||
|
|
||||||
|
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!ready) return
|
||||||
|
const text = draft.trim()
|
||||||
|
const body: NewRegular = isLink ? { name: linkName.trim(), recipe_url: text } : { name: text }
|
||||||
|
track(`add:${body.name.toLowerCase()}`, `add ${body.name}`, add.mutateAsync(body))
|
||||||
|
setDraft('')
|
||||||
|
setLinkName('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRemove(regular: Regular) {
|
||||||
|
setOpen(null)
|
||||||
|
track(`remove:${regular.id}`, `remove ${regular.name}`, remove.mutateAsync(regular))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFavorite(regular: Regular) {
|
||||||
|
track(`favorite:${regular.id}`, `change the favorite heart on ${regular.name}`, favorite.mutateAsync(regular))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form className={styles.addForm} onSubmit={onSubmit}>
|
||||||
|
<div className={styles.add}>
|
||||||
|
<TextInput
|
||||||
|
label="Add a Regular"
|
||||||
|
labelHidden
|
||||||
|
className={styles.addInput}
|
||||||
|
placeholder="Dinner name or recipe link"
|
||||||
|
autoComplete="off"
|
||||||
|
enterKeyHint="done"
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
/>
|
||||||
|
<IconButton type="submit" label="Add Regular" tone="accent" disabled={!ready}>
|
||||||
|
<Icon icon={Plus} size="nav" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
{isLink && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label="Dinner name"
|
||||||
|
placeholder="What do you call this dinner?"
|
||||||
|
autoComplete="off"
|
||||||
|
enterKeyHint="done"
|
||||||
|
aria-describedby={linkHintId}
|
||||||
|
value={linkName}
|
||||||
|
onChange={(event) => setLinkName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<p id={linkHintId} className={styles.hint}>
|
||||||
|
Name it yourself: a link can carry private details, so it never names a dinner.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
{refusals.map((refusal) => (
|
||||||
|
<p key={refusal.key} role="alert" className={styles.problem}>
|
||||||
|
{refusal.text}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{regulars.isPending && <Skeleton className={styles.loading} />}
|
||||||
|
{regulars.isError && (
|
||||||
|
<ErrorBanner onRetry={() => void regulars.refetch()}>
|
||||||
|
Your Regulars could not load: {regulars.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)}
|
||||||
|
{regulars.isSuccess && (
|
||||||
|
<section aria-labelledby={headingId} className={styles.section}>
|
||||||
|
<h2 id={headingId} className={styles.groupLabel}>
|
||||||
|
On rotation · {list.length}
|
||||||
|
</h2>
|
||||||
|
{list.length === 0 ? (
|
||||||
|
<p className={styles.hint}>No Regulars yet. Add the dinners your household makes on repeat.</p>
|
||||||
|
) : (
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<ul className={styles.rows} aria-label="Regulars">
|
||||||
|
{list.map((regular) => (
|
||||||
|
<RegularRow
|
||||||
|
key={regular.id}
|
||||||
|
regular={regular}
|
||||||
|
onOpen={() =>
|
||||||
|
setOpen({ sheet: regular.grounding_status === 'failed' ? 'fix' : 'edit', id: regular.id })
|
||||||
|
}
|
||||||
|
onFavorite={() => onFavorite(regular)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{opened && open.sheet === 'edit' && (
|
||||||
|
<EditRegularSheet
|
||||||
|
key={opened.id}
|
||||||
|
regular={opened}
|
||||||
|
onClose={() => setOpen(null)}
|
||||||
|
onRemove={() => onRemove(opened)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{opened && open.sheet === 'fix' && (
|
||||||
|
<FixRecipeSheet
|
||||||
|
key={opened.id}
|
||||||
|
regular={opened}
|
||||||
|
onClose={() => setOpen(null)}
|
||||||
|
onEdit={() => setOpen({ sheet: 'edit', id: opened.id })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// details is a Regular's "method · minutes" line, or null when neither is
|
||||||
|
// known yet.
|
||||||
|
export function details(regular: Regular): string | null {
|
||||||
|
const parts = [
|
||||||
|
regular.method && methodLabels[regular.method],
|
||||||
|
regular.minutes_total !== null && `${regular.minutes_total} min`,
|
||||||
|
].filter((part): part is string => typeof part === 'string')
|
||||||
|
return parts.length === 0 ? null : parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const badges: Record<GroundingStatus, { text: string; className: string | undefined }> = {
|
||||||
|
pending: { text: 'Finding recipe…', className: styles.finding },
|
||||||
|
grounded: { text: 'Recipe found', className: styles.found },
|
||||||
|
manual: { text: 'Recipe found', className: styles.found },
|
||||||
|
failed: { text: 'Needs a recipe', className: styles.needs },
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroundingBadge({ status }: { status: GroundingStatus }) {
|
||||||
|
const badge = badges[status]
|
||||||
|
return <Tag className={`${styles.badge} ${badge.className}`}>{badge.text}</Tag>
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegularRowProps = {
|
||||||
|
regular: Regular
|
||||||
|
onOpen: () => void
|
||||||
|
onFavorite: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function RegularRow({ regular, onOpen, onFavorite }: RegularRowProps) {
|
||||||
|
const line = details(regular)
|
||||||
|
return (
|
||||||
|
<li className={styles.item} aria-busy={regular.pending}>
|
||||||
|
<ListRow
|
||||||
|
className={regular.pending && styles.pending}
|
||||||
|
leading={<Photo id={regular.photo_id} name={regular.name} radius="14px" sizes="48px" className={styles.photo} />}
|
||||||
|
title={regular.name}
|
||||||
|
meta={
|
||||||
|
<span className={styles.meta}>
|
||||||
|
{line && <span>{line}</span>}
|
||||||
|
<GroundingBadge status={regular.grounding_status} />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
onClick={regular.pending ? undefined : onOpen}
|
||||||
|
trailing={
|
||||||
|
<IconButton
|
||||||
|
label={`Favorite ${regular.name}`}
|
||||||
|
aria-pressed={regular.is_favorite}
|
||||||
|
disabled={regular.pending}
|
||||||
|
onClick={onFavorite}
|
||||||
|
>
|
||||||
|
<Icon icon={Heart} size="nav" className={regular.is_favorite ? styles.filled : undefined} />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SheetFrameProps = {
|
||||||
|
title: string
|
||||||
|
onClose: () => void
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFrame({ title, onClose, children }: SheetFrameProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
return (
|
||||||
|
<Sheet open onClose={onClose} aria-labelledby={titleId}>
|
||||||
|
<div className={styles.sheet}>
|
||||||
|
<h2 id={titleId} className={styles.sheetTitle}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// minutesProblem is why a typed minutes value cannot be saved, or null.
|
||||||
|
function minutesProblem(text: string): string | null {
|
||||||
|
if (text === '') return null
|
||||||
|
const minutes = Number(text)
|
||||||
|
if (!Number.isInteger(minutes) || minutes < 1 || minutes > MAX_MINUTES) {
|
||||||
|
return `Minutes must be a whole number from 1 to ${MAX_MINUTES}.`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
type Draft = { name: string; method: Method | ''; minutes: string; difficulty: Difficulty | '' }
|
||||||
|
|
||||||
|
function draftOf(regular: Regular): Draft {
|
||||||
|
return {
|
||||||
|
name: regular.name,
|
||||||
|
method: regular.method ?? '',
|
||||||
|
minutes: regular.minutes_total === null ? '' : String(regular.minutes_total),
|
||||||
|
difficulty: regular.difficulty ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchOf is the PATCH body for the fields draft changes. A field the
|
||||||
|
// Regular has no value for stays unset until one is chosen.
|
||||||
|
function patchOf(regular: Regular, draft: Draft): RegularPatch {
|
||||||
|
const saved = draftOf(regular)
|
||||||
|
const patch: RegularPatch = {}
|
||||||
|
if (draft.name.trim() !== saved.name) patch.name = draft.name.trim()
|
||||||
|
if (draft.method !== saved.method && draft.method !== '') patch.method = draft.method
|
||||||
|
if (draft.minutes !== saved.minutes && draft.minutes !== '') patch.minutes_total = Number(draft.minutes)
|
||||||
|
if (draft.difficulty !== saved.difficulty && draft.difficulty !== '') patch.difficulty = draft.difficulty
|
||||||
|
return patch
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditRegularSheetProps = {
|
||||||
|
regular: Regular
|
||||||
|
onClose: () => void
|
||||||
|
onRemove: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditRegularSheet renames a Regular and sets its method, minutes and
|
||||||
|
// difficulty, or removes it from the rotation once confirmed.
|
||||||
|
function EditRegularSheet({ regular, onClose, onRemove }: EditRegularSheetProps) {
|
||||||
|
const [draft, setDraft] = useState(() => draftOf(regular))
|
||||||
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
const edit = useEditRegular()
|
||||||
|
const patch = patchOf(regular, draft)
|
||||||
|
const minutesError = minutesProblem(draft.minutes)
|
||||||
|
const nameError = draft.name.trim() === '' ? 'A Regular needs a name.' : null
|
||||||
|
const canSave = Object.keys(patch).length > 0 && minutesError === null && nameError === null
|
||||||
|
|
||||||
|
function change(next: Partial<Draft>) {
|
||||||
|
edit.reset()
|
||||||
|
setDraft((current) => ({ ...current, ...next }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSave(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!canSave) return
|
||||||
|
edit.mutate({ id: regular.id, patch }, { onSuccess: onClose })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirming) {
|
||||||
|
return (
|
||||||
|
<SheetFrame title={`Remove ${regular.name}?`} onClose={onClose}>
|
||||||
|
<p className={styles.hint}>
|
||||||
|
It leaves your Regulars, so suggestions and plans stop using it. You can add it again later.
|
||||||
|
</p>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Button onClick={onRemove}>Remove</Button>
|
||||||
|
<Button variant="outline" onClick={() => setConfirming(false)}>
|
||||||
|
Keep it
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SheetFrame title={`Edit ${regular.name}`} onClose={onClose}>
|
||||||
|
<form className={styles.form} onSubmit={onSave}>
|
||||||
|
<fieldset className={styles.lock} disabled={edit.isPending}>
|
||||||
|
<TextInput
|
||||||
|
label="Name"
|
||||||
|
value={draft.name}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-invalid={nameError !== null}
|
||||||
|
onChange={(event) => change({ name: event.target.value })}
|
||||||
|
/>
|
||||||
|
{nameError && <p className={styles.problem}>{nameError}</p>}
|
||||||
|
<div className={styles.columns}>
|
||||||
|
<Select label="Method" value={draft.method} onChange={(event) => change({ method: event.target.value as Method })}>
|
||||||
|
{regular.method === null && (
|
||||||
|
<option value="" disabled>
|
||||||
|
Not set
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{METHODS.map((method) => (
|
||||||
|
<option key={method} value={method}>
|
||||||
|
{methodLabels[method]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<TextInput
|
||||||
|
label="Minutes"
|
||||||
|
className={styles.paired}
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1}
|
||||||
|
max={MAX_MINUTES}
|
||||||
|
value={draft.minutes}
|
||||||
|
aria-invalid={minutesError !== null}
|
||||||
|
onChange={(event) => change({ minutes: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{minutesError && <p className={styles.problem}>{minutesError}</p>}
|
||||||
|
<Select
|
||||||
|
label="Difficulty"
|
||||||
|
value={draft.difficulty}
|
||||||
|
onChange={(event) => change({ difficulty: event.target.value as Difficulty })}
|
||||||
|
>
|
||||||
|
{regular.difficulty === null && (
|
||||||
|
<option value="" disabled>
|
||||||
|
Not set
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{DIFFICULTIES.map((difficulty) => (
|
||||||
|
<option key={difficulty} value={difficulty}>
|
||||||
|
{difficultyLabels[difficulty]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{edit.isError && (
|
||||||
|
<p role="alert" className={styles.problem}>
|
||||||
|
Not saved: {edit.error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Button type="submit" disabled={!canSave}>
|
||||||
|
{edit.isPending ? 'Saving…' : 'Save changes'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setConfirming(true)}>
|
||||||
|
Remove from Regulars
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</SheetFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FixPath = 'pick' | 'link' | 'manual'
|
||||||
|
|
||||||
|
const fixPaths = [
|
||||||
|
{ value: 'pick', label: 'Pick one' },
|
||||||
|
{ value: 'link', label: 'Paste a link' },
|
||||||
|
{ value: 'manual', label: 'Type it in' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type FixRecipeSheetProps = {
|
||||||
|
regular: Regular
|
||||||
|
onClose: () => void
|
||||||
|
onEdit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// lines is text split into its non-blank lines, trimmed.
|
||||||
|
function lines(text: string): string[] {
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line !== '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// FixRecipeSheet grounds a Regular whose recipe was not found: in one of
|
||||||
|
// up to three recipe pages found for its name, in a pasted recipe link, or
|
||||||
|
// in ingredients and steps typed in.
|
||||||
|
function FixRecipeSheet({ regular, onClose, onEdit }: FixRecipeSheetProps) {
|
||||||
|
const [path, setPath] = useState<FixPath>('pick')
|
||||||
|
const ground = useGroundRegular()
|
||||||
|
|
||||||
|
function fix(body: RecipeFix) {
|
||||||
|
ground.mutate({ id: regular.id, fix: body }, { onSuccess: onClose })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SheetFrame title="Fix recipe" onClose={onClose}>
|
||||||
|
<p className={styles.hint}>
|
||||||
|
We couldn't find a recipe for {regular.name}. Pick a match below, paste a recipe link or type it in.
|
||||||
|
</p>
|
||||||
|
<Segmented
|
||||||
|
label="How to fix the recipe"
|
||||||
|
options={fixPaths}
|
||||||
|
value={path}
|
||||||
|
onValueChange={(next) => {
|
||||||
|
ground.reset()
|
||||||
|
setPath(next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<fieldset className={styles.lock} disabled={ground.isPending}>
|
||||||
|
{path === 'pick' && <PickCandidate id={regular.id} onPick={(candidate_id) => fix({ candidate_id })} />}
|
||||||
|
{path === 'link' && <PasteLink onSubmit={(recipe_url) => fix({ recipe_url })} />}
|
||||||
|
{path === 'manual' && <ManualEntry onSubmit={(manual) => fix({ manual })} />}
|
||||||
|
</fieldset>
|
||||||
|
<p role="status" className={styles.status}>
|
||||||
|
{ground.isPending && 'Finding recipe… this can take a minute.'}
|
||||||
|
</p>
|
||||||
|
{ground.isError && (
|
||||||
|
<p role="alert" className={styles.problem}>
|
||||||
|
Not fixed: {ground.error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" disabled={ground.isPending} onClick={onEdit}>
|
||||||
|
Edit or remove {regular.name}
|
||||||
|
</Button>
|
||||||
|
</SheetFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PickCandidate({ id, onPick }: { id: string; onPick: (candidateId: string) => void }) {
|
||||||
|
const candidates = useRecipeCandidates(id)
|
||||||
|
if (candidates.isPending) return <Skeleton className={styles.candidatesLoading} />
|
||||||
|
if (candidates.isError) {
|
||||||
|
return (
|
||||||
|
<ErrorBanner onRetry={() => void candidates.refetch()}>
|
||||||
|
Recipe matches could not load: {candidates.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (candidates.data.length === 0) {
|
||||||
|
return <p className={styles.hint}>No matches were found. Paste a recipe link or type it in.</p>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className={styles.candidates} aria-label="Recipe matches">
|
||||||
|
{candidates.data.map((candidate) => (
|
||||||
|
<li key={candidate.candidate_id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.candidate}
|
||||||
|
aria-label={`Use ${candidate.title} from ${candidate.site_name}`}
|
||||||
|
onClick={() => onPick(candidate.candidate_id)}
|
||||||
|
>
|
||||||
|
<Photo id={candidate.photo_id} name={candidate.title} radius="14px" sizes="56px" className={styles.candidatePhoto} />
|
||||||
|
<span className={styles.candidateBody}>
|
||||||
|
<span className={styles.candidateTitle}>{candidate.title}</span>
|
||||||
|
<span className={styles.candidateSite}>{candidate.site_name}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasteLink({ onSubmit }: { onSubmit: (recipeUrl: string) => void }) {
|
||||||
|
const [link, setLink] = useState('')
|
||||||
|
const url = link.trim()
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className={styles.form}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (url !== '') onSubmit(url)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
label="Recipe link"
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
placeholder="https://"
|
||||||
|
autoComplete="off"
|
||||||
|
value={link}
|
||||||
|
onChange={(event) => setLink(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={url === ''}>
|
||||||
|
Use this recipe
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManualEntry({ onSubmit }: { onSubmit: (manual: ManualRecipe) => void }) {
|
||||||
|
const [ingredients, setIngredients] = useState('')
|
||||||
|
const [steps, setSteps] = useState('')
|
||||||
|
const [minutes, setMinutes] = useState('')
|
||||||
|
const [method, setMethod] = useState<Method | ''>('')
|
||||||
|
const ingredientsId = useId()
|
||||||
|
const stepsId = useId()
|
||||||
|
const minutesError = minutesProblem(minutes)
|
||||||
|
const ingredientLines = lines(ingredients)
|
||||||
|
const missing = method === '' || minutes === ''
|
||||||
|
const canSave = ingredientLines.length > 0 && !missing && minutesError === null
|
||||||
|
|
||||||
|
function onSave(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!canSave) return
|
||||||
|
onSubmit({ ingredients: ingredientLines, steps: lines(steps), minutes: Number(minutes), method })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className={styles.form} onSubmit={onSave}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor={ingredientsId} className={styles.label}>
|
||||||
|
Ingredients, one per line
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id={ingredientsId}
|
||||||
|
className={styles.textarea}
|
||||||
|
rows={5}
|
||||||
|
value={ingredients}
|
||||||
|
onChange={(event) => setIngredients(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor={stepsId} className={styles.label}>
|
||||||
|
Steps, one per line
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id={stepsId}
|
||||||
|
className={styles.textarea}
|
||||||
|
rows={5}
|
||||||
|
value={steps}
|
||||||
|
onChange={(event) => setSteps(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.columns}>
|
||||||
|
<Select label="Method" value={method} onChange={(event) => setMethod(event.target.value as Method | '')}>
|
||||||
|
<option value="">Not set</option>
|
||||||
|
{METHODS.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{methodLabels[m]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<TextInput
|
||||||
|
label="Minutes"
|
||||||
|
className={styles.paired}
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1}
|
||||||
|
max={MAX_MINUTES}
|
||||||
|
value={minutes}
|
||||||
|
aria-invalid={minutesError !== null}
|
||||||
|
onChange={(event) => setMinutes(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{minutesError && <p className={styles.problem}>{minutesError}</p>}
|
||||||
|
{ingredientLines.length > 0 && missing && <p className={styles.problem}>Choose a method and minutes to save.</p>}
|
||||||
|
<Button type="submit" disabled={!canSave}>
|
||||||
|
Save recipe
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
+43
-5
@@ -37,13 +37,17 @@ export type HouseholdSettings = {
|
|||||||
suggestions_time: string
|
suggestions_time: string
|
||||||
chat_autosend_dow: number
|
chat_autosend_dow: number
|
||||||
chat_autosend_time: string
|
chat_autosend_time: string
|
||||||
|
// When the household finished onboarding; null until it has, and every
|
||||||
|
// app route sends it to /onboarding. Not editable in settings.
|
||||||
|
onboarding_completed_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HouseholdSettingsField is a household setting the settings pages edit.
|
||||||
|
export type HouseholdSettingsField = Exclude<keyof HouseholdSettings, 'onboarding_completed_at'>
|
||||||
|
|
||||||
// HouseholdSettingsPatch is the PATCH /api/v1/settings/household body: only
|
// HouseholdSettingsPatch is the PATCH /api/v1/settings/household body: only
|
||||||
// the fields that change.
|
// the fields that change.
|
||||||
export type HouseholdSettingsPatch = Partial<HouseholdSettings>
|
export type HouseholdSettingsPatch = Partial<Pick<HouseholdSettings, HouseholdSettingsField>>
|
||||||
|
|
||||||
export type HouseholdSettingsField = keyof HouseholdSettings
|
|
||||||
|
|
||||||
const householdTimeFields = [
|
const householdTimeFields = [
|
||||||
'dinner_time',
|
'dinner_time',
|
||||||
@@ -355,17 +359,24 @@ function parseHouseholdSettings(where: string, body: unknown): HouseholdSettings
|
|||||||
}
|
}
|
||||||
days[field] = value
|
days[field] = value
|
||||||
}
|
}
|
||||||
return { timezone: body.timezone, ...times, ...days }
|
if (!isStringOrNull(body.onboarding_completed_at)) {
|
||||||
|
throw new Error(`${where}: onboarding_completed_at ${JSON.stringify(body.onboarding_completed_at)} is not a time or null`)
|
||||||
|
}
|
||||||
|
return { timezone: body.timezone, ...times, ...days, onboarding_completed_at: body.onboarding_completed_at }
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOUSEHOLD_SETTINGS_KEY = ['settings', 'household'] as const
|
export const HOUSEHOLD_SETTINGS_KEY = ['settings', 'household'] as const
|
||||||
|
|
||||||
|
// useHouseholdSettings runs even while the browser reports offline, like
|
||||||
|
// /me: OnboardingGate reads it before every page, so offline it must fail
|
||||||
|
// into the saved pages instead of waiting for the network.
|
||||||
export function useHouseholdSettings() {
|
export function useHouseholdSettings() {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: HOUSEHOLD_SETTINGS_KEY,
|
queryKey: HOUSEHOLD_SETTINGS_KEY,
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
parseHouseholdSettings(`GET ${HOUSEHOLD_SETTINGS_PATH}`, await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH)),
|
parseHouseholdSettings(`GET ${HOUSEHOLD_SETTINGS_PATH}`, await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH)),
|
||||||
|
networkMode: 'always',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +393,21 @@ export function useSaveHouseholdSettings() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// useCompleteOnboarding records that the household finished onboarding: the
|
||||||
|
// server sets onboarding_completed_at, which ends the first-run redirect.
|
||||||
|
export function useCompleteOnboarding() {
|
||||||
|
const { apiFetch } = useApi()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
parseHouseholdSettings(
|
||||||
|
`PATCH ${HOUSEHOLD_SETTINGS_PATH}`,
|
||||||
|
await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH, { method: 'PATCH', body: { onboarding_completed: true } }),
|
||||||
|
),
|
||||||
|
onSuccess: (settings) => queryClient.setQueryData(HOUSEHOLD_SETTINGS_KEY, settings),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// HouseholdSettingsProblems is a refused household settings save: the reason
|
// HouseholdSettingsProblems is a refused household settings save: the reason
|
||||||
// for each field the server named, and any reasons that name no field.
|
// for each field the server named, and any reasons that name no field.
|
||||||
export type HouseholdSettingsProblems = {
|
export type HouseholdSettingsProblems = {
|
||||||
@@ -419,6 +445,18 @@ export function householdSettingsProblems(error: Error): HouseholdSettingsProble
|
|||||||
return problems
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// needsMoreRegulars reports whether error is the server refusing to finish
|
||||||
|
// onboarding because the household has fewer than three Regulars
|
||||||
|
// (internal/household MinRegulars) — the only refusal of
|
||||||
|
// {"onboarding_completed": true}.
|
||||||
|
export function needsMoreRegulars(error: Error): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof ApiError &&
|
||||||
|
error.code === 'invalid_settings' &&
|
||||||
|
error.message.startsWith(`${invalidHouseholdPrefix}onboarding_completed: `)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// aiStatus is the AI assistant row's status line: the provider and the
|
// aiStatus is the AI assistant row's status line: the provider and the
|
||||||
// model's display name, or that no model is chosen yet (the seed).
|
// model's display name, or that no model is chosen yet (the seed).
|
||||||
export function aiStatus(settings: Pick<AISettings, 'provider' | 'model'>): string {
|
export function aiStatus(settings: Pick<AISettings, 'provider' | 'model'>): string {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ afterEach(() => {
|
|||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
function renderAt(path: string, width: number) {
|
async function renderAt(path: string, width: number) {
|
||||||
const resize = mockViewport(width)
|
const resize = mockViewport(width)
|
||||||
stubFetchByPath(SETTINGS_API)
|
stubFetchByPath(SETTINGS_API)
|
||||||
render(
|
render(
|
||||||
@@ -31,6 +31,8 @@ function renderAt(path: string, width: number) {
|
|||||||
</ApiProviders>
|
</ApiProviders>
|
||||||
</InDataRouter>,
|
</InDataRouter>,
|
||||||
)
|
)
|
||||||
|
// The routes wait for the household settings (OnboardingGate).
|
||||||
|
await screen.findByRole('navigation', { name: 'Primary' })
|
||||||
return resize
|
return resize
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +51,8 @@ function current() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('AppShell', () => {
|
describe('AppShell', () => {
|
||||||
it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', (width) => {
|
it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', async (width) => {
|
||||||
renderAt('/', width)
|
await renderAt('/', width)
|
||||||
expect(navLinks().map((link) => link.textContent)).toEqual(ORDER)
|
expect(navLinks().map((link) => link.textContent)).toEqual(ORDER)
|
||||||
expect(navLinks().map((link) => link.getAttribute('href'))).toEqual([
|
expect(navLinks().map((link) => link.getAttribute('href'))).toEqual([
|
||||||
'/',
|
'/',
|
||||||
@@ -61,8 +63,8 @@ describe('AppShell', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows only the mobile bottom bar below 768 px', () => {
|
it('shows only the mobile bottom bar below 768 px', async () => {
|
||||||
renderAt('/', 767)
|
await renderAt('/', 767)
|
||||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||||
expect(nav()).toHaveClass(bottomBarStyles.bar!)
|
expect(nav()).toHaveClass(bottomBarStyles.bar!)
|
||||||
expect(screen.queryByText('M')).not.toBeInTheDocument()
|
expect(screen.queryByText('M')).not.toBeInTheDocument()
|
||||||
@@ -80,8 +82,8 @@ describe('AppShell', () => {
|
|||||||
expect(bar.borderRadius).toBe('var(--radius-pill)')
|
expect(bar.borderRadius).toBe('var(--radius-pill)')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', () => {
|
it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', async () => {
|
||||||
renderAt('/discover', MOBILE)
|
await renderAt('/discover', MOBILE)
|
||||||
const pills = nav().querySelectorAll(`.${bottomBarStyles.pill}`)
|
const pills = nav().querySelectorAll(`.${bottomBarStyles.pill}`)
|
||||||
expect(pills).toHaveLength(1)
|
expect(pills).toHaveLength(1)
|
||||||
const pill = declaredStyle(pills[0]!)
|
const pill = declaredStyle(pills[0]!)
|
||||||
@@ -101,24 +103,24 @@ describe('AppShell', () => {
|
|||||||
expect(within(inactive).getByText('Plan')).toHaveClass(bottomBarStyles.hiddenLabel!)
|
expect(within(inactive).getByText('Plan')).toHaveClass(bottomBarStyles.hiddenLabel!)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('makes the full 62 px bar height the mobile tap target', () => {
|
it('makes the full 62 px bar height the mobile tap target', async () => {
|
||||||
renderAt('/', MOBILE)
|
await renderAt('/', MOBILE)
|
||||||
for (const link of navLinks()) {
|
for (const link of navLinks()) {
|
||||||
expect(declaredStyle(link).height).toBe('62px')
|
expect(declaredStyle(link).height).toBe('62px')
|
||||||
expectMinHitArea(link)
|
expectMinHitArea(link)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reserves at least 100 px below the mobile scroll area', () => {
|
it('reserves at least 100 px below the mobile scroll area', async () => {
|
||||||
renderAt('/', MOBILE)
|
await renderAt('/', MOBILE)
|
||||||
const padding = declaredStyle(screen.getByRole('main')).paddingBottom
|
const padding = declaredStyle(screen.getByRole('main')).paddingBottom
|
||||||
const reserved = /^calc\((\d+)px \+ env\(safe-area-inset-bottom\)\)$/.exec(padding)
|
const reserved = /^calc\((\d+)px \+ env\(safe-area-inset-bottom\)\)$/.exec(padding)
|
||||||
expect(reserved, `padding-bottom ${JSON.stringify(padding)}`).not.toBeNull()
|
expect(reserved, `padding-bottom ${JSON.stringify(padding)}`).not.toBeNull()
|
||||||
expect(Number(reserved![1])).toBeGreaterThanOrEqual(100)
|
expect(Number(reserved![1])).toBeGreaterThanOrEqual(100)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', (width) => {
|
it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', async (width) => {
|
||||||
renderAt('/plan', width)
|
await renderAt('/plan', width)
|
||||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||||
expect(nav()).toHaveClass(railStyles.rail!)
|
expect(nav()).toHaveClass(railStyles.rail!)
|
||||||
expect(declaredStyle(nav()).width).toBe('96px')
|
expect(declaredStyle(nav()).width).toBe('96px')
|
||||||
@@ -146,8 +148,8 @@ describe('AppShell', () => {
|
|||||||
expect(screen.getByRole('link', { name: 'Home' }).firstElementChild).not.toHaveClass(railStyles.active!)
|
expect(screen.getByRole('link', { name: 'Home' }).firstElementChild).not.toHaveClass(railStyles.active!)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', (width) => {
|
it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', async (width) => {
|
||||||
renderAt('/quick', width)
|
await renderAt('/quick', width)
|
||||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||||
const sidebar = nav().closest('aside')!
|
const sidebar = nav().closest('aside')!
|
||||||
expect(sidebar).toHaveClass(sidebarStyles.sidebar!)
|
expect(sidebar).toHaveClass(sidebarStyles.sidebar!)
|
||||||
@@ -176,8 +178,8 @@ describe('AppShell', () => {
|
|||||||
for (const link of navLinks()) expect(declaredStyle(link).minHeight).toBe('44px')
|
for (const link of navLinks()) expect(declaredStyle(link).minHeight).toBe('44px')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('switches layout when the viewport crosses a breakpoint', () => {
|
it('switches layout when the viewport crosses a breakpoint', async () => {
|
||||||
const resize = renderAt('/', MOBILE)
|
const resize = await renderAt('/', MOBILE)
|
||||||
expect(nav()).toHaveClass(bottomBarStyles.bar!)
|
expect(nav()).toHaveClass(bottomBarStyles.bar!)
|
||||||
resize(TABLET)
|
resize(TABLET)
|
||||||
expect(nav()).toHaveClass(railStyles.rail!)
|
expect(nav()).toHaveClass(railStyles.rail!)
|
||||||
@@ -206,11 +208,10 @@ describe('AppShell', () => {
|
|||||||
['/settings/household', ['Settings']],
|
['/settings/household', ['Settings']],
|
||||||
['/settings/regulars', ['Settings']],
|
['/settings/regulars', ['Settings']],
|
||||||
['/settings/calendar', ['Settings']],
|
['/settings/calendar', ['Settings']],
|
||||||
['/onboarding', []],
|
|
||||||
['/cook-from-pantry', []],
|
['/cook-from-pantry', []],
|
||||||
['/dinner/abc123', []],
|
['/dinner/abc123', []],
|
||||||
])('%s marks %j current', (path, expected) => {
|
])('%s marks %j current', async (path, expected) => {
|
||||||
renderAt(path, width)
|
await renderAt(path, width)
|
||||||
expect(current()).toEqual(expected)
|
expect(current()).toEqual(expected)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ afterEach(() => {
|
|||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
function renderAt(path: string, reducedMotion = false) {
|
async function renderAt(path: string, reducedMotion = false) {
|
||||||
mockViewport(MOBILE, { reducedMotion })
|
mockViewport(MOBILE, { reducedMotion })
|
||||||
stubFetchByPath(SETTINGS_API)
|
stubFetchByPath(SETTINGS_API)
|
||||||
render(
|
render(
|
||||||
@@ -80,6 +80,8 @@ function renderAt(path: string, reducedMotion = false) {
|
|||||||
</ApiProviders>
|
</ApiProviders>
|
||||||
</InDataRouter>,
|
</InDataRouter>,
|
||||||
)
|
)
|
||||||
|
// The routes wait for the household settings (OnboardingGate).
|
||||||
|
await screen.findByRole('navigation', { name: 'Primary' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function nav() {
|
function nav() {
|
||||||
@@ -109,21 +111,21 @@ function animationsOf(element: Element) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('BottomBar', () => {
|
describe('BottomBar', () => {
|
||||||
it('places the pill on the active item without animating on first render', () => {
|
it('places the pill on the active item without animating on first render', async () => {
|
||||||
renderAt('/plan')
|
await renderAt('/plan')
|
||||||
expect(pill().style.left).toBe('54px')
|
expect(pill().style.left).toBe('54px')
|
||||||
expect(pill().style.width).toBe('110px')
|
expect(pill().style.width).toBe('110px')
|
||||||
expect(pill()).toBeVisible()
|
expect(pill()).toBeVisible()
|
||||||
expect(animate).not.toHaveBeenCalled()
|
expect(animate).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides the pill when no item is active', () => {
|
it('hides the pill when no item is active', async () => {
|
||||||
renderAt('/onboarding')
|
await renderAt('/cook-from-pantry')
|
||||||
expect(pill()).not.toBeVisible()
|
expect(pill()).not.toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('slides the pill from the old item to the new one over 420 ms on the spring curve', () => {
|
it('slides the pill from the old item to the new one over 420 ms on the spring curve', async () => {
|
||||||
renderAt('/')
|
await renderAt('/')
|
||||||
fireEvent.click(link('Quick'))
|
fireEvent.click(link('Quick'))
|
||||||
|
|
||||||
expect(animationsOf(pill())).toEqual([
|
expect(animationsOf(pill())).toEqual([
|
||||||
@@ -139,8 +141,8 @@ describe('BottomBar', () => {
|
|||||||
expect(pill().style.width).toBe('110px')
|
expect(pill().style.width).toBe('110px')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FLIPs every item that moved from its old left to its new one with the same timing', () => {
|
it('FLIPs every item that moved from its old left to its new one with the same timing', async () => {
|
||||||
renderAt('/')
|
await renderAt('/')
|
||||||
fireEvent.click(link('Quick'))
|
fireEvent.click(link('Quick'))
|
||||||
|
|
||||||
// Home shrinks from 110 to 48 px, so Plan and Discover shift 62 px left
|
// Home shrinks from 110 to 48 px, so Plan and Discover shift 62 px left
|
||||||
@@ -163,8 +165,8 @@ describe('BottomBar', () => {
|
|||||||
expect(animationsOf(link('Settings'))).toEqual([])
|
expect(animationsOf(link('Settings'))).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('fades the new label in over 220 ms ease-out after 120 ms', () => {
|
it('fades the new label in over 220 ms ease-out after 120 ms', async () => {
|
||||||
renderAt('/')
|
await renderAt('/')
|
||||||
fireEvent.click(link('Discover'))
|
fireEvent.click(link('Discover'))
|
||||||
|
|
||||||
const label = within(link('Discover')).getByText('Discover')
|
const label = within(link('Discover')).getByText('Discover')
|
||||||
@@ -180,14 +182,14 @@ describe('BottomBar', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not animate when the active item stays the same', () => {
|
it('does not animate when the active item stays the same', async () => {
|
||||||
renderAt('/plan')
|
await renderAt('/plan')
|
||||||
fireEvent.click(link('Plan'))
|
fireEvent.click(link('Plan'))
|
||||||
expect(animate).not.toHaveBeenCalled()
|
expect(animate).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('jumps the pill to the new item with no animation under reduced motion', () => {
|
it('jumps the pill to the new item with no animation under reduced motion', async () => {
|
||||||
renderAt('/', true)
|
await renderAt('/', true)
|
||||||
fireEvent.click(link('Settings'))
|
fireEvent.click(link('Settings'))
|
||||||
|
|
||||||
expect(link('Settings')).toHaveAttribute('aria-current', 'page')
|
expect(link('Settings')).toHaveAttribute('aria-current', 'page')
|
||||||
@@ -196,8 +198,8 @@ describe('BottomBar', () => {
|
|||||||
expect(pill().style.width).toBe('110px')
|
expect(pill().style.width).toBe('110px')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('re-places the pill when the viewport resizes', () => {
|
it('re-places the pill when the viewport resizes', async () => {
|
||||||
renderAt('/discover')
|
await renderAt('/discover')
|
||||||
expect(pill().style.left).toBe('102px')
|
expect(pill().style.left).toBe('102px')
|
||||||
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(120)
|
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(120)
|
||||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96)
|
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96)
|
||||||
@@ -207,8 +209,8 @@ describe('BottomBar', () => {
|
|||||||
expect(animate).not.toHaveBeenCalled()
|
expect(animate).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('starts a second slide from where the first one is on screen, not from its destination', () => {
|
it('starts a second slide from where the first one is on screen, not from its destination', async () => {
|
||||||
renderAt('/')
|
await renderAt('/')
|
||||||
fireEvent.click(link('Quick'))
|
fireEvent.click(link('Quick'))
|
||||||
const [firstPill] = runningOn(pill())
|
const [firstPill] = runningOn(pill())
|
||||||
const firstPlan = runningOn(link('Plan'))[0]!
|
const firstPlan = runningOn(link('Plan'))[0]!
|
||||||
@@ -247,8 +249,8 @@ describe('BottomBar', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('stops a running slide and places the pill on the active item when the viewport resizes', () => {
|
it('stops a running slide and places the pill on the active item when the viewport resizes', async () => {
|
||||||
renderAt('/')
|
await renderAt('/')
|
||||||
fireEvent.click(link('Quick'))
|
fireEvent.click(link('Quick'))
|
||||||
const slide = [pill(), link('Plan'), link('Discover'), link('Quick')].flatMap(runningOn)
|
const slide = [pill(), link('Plan'), link('Discover'), link('Quick')].flatMap(runningOn)
|
||||||
expect(slide).toHaveLength(4)
|
expect(slide).toHaveLength(4)
|
||||||
@@ -269,7 +271,7 @@ describe('BottomBar', () => {
|
|||||||
const ready = new Promise<void>((resolve) => (fontsReady = resolve))
|
const ready = new Promise<void>((resolve) => (fontsReady = resolve))
|
||||||
Object.defineProperty(document, 'fonts', { configurable: true, value: { ready } })
|
Object.defineProperty(document, 'fonts', { configurable: true, value: { ready } })
|
||||||
try {
|
try {
|
||||||
renderAt('/discover')
|
await renderAt('/discover')
|
||||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(124)
|
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(124)
|
||||||
fontsReady()
|
fontsReady()
|
||||||
await ready
|
await ready
|
||||||
@@ -279,8 +281,8 @@ describe('BottomBar', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('crossfades the icon colour with a .2s colour transition', () => {
|
it('crossfades the icon colour with a .2s colour transition', async () => {
|
||||||
renderAt('/')
|
await renderAt('/')
|
||||||
for (const item of within(nav()).getAllByRole('link')) {
|
for (const item of within(nav()).getAllByRole('link')) {
|
||||||
expect(declaredStyle(item).transition).toBe('color .2s')
|
expect(declaredStyle(item).transition).toBe('color .2s')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Runs the service worker's activate handler against mocked Cache Storage,
|
||||||
|
// with the Workbox precache and router stubbed out. tsconfig.sw.json checks
|
||||||
|
// this file with the worker's types, like src/sw.ts itself.
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('workbox-precaching', () => ({
|
||||||
|
PrecacheFallbackPlugin: class {},
|
||||||
|
matchPrecache: vi.fn(),
|
||||||
|
precacheAndRoute: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.mock('workbox-routing', () => ({ registerRoute: vi.fn() }))
|
||||||
|
|
||||||
|
describe('service worker activation', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes the v1 and v2 API caches and keeps the current caches', async () => {
|
||||||
|
// A household that ran the v1 and v2 workers: the v1 API cache held
|
||||||
|
// dinners with the pasted recipe_url, the v2 one recipe candidates whose
|
||||||
|
// candidate_id was the full URL. Its photos and fonts are not in the
|
||||||
|
// precache, and the current API cache may already exist.
|
||||||
|
const names = [
|
||||||
|
'mealquest-precache-v1',
|
||||||
|
'mealquest-api-v1',
|
||||||
|
'mealquest-api-v2',
|
||||||
|
'mealquest-api-v3',
|
||||||
|
'mealquest-media-v1',
|
||||||
|
'mealquest-fonts-v1',
|
||||||
|
'other-app-cache',
|
||||||
|
]
|
||||||
|
const cacheStorage = {
|
||||||
|
keys: vi.fn(async () => names),
|
||||||
|
delete: vi.fn(async () => true),
|
||||||
|
}
|
||||||
|
vi.stubGlobal('caches', cacheStorage)
|
||||||
|
vi.stubGlobal('__WB_MANIFEST', [])
|
||||||
|
await import('./sw')
|
||||||
|
|
||||||
|
const pending: Promise<unknown>[] = []
|
||||||
|
const activate = Object.assign(new Event('activate'), {
|
||||||
|
waitUntil: (promise: Promise<unknown>) => pending.push(promise),
|
||||||
|
})
|
||||||
|
self.dispatchEvent(activate)
|
||||||
|
await Promise.all(pending)
|
||||||
|
|
||||||
|
expect(pending).toHaveLength(1)
|
||||||
|
expect(cacheStorage.delete).toHaveBeenCalledWith('mealquest-api-v1')
|
||||||
|
expect(cacheStorage.delete).toHaveBeenCalledWith('mealquest-api-v2')
|
||||||
|
expect(cacheStorage.delete).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
+2
-2
@@ -18,7 +18,7 @@ import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
|
|||||||
import { createApiStrategy } from './sw/apiStrategy'
|
import { createApiStrategy } from './sw/apiStrategy'
|
||||||
import {
|
import {
|
||||||
cacheNames,
|
cacheNames,
|
||||||
cacheVersion,
|
cacheVersions,
|
||||||
routeFor,
|
routeFor,
|
||||||
staleCaches,
|
staleCaches,
|
||||||
type Route,
|
type Route,
|
||||||
@@ -29,7 +29,7 @@ declare const self: ServiceWorkerGlobalScope & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The precache becomes mealquest-precache-v1.
|
// The precache becomes mealquest-precache-v1.
|
||||||
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersion })
|
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersions.precache })
|
||||||
|
|
||||||
const day = 24 * 60 * 60
|
const day = 24 * 60 * 60
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { cacheFallbackHeader, cacheNames, markCacheFallback, routeFor, staleCaches, type Route } from './routes'
|
import { cacheFallbackHeader, cacheNames, cacheVersions, markCacheFallback, routeFor, staleCaches, type Route } from './routes'
|
||||||
|
|
||||||
const origin = 'https://mealquest.test'
|
const origin = 'https://mealquest.test'
|
||||||
|
|
||||||
@@ -146,6 +146,19 @@ describe('cache names', () => {
|
|||||||
it('all start with mealquest-', () => {
|
it('all start with mealquest-', () => {
|
||||||
for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/)
|
for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('retire only the v1 and v2 API caches', () => {
|
||||||
|
// v1 API responses held dinners with the pasted recipe_url and v2 ones
|
||||||
|
// recipe candidates whose candidate_id was the full URL; either can carry
|
||||||
|
// a personal token, and offline the worker would still serve them.
|
||||||
|
// The precache, photos and fonts keep their v1 caches.
|
||||||
|
expect(cacheNames).toEqual({
|
||||||
|
api: 'mealquest-api-v3',
|
||||||
|
media: 'mealquest-media-v1',
|
||||||
|
fonts: 'mealquest-fonts-v1',
|
||||||
|
})
|
||||||
|
expect(cacheVersions.precache).toBe('v1')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('staleCaches', () => {
|
describe('staleCaches', () => {
|
||||||
|
|||||||
+15
-5
@@ -24,14 +24,24 @@ export interface RouteRequest {
|
|||||||
mode: RequestMode
|
mode: RequestMode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bumping the version renames every cache; activate then deletes the old ones.
|
// Each cache carries its own version. Bumping one renames only that cache;
|
||||||
export const cacheVersion = 'v1'
|
// activate then deletes its old name and keeps every other cache, so saved
|
||||||
|
// photos and fonts stay available offline.
|
||||||
|
export const cacheVersions = {
|
||||||
|
precache: 'v1',
|
||||||
|
// v2 retired the v1 API cache, whose dinner responses still carried the
|
||||||
|
// pasted recipe_url; v3 retires the v2 API cache, whose recipe-candidate
|
||||||
|
// responses used the full URL as candidate_id.
|
||||||
|
api: 'v3',
|
||||||
|
media: 'v1',
|
||||||
|
fonts: 'v1',
|
||||||
|
} as const
|
||||||
export const cachePrefix = 'mealquest-'
|
export const cachePrefix = 'mealquest-'
|
||||||
|
|
||||||
export const cacheNames = {
|
export const cacheNames = {
|
||||||
api: `${cachePrefix}api-${cacheVersion}`,
|
api: `${cachePrefix}api-${cacheVersions.api}`,
|
||||||
media: `${cachePrefix}media-${cacheVersion}`,
|
media: `${cachePrefix}media-${cacheVersions.media}`,
|
||||||
fonts: `${cachePrefix}fonts-${cacheVersion}`,
|
fonts: `${cachePrefix}fonts-${cacheVersions.fonts}`,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const networkTimeoutSeconds = 3
|
export const networkTimeoutSeconds = 3
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// source: they pin the parts routes.ts cannot express — the API cache's
|
// source: they pin the parts routes.ts cannot express — the API cache's
|
||||||
// strategy, the font strategy's expiration, the font route's place ahead of
|
// strategy, the font strategy's expiration, the font route's place ahead of
|
||||||
// the precache route and network-only requests left to the browser.
|
// the precache route and network-only requests left to the browser.
|
||||||
|
// src/sw.test.ts runs the worker's activation against mocked Cache Storage.
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import source from '../sw.ts?raw'
|
import source from '../sw.ts?raw'
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const AI_SETTINGS = {
|
|||||||
export const AI_MODELS = { models: [{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }] }
|
export const AI_MODELS = { models: [{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }] }
|
||||||
|
|
||||||
// HOUSEHOLD_SETTINGS is a GET /api/v1/settings/household body: the seed
|
// HOUSEHOLD_SETTINGS is a GET /api/v1/settings/household body: the seed
|
||||||
// times in New York.
|
// times in New York, onboarding finished.
|
||||||
export const HOUSEHOLD_SETTINGS = {
|
export const HOUSEHOLD_SETTINGS = {
|
||||||
timezone: 'America/New_York',
|
timezone: 'America/New_York',
|
||||||
dinner_time: '17:30',
|
dinner_time: '17:30',
|
||||||
@@ -33,7 +33,7 @@ export const HOUSEHOLD_SETTINGS = {
|
|||||||
suggestions_time: '08:00',
|
suggestions_time: '08:00',
|
||||||
chat_autosend_dow: 6,
|
chat_autosend_dow: 6,
|
||||||
chat_autosend_time: '08:00',
|
chat_autosend_time: '08:00',
|
||||||
onboarding_completed_at: null,
|
onboarding_completed_at: '2026-10-01T15:30:00.000Z',
|
||||||
updates_seen_at: null,
|
updates_seen_at: null,
|
||||||
updated_by: null,
|
updated_by: null,
|
||||||
created_at: '2026-10-01T15:00:00.000Z',
|
created_at: '2026-10-01T15:00:00.000Z',
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// DinnerBody is a dinner as the dinners API sends it.
|
||||||
|
export type DinnerBody = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
origin: string
|
||||||
|
method: string | null
|
||||||
|
minutes_total: number | null
|
||||||
|
difficulty: string | null
|
||||||
|
cuisine: string | null
|
||||||
|
is_favorite: boolean
|
||||||
|
recipe_id: string | null
|
||||||
|
photo_url: string | null
|
||||||
|
grounding_status: string
|
||||||
|
grounding_error: string | null
|
||||||
|
updated_by: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// dinnerBody is a Regular as the server sends it: grounded with no photo,
|
||||||
|
// method or minutes unless overrides set them.
|
||||||
|
export function dinnerBody(id: string, name: string, overrides: Partial<DinnerBody> = {}): DinnerBody {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
status: 'regular',
|
||||||
|
origin: 'regular',
|
||||||
|
method: null,
|
||||||
|
minutes_total: null,
|
||||||
|
difficulty: null,
|
||||||
|
cuisine: null,
|
||||||
|
is_favorite: false,
|
||||||
|
recipe_id: null,
|
||||||
|
photo_url: null,
|
||||||
|
grounding_status: 'grounded',
|
||||||
|
grounding_error: null,
|
||||||
|
updated_by: null,
|
||||||
|
created_at: '2026-10-01T15:00:00.000Z',
|
||||||
|
updated_at: '2026-10-01T15:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -19,5 +19,5 @@
|
|||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["src", "vite.config.ts", "manifest.json"],
|
"include": ["src", "vite.config.ts", "manifest.json"],
|
||||||
"exclude": ["src/sw.ts"]
|
"exclude": ["src/sw.ts", "src/sw.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
"lib": ["ES2022", "WebWorker"],
|
"lib": ["ES2022", "WebWorker"],
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
"include": ["src/sw.ts"],
|
"include": ["src/sw.ts", "src/sw.test.ts"],
|
||||||
"exclude": []
|
"exclude": []
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user