Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 568a6ddfb8 Keep same-name add refusals and refused removal order in onboarding
CI / ci (push) Successful in 2m20s
CI / ci (pull_request) Successful in 2m17s
Step 2 keyed each refusal by the dinner's name, so a second add of the
same name still in flight replaced the first add's refusal when it was
refused too. Each attempt now keys its refusal uniquely; trying a change
again once it has been refused still clears that earlier refusal.

A refused removal was put back at the index it had in the list as it
stood when it was removed, so refusing two overlapping removals of A and
B restored B, A, C until a reread corrected it. A removal now only marks
its Regular in the cache and the list hides it; a refusal clears the
mark, so the Regular returns to its own place whatever else changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:26:27 -04:00
dev_agentandClaude Opus 5.5 4e9b1ea2f9 Keep each onboarding add and removal refusal on screen
CI / ci (push) Successful in 2m18s
CI / ci (pull_request) Successful in 2m19s
Step 2 read the refusal from the mutation hooks, which only report their
latest change: an add refused while another add was in flight was rolled
back with no message, and the same for removals. Each change now records
its own refusal, keyed by the change, so it stays shown whatever the
other changes in flight do; trying the same change again replaces it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:14:01 -04:00
dev_agentandClaude Opus 5.5 760ad9c421 Gate onboarding Next on three saved Regulars
CI / ci (push) Successful in 2m22s
CI / ci (pull_request) Successful in 2m21s
Next: notifications counted optimistic chips, so a household with two
saved Regulars could tap a third and leave step 2 before the server
refused it. Next now waits until no add is pending and three Regulars
have server ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:02:44 -04:00
dev_agentandClaude Opus 5.5 b568558875 Add onboarding steps 1 and 2 with the first-run redirect
CI / ci (push) Successful in 2m20s
CI / ci (pull_request) Successful in 2m21s
While household_settings.onboarding_completed_at is null, OnboardingGate
sends every app route to /onboarding, a full screen outside the shell.
The household settings parser now reads onboarding_completed_at from the
existing GET /api/v1/settings/household.

Step 1 welcomes the household and explains Regulars, Side quests and the
Quest log. Step 2 follows mock 1h: progress pills, the add pill with its
grape "+" (Enter also adds), Regulars chips with remove, dashed Common
picks, and "Next: notifications" enabled at 3 Regulars. Adds and removes
go through new dinners API hooks (POST /api/v1/dinners, DELETE
/api/v1/dinners/{id}) optimistically, rolled back if the server refuses.
Step 3 is a placeholder until its task lands.

Maestro task 5a43f813-c940-4f79-a2be-027644ae9e6c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:48:42 -04:00
dev_agent 423b898f54 Background grounding and recipe fix-up API (#90)
CI / ci (push) Successful in 2m24s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 16:39:10 -04:00
dev_agent 729c5bd0fe Dinners API for Regulars (#89)
CI / ci (push) Successful in 2m15s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 15:57:13 -04:00
47 changed files with 4974 additions and 148 deletions
+42 -5
View File
@@ -14,8 +14,11 @@ import (
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
@@ -75,7 +78,10 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
photos, err := recipes.NewPhotoCache(recipes.NewFetcher(), db, cfg.DataDir)
// One fetcher serves pages and photos, so its per-host spacing and
// page cache cover both.
fetcher := recipes.NewFetcher()
photos, err := recipes.NewPhotoCache(fetcher, db, cfg.DataDir)
if err != nil {
return err
}
@@ -88,6 +94,18 @@ func run(cfg config.Config, logger *slog.Logger) error {
if err != nil {
return err
}
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
return err
}
grounder, err := recipes.NewGrounder(db, search, fetcher, photos, normalizer(ai, guard))
if err != nil {
return err
}
dinnerSvc, err := dinners.NewService(db, grounder, runner, logger)
if err != nil {
return err
}
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil {
@@ -105,7 +123,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, 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 {
return err
}
@@ -121,6 +139,12 @@ func run(cfg config.Config, logger *slog.Logger) error {
if err := runner.Start(jobsCtx); err != nil {
return err
}
// With those jobs failed, a dinner still pending with no grounding job
// queued or running is failed as interrupted, before any request can
// create one.
if _, err := dinnerSvc.FailInterruptedGroundings(ctx); err != nil {
return err
}
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: router,
@@ -202,7 +226,7 @@ func seedDefaults(ctx context.Context, db *store.Store, defaultTZ string) error
return household.SeedNotificationPrefs(ctx, db)
}
// jobKinds enqueues every job kind the server runs.
// jobKinds enqueues the framework's job kinds.
type jobKinds struct {
builtins *jobs.Builtins
}
@@ -221,8 +245,9 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
return runner, kinds, nil
}
// registerJobKinds registers every job kind's handler on runner. Each
// feature's job kinds are registered here.
// registerJobKinds registers the framework's job kinds on runner. A feature
// service registers its own kinds when it is built, before the runner
// starts (dinners.NewService registers dinners.KindGroundDinner).
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
builtins, err := jobs.RegisterBuiltins(runner)
if err != nil {
@@ -231,6 +256,18 @@ func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
return &jobKinds{builtins: builtins}, nil
}
// normalizer runs normalize_recipe with the AI settings current at each
// run, sharing guard.
func normalizer(ai *aisettings.Service, guard *harness.Guard) recipes.Normalizer {
return func(ctx context.Context, scope string, in skills.RecipeText) (skills.NormalizedRecipe, error) {
cfg, err := ai.HarnessConfig(ctx, guard)
if err != nil {
return skills.NormalizedRecipe{}, err
}
return skills.NormalizeRecipe(ctx, cfg, scope, in)
}
}
// logListening logs the startup line. Only the public URL's origin is logged
// so no credential-bearing URL component can reach the log.
func logListening(logger *slog.Logger, cfg config.Config) {
+73 -2
View File
@@ -96,7 +96,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
| `recipe_steps` | recipe_id, position, text. |
| `photos` | source_url, local_path, width, height, sha256, fetched_at. |
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by. |
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), updated_by. |
| `suggestion_batches` | trigger, status, ai_run_id. |
| `suggestions` | dinner_id, batch_id, reason, cited_regular_ids (JSON), status, decided_at. |
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. |
@@ -178,6 +178,65 @@ automatic** (D-021).
restores `household.DefaultPersonaPrompt` (CTXD PROMPTS.MD §4). No
response holds the key. `Service.HarnessConfig` reads the settings for
each skill run, so a saved change applies to the next run.
- Dinners (`dinners.Service`). `POST /api/v1/dinners` adds a Regular from
exactly one of `{name}` or `{recipe_url}` (an https:// link, kept in
`recipe_url` for grounding) with status and origin `regular` and
grounding_status `pending`, answering 201 with the dinner. A link's
dinner is named from its last path segment (separators become spaces,
an extension and all-digit words are dropped: `…/12345/beef-stew/` is
"Beef stew"); a link that names nothing is 400 — add it by name. A name
is trimmed, at most 120 characters and must keep a word after
`harness.Normalize`, which sets `normalized_name`. A name
`harness.Similar` to an unarchived dinner's, or a link one already has,
is 409 `duplicate_dinner`; archived names may be added again.
`GET /api/v1/dinners?status=regular|quest_log` returns
`{dinners: [...]}` ordered by normalized name, each with `photo_url`
(`/media/photos/{photo_id}-400.jpg`, or null) and `grounding_status`;
any other status is 400. `PATCH /api/v1/dinners/{id}` edits `name`
(deduplicated against the other unarchived dinners), `method`
(`stovetop` / `oven` / `crockpot` / `grill` / `other`), `minutes_total`
(1–1440) and `difficulty` (`easy` / `medium` / `hard`); invalid values
are 400 `invalid_dinner` and change nothing. `DELETE /api/v1/dinners/{id}`
sets status `archived` and keeps the row; `POST
/api/v1/dinners/{id}/favorite` toggles `is_favorite`. Every write records
the session's subject as `updated_by`; an unknown id is 404.
- Dinner grounding. `POST /api/v1/dinners` also enqueues a `ground_dinner`
job and answers with its `grounding_job_id` without waiting for it
(`GET /api/v1/jobs/{id}` reports it); if the job cannot be enqueued it
still answers 201, with grounding_status `failed` (`grounding_error`)
and `grounding_job_id` null. The job grounds the dinner's
`recipe_url` directly with `GroundURL`, else its name with `Ground`
(see Recipe grounding), and sets grounding_status `grounded` with the
recipe's recipe_id, photo_id, method, minutes_total and difficulty, or
`failed` with `grounding_error` — also the job's failure code — one of
`no_recipe_found`, `recipe_unusable` (the pasted link), `search_not_configured`,
`search_failed`, `ai_failed`, `interrupted` (the server stopped) or
`grounding_error`. It only writes a dinner still `pending`, so a fix-up
made meanwhile wins. At boot, after the job runner fails the jobs a
previous process left running, every dinner still `pending` with no
`ground_dinner` job queued or running is set `failed` with `interrupted`,
so no dinner stays pending with nothing left to ground it.
`GET /api/v1/dinners/{id}/recipe-candidates`
searches for the dinner's name and returns `{candidates: [{candidate_id,
title, site_name, photo_url, minutes}]}`, at most 3, ranked as `Ground`
ranks them; it fetches pages but downloads no photo and runs no model,
so `photo_url` is set only for a page already stored, and `candidate_id`
is the page's URL. `POST /api/v1/dinners/{id}/ground` takes exactly one
of `{candidate_id}`, `{recipe_url}` (both grounded with `GroundURL`,
setting `grounded`) or `{manual: {ingredients[], steps[], minutes,
method}}` (1–100 ingredient lines, up to 100 steps, each non-blank and at
most 500 characters; minutes 1–1440). A manual recipe is stored as typed
— no model runs — as a `recipes` row whose source_url is
`mealquest:manual:{recipe_id}` and whose raw_jsonld is a schema.org
Recipe built from it, and sets `manual` with its method and minutes, no
photo and the dinner's difficulty unchanged. It answers 200 with the
dinner; an invalid body is 400, an unknown dinner 404, a page that cannot
ground it 422 `ungroundable` (the dinner is unchanged), recipe search not
set up 409 `not_configured`, a search or model failure 502
`search_failed` / `ai_failed` (only the reason code is logged, never the
provider's text), and grounding a candidate or link that runs past 90
seconds (fetch, photo and `normalize_recipe`, all in the request) 504
`ground_timeout`, leaving the dinner unchanged.
- `/health` — liveness.
- Everything else serves the embedded PWA with SPA fallback.
@@ -346,7 +405,10 @@ automatic** (D-021).
Rank's per-candidate reasons, photo or normalize failures). Failures
that are not the candidates' — search, model provider, database — are
returned as they are. `GroundURL(ctx, url)` does the same for one pasted
URL. `GroundMany` grounds items 3 at a time and returns one result per
URL. `Candidates(ctx, dishName, query, n)` returns the first n of the
candidates `Ground` would try, without grounding any. Search failures
wrap `recipes.ErrSearchFailed` and model provider failures
`recipes.ErrNormalizeFailed`. `GroundMany` grounds items 3 at a time and returns one result per
item, so one failure never affects its siblings. A recipe is usable only
with ingredients, an image and, if given, a positive total time.
@@ -422,6 +484,15 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
`/settings/household`, `/settings/regulars` and `/settings/calendar` come from
CTXD; the other routes are defined in the UI handoff.
First run: while `household_settings.onboarding_completed_at` (read from
`GET /api/v1/settings/household`) is null, every route redirects to
`/onboarding`. Onboarding is a full screen outside the app shell, in three
steps: the welcome (`/onboarding`), the Regulars (`/onboarding/regulars`,
mock 1h, adding and archiving through the dinners API optimistically) and
notifications (`/onboarding/notifications`). 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.
## Boundaries and gotchas
- **Secrets never reach the client** — API keys, OAuth tokens and the Chat
+31 -1
View File
@@ -106,13 +106,22 @@ func TestDinners(t *testing.T) {
Cuisine: str("Mexican"),
IsFavorite: 0,
RecipeID: str(recipe.ID),
RecipeUrl: str("https://example.com/tacos"),
PhotoID: str(photo.ID),
GroundingStatus: "grounded",
UpdatedBy: str("alex@example.com"),
CreatedAt: created,
UpdatedAt: created,
}
want := db.Dinner(create)
want := db.Dinner{
ID: create.ID, Name: create.Name, NormalizedName: create.NormalizedName,
Status: create.Status, Origin: create.Origin, Method: create.Method,
MinutesTotal: create.MinutesTotal, Difficulty: create.Difficulty,
Cuisine: create.Cuisine, IsFavorite: create.IsFavorite, RecipeID: create.RecipeID,
PhotoID: create.PhotoID, GroundingStatus: create.GroundingStatus,
UpdatedBy: create.UpdatedBy, CreatedAt: created, UpdatedAt: created,
RecipeUrl: create.RecipeUrl,
}
got, err := q.CreateDinner(ctx, create)
if err != nil {
t.Fatalf("CreateDinner: %v", err)
@@ -197,6 +206,7 @@ func TestDinners(t *testing.T) {
Cuisine: update.Cuisine, IsFavorite: 0, RecipeID: update.RecipeID,
PhotoID: update.PhotoID, GroundingStatus: update.GroundingStatus,
UpdatedBy: update.UpdatedBy, CreatedAt: created, UpdatedAt: updated,
RecipeUrl: create.RecipeUrl, // UpdateDinner leaves the recipe link
}
if got != want {
t.Fatalf("UpdateDinner returned %+v, want %+v", got, want)
@@ -227,6 +237,17 @@ func TestDinners(t *testing.T) {
_, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ID: "missing", IsFavorite: 1, UpdatedAt: updated})
mustNoRows(t, "SetDinnerFavorite missing", err)
for _, wantFav := range []int64{1, 0} {
got, err = q.ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{
ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: updated,
})
if err != nil || got.IsFavorite != wantFav || got.UpdatedBy != str("alex@example.com") {
t.Fatalf("ToggleDinnerFavorite = %+v, %v; want is_favorite %d", got, err, wantFav)
}
}
_, err = q.ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{ID: "missing", UpdatedAt: updated})
mustNoRows(t, "ToggleDinnerFavorite missing", err)
const archivedAt = "2026-10-05T12:00:00.000Z"
got, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{
ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: archivedAt,
@@ -240,6 +261,15 @@ func TestDinners(t *testing.T) {
}
assertIDs(t, "archived", listIDs("archived"), create.ID)
assertIDs(t, "tried after archive", listIDs("tried"))
unarchived, err := q.ListUnarchivedDinners(ctx)
if err != nil {
t.Fatalf("ListUnarchivedDinners: %v", err)
}
var unarchivedIDs []string
for _, d := range unarchived {
unarchivedIDs = append(unarchivedIDs, d.ID)
}
assertIDs(t, "unarchived", unarchivedIDs, other.ID, third.ID)
_, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt})
mustNoRows(t, "ArchiveDinner missing", err)
}
+276 -9
View File
@@ -16,7 +16,7 @@ SET status = 'archived',
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
`
type ArchiveDinnerParams struct {
@@ -46,6 +46,8 @@ func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (D
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
)
return i, err
}
@@ -53,14 +55,14 @@ func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (D
const createDinner = `-- name: CreateDinner :one
INSERT INTO dinners (
id, name, normalized_name, status, origin, method, minutes_total,
difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status,
updated_by, created_at, updated_at
difficulty, cuisine, is_favorite, recipe_id, recipe_url, photo_id,
grounding_status, updated_by, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?,
?, ?, ?
?, ?, ?, ?
)
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
`
type CreateDinnerParams struct {
@@ -75,6 +77,7 @@ type CreateDinnerParams struct {
Cuisine sql.NullString
IsFavorite int64
RecipeID sql.NullString
RecipeUrl sql.NullString
PhotoID sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
@@ -95,6 +98,7 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din
arg.Cuisine,
arg.IsFavorite,
arg.RecipeID,
arg.RecipeUrl,
arg.PhotoID,
arg.GroundingStatus,
arg.UpdatedBy,
@@ -119,12 +123,70 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
)
return i, err
}
const failOrphanedPendingDinnerGroundings = `-- name: FailOrphanedPendingDinnerGroundings :execrows
UPDATE dinners
SET grounding_status = 'failed',
grounding_error = ?1,
updated_at = ?2
WHERE grounding_status = 'pending'
AND NOT EXISTS (
SELECT 1 FROM jobs
WHERE jobs.kind = ?3
AND jobs.status IN ('queued', 'running')
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
)
`
type FailOrphanedPendingDinnerGroundingsParams struct {
GroundingError sql.NullString
UpdatedAt string
Kind string
}
// Fails every dinner still pending whose grounding job is no longer queued
// or running: the process stopped before the job, or before its handler
// recorded a result. Run at boot, after the job runner fails the jobs a
// previous process left running.
func (q *Queries) FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failOrphanedPendingDinnerGroundings, arg.GroundingError, arg.UpdatedAt, arg.Kind)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const failPendingDinnerGrounding = `-- name: FailPendingDinnerGrounding :execrows
UPDATE dinners
SET grounding_status = 'failed',
grounding_error = ?,
updated_at = ?
WHERE id = ? AND grounding_status = 'pending'
`
type FailPendingDinnerGroundingParams struct {
GroundingError sql.NullString
UpdatedAt string
ID string
}
// Records why the background grounding of a dinner still pending failed; a
// dinner grounded some other way meanwhile is left as it is (0 rows).
func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failPendingDinnerGrounding, arg.GroundingError, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getDinner = `-- name: GetDinner :one
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at FROM dinners
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error FROM dinners
WHERE id = ?
`
@@ -148,12 +210,120 @@ func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) {
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
)
return i, err
}
const groundDinner = `-- name: GroundDinner :one
UPDATE dinners
SET recipe_id = ?,
photo_id = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
grounding_status = ?,
grounding_error = NULL,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
`
type GroundDinnerParams struct {
RecipeID sql.NullString
PhotoID sql.NullString
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// Records a dinner's grounding (grounded or manual) and clears any
// grounding_error. An unknown id returns sql.ErrNoRows.
func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error) {
row := q.db.QueryRowContext(ctx, groundDinner,
arg.RecipeID,
arg.PhotoID,
arg.Method,
arg.MinutesTotal,
arg.Difficulty,
arg.GroundingStatus,
arg.UpdatedBy,
arg.UpdatedAt,
arg.ID,
)
var i Dinner
err := row.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
)
return i, err
}
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
UPDATE dinners
SET recipe_id = ?,
photo_id = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
grounding_status = 'grounded',
grounding_error = NULL,
updated_at = ?
WHERE id = ? AND grounding_status = 'pending'
`
type GroundPendingDinnerParams struct {
RecipeID sql.NullString
PhotoID sql.NullString
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
UpdatedAt string
ID string
}
// Records the background grounding of a dinner still pending; a dinner
// grounded some other way meanwhile is left as it is (0 rows).
func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error) {
result, err := q.db.ExecContext(ctx, groundPendingDinner,
arg.RecipeID,
arg.PhotoID,
arg.Method,
arg.MinutesTotal,
arg.Difficulty,
arg.UpdatedAt,
arg.ID,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const listDinnersByStatus = `-- name: ListDinnersByStatus :many
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at FROM dinners
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error FROM dinners
WHERE status = ?
ORDER BY normalized_name, id
`
@@ -184,6 +354,57 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listUnarchivedDinners = `-- name: ListUnarchivedDinners :many
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error FROM dinners
WHERE status <> 'archived'
ORDER BY id
`
// Every dinner a new or renamed dinner must not repeat.
func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]Dinner, error) {
rows, err := q.db.QueryContext(ctx, listUnarchivedDinners)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Dinner
for rows.Next() {
var i Dinner
if err := rows.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
); err != nil {
return nil, err
}
@@ -204,7 +425,7 @@ SET is_favorite = ?,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
`
type SetDinnerFavoriteParams struct {
@@ -240,6 +461,50 @@ func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoritePa
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
)
return i, err
}
const toggleDinnerFavorite = `-- name: ToggleDinnerFavorite :one
UPDATE dinners
SET is_favorite = 1 - is_favorite,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
`
type ToggleDinnerFavoriteParams struct {
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// An unknown id returns sql.ErrNoRows.
func (q *Queries) ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error) {
row := q.db.QueryRowContext(ctx, toggleDinnerFavorite, arg.UpdatedBy, arg.UpdatedAt, arg.ID)
var i Dinner
err := row.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
)
return i, err
}
@@ -260,7 +525,7 @@ SET name = ?,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
`
type UpdateDinnerParams struct {
@@ -316,6 +581,8 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
)
return i, err
}
+2
View File
@@ -67,6 +67,8 @@ type Dinner struct {
UpdatedBy sql.NullString
CreatedAt string
UpdatedAt string
RecipeUrl sql.NullString
GroundingError sql.NullString
}
type HouseholdSetting struct {
+21
View File
@@ -24,6 +24,9 @@ type Querier interface {
CreateAIRun(ctx context.Context, arg CreateAIRunParams) error
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
CreateJob(ctx context.Context, arg CreateJobParams) error
// A recipe the household typed in. Its source_url is a mealquest:manual:
// URN rather than a page, so it never matches a fetched page.
CreateManualRecipe(ctx context.Context, arg CreateManualRecipeParams) (Recipe, error)
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
@@ -31,6 +34,14 @@ type Querier interface {
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
DeleteRecipeSteps(ctx context.Context, recipeID string) error
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
// Fails every dinner still pending whose grounding job is no longer queued
// or running: the process stopped before the job, or before its handler
// recorded a result. Run at boot, after the job runner fails the jobs a
// previous process left running.
FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error)
// Records why the background grounding of a dinner still pending failed; a
// dinner grounded some other way meanwhile is left as it is (0 rows).
FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error)
// Fails every job left running, at boot before any worker starts.
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
// Returns sql.ErrNoRows until the row exists.
@@ -51,6 +62,12 @@ type Querier interface {
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
// Returns sql.ErrNoRows until the row exists.
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
// Records a dinner's grounding (grounded or manual) and clears any
// grounding_error. An unknown id returns sql.ErrNoRows.
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
// Records the background grounding of a dinner still pending; a dinner
// grounded some other way meanwhile is left as it is (0 rows).
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
// The planned dinners dated from from_date up to but not including
// to_date, oldest first. Days with no dinner are left out.
@@ -60,12 +77,16 @@ type Querier interface {
ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error)
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
// Every dinner a new or renamed dinner must not repeat.
ListUnarchivedDinners(ctx context.Context) ([]Dinner, error)
// Stores a partial result of a running job.
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
// An unknown id returns sql.ErrNoRows.
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
// An unknown id returns sql.ErrNoRows.
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
// An unknown id returns sql.ErrNoRows.
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
// Creates the single row (id 1) or replaces every column except id and
// created_at.
+61
View File
@@ -10,6 +10,67 @@ import (
"database/sql"
)
const createManualRecipe = `-- name: CreateManualRecipe :one
INSERT INTO recipes (
id, source_url, title, total_minutes, raw_jsonld, method,
minutes_total, fetched_at, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?,
?, ?, ?, ?
)
RETURNING id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at, photo_id, method, difficulty, minutes_total
`
type CreateManualRecipeParams struct {
ID string
SourceUrl string
Title string
TotalMinutes sql.NullInt64
RawJsonld string
Method sql.NullString
MinutesTotal sql.NullInt64
FetchedAt string
CreatedAt string
UpdatedAt string
}
// A recipe the household typed in. Its source_url is a mealquest:manual:
// URN rather than a page, so it never matches a fetched page.
func (q *Queries) CreateManualRecipe(ctx context.Context, arg CreateManualRecipeParams) (Recipe, error) {
row := q.db.QueryRowContext(ctx, createManualRecipe,
arg.ID,
arg.SourceUrl,
arg.Title,
arg.TotalMinutes,
arg.RawJsonld,
arg.Method,
arg.MinutesTotal,
arg.FetchedAt,
arg.CreatedAt,
arg.UpdatedAt,
)
var i Recipe
err := row.Scan(
&i.ID,
&i.SourceUrl,
&i.SiteName,
&i.Title,
&i.TotalMinutes,
&i.PrepMinutes,
&i.CookMinutes,
&i.Yield,
&i.RawJsonld,
&i.FetchedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.PhotoID,
&i.Method,
&i.Difficulty,
&i.MinutesTotal,
)
return i, err
}
const createRecipeIngredient = `-- name: CreateRecipeIngredient :exec
INSERT INTO recipe_ingredients (
id, recipe_id, position, raw_text, name, qty, unit, note,
+535
View File
@@ -0,0 +1,535 @@
// Package dinners keeps the household's dinners: adding Regulars by name or
// recipe link, listing Regulars and the Quest log, editing, archiving and
// favoriting, and grounding each dinner in a recipe. Names are deduplicated with the harness's dinner-name rules,
// so the Regulars the household types and the Side quests the AI suggests
// are compared the same way.
package dinners
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/url"
"slices"
"strings"
"unicode"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// Dinner statuses (the dinners.status column).
const (
StatusSuggested = "suggested"
StatusQuestLog = "quest_log"
StatusTried = "tried"
StatusRegular = "regular"
StatusArchived = "archived"
)
// Limits on what a member may enter.
const (
MaxNameChars = 120
MaxRecipeURL = 2048
MaxMinutes = 1440 // a day; a crock-pot dinner fits
photoListWidth = 400 // the stored photo size lists show
)
// Methods and Difficulties are the values the dinners table allows.
var (
Methods = []string{"stovetop", "oven", "crockpot", "grill", "other"}
Difficulties = []string{"easy", "medium", "hard"}
)
// listable are the statuses List answers for.
var listable = []string{StatusRegular, StatusQuestLog}
// ErrNotFound is returned for an unknown dinner id.
var ErrNotFound = errors.New("dinner not found")
// Dinner is a dinner as the API shows it.
type Dinner struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Origin string `json:"origin"`
Method *string `json:"method"`
MinutesTotal *int64 `json:"minutes_total"`
Difficulty *string `json:"difficulty"`
Cuisine *string `json:"cuisine"`
IsFavorite bool `json:"is_favorite"`
RecipeID *string `json:"recipe_id"`
RecipeURL *string `json:"recipe_url"`
PhotoURL *string `json:"photo_url"`
GroundingStatus string `json:"grounding_status"`
// GroundingError is why grounding failed, one of the Reason codes;
// null unless GroundingStatus is failed.
GroundingError *string `json:"grounding_error"`
UpdatedBy *string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// NewRegular is a Regular to add: exactly one of Name and RecipeURL.
type NewRegular struct {
Name *string
RecipeURL *string
}
// Patch edits a dinner. Nil fields are left as they are.
type Patch struct {
Name *string
Method *string
MinutesTotal *int
Difficulty *string
}
// Problem is one invalid field, named by its JSON key.
type Problem struct {
Field string
Reason string
}
// ValidationError lists every invalid field of a request.
type ValidationError struct {
Problems []Problem
}
func (e *ValidationError) Error() string {
parts := make([]string, len(e.Problems))
for i, p := range e.Problems {
parts[i] = p.Field + ": " + p.Reason
}
return "invalid dinner: " + strings.Join(parts, "; ")
}
func invalid(field, reason string) *ValidationError {
return &ValidationError{Problems: []Problem{{Field: field, Reason: reason}}}
}
// DuplicateError is returned when a new or renamed dinner repeats an
// unarchived one: its name is harness.Similar, or it has the same recipe
// link.
type DuplicateError struct {
Existing Dinner
}
func (e *DuplicateError) Error() string {
return fmt.Sprintf("%q is already a dinner (%s)", e.Existing.Name, e.Existing.Status)
}
// Service reads and changes dinners.
type Service struct {
st *store.Store
grounder *recipes.Grounder
logger *slog.Logger
groundJob *jobs.Kind[GroundJob]
}
// NewService returns a service over st that grounds dinners with grounder
// and registers the KindGroundDinner job on runner, which must not have
// started yet.
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("dinners: nil store")
case grounder == nil:
return nil, errors.New("dinners: nil grounder")
case runner == nil:
return nil, errors.New("dinners: nil job runner")
case logger == nil:
return nil, errors.New("dinners: nil logger")
}
s := &Service{st: st, grounder: grounder, logger: logger}
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
if err != nil {
return nil, err
}
s.groundJob = kind
return s, nil
}
// Created is a new Regular with the id of the job grounding it, which is
// nil when the job could not be enqueued and the dinner's grounding is
// failed instead.
type Created struct {
Dinner
GroundingJobID *string `json:"grounding_job_id"`
}
// CreateRegular adds a Regular named by r.Name, or by the last path segment
// of r.RecipeURL (whose recipe grounding later replaces), with status and
// origin regular and grounding_status pending, recording updatedBy, and
// enqueues the KindGroundDinner job that grounds it. It returns without
// waiting for the job. A name repeating an unarchived dinner is a
// *DuplicateError. When the job cannot be enqueued, the stored dinner is
// returned with grounding_status failed (ReasonGroundingError) and no job
// id, so the household can fix its recipe by hand.
func (s *Service) CreateRegular(ctx context.Context, r NewRegular, updatedBy string) (Created, error) {
d, err := s.createRegular(ctx, r, updatedBy)
if err != nil {
return Created{}, err
}
jobID, err := s.groundJob.Enqueue(ctx, GroundJob{DinnerID: d.ID})
if err == nil {
return Created{Dinner: d, GroundingJobID: &jobID}, nil
}
// The dinner is stored; record that it will not be grounded so it is
// not left pending with no job. The error is a database error, never a
// dependency's text.
s.logger.ErrorContext(ctx, "dinner grounding not enqueued", "dinner_id", d.ID, "error", err)
write := context.WithoutCancel(ctx)
if _, err := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
UpdatedAt: store.Now().String(),
ID: d.ID,
}); err != nil {
return Created{}, fmt.Errorf("dinners: record failed grounding of %s: %w", d.ID, err)
}
row, err := db.New(s.st.Reader).GetDinner(write, d.ID)
if err != nil {
return Created{}, fmt.Errorf("dinners: read %s: %w", d.ID, err)
}
return Created{Dinner: fromRow(row)}, nil
}
func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: create: no member to record as updated_by")
}
var name string
var recipeURL sql.NullString
switch {
case r.Name != nil && r.RecipeURL != nil:
return Dinner{}, invalid("name", "give a name or a recipe_url, not both")
case r.Name != nil:
n, err := checkName(*r.Name)
if err != nil {
return Dinner{}, err
}
name = n
case r.RecipeURL != nil:
u, err := checkRecipeURL(*r.RecipeURL)
if err != nil {
return Dinner{}, err
}
n, ok := nameFromURL(u)
if !ok {
return Dinner{}, invalid("recipe_url", "the link does not name a dinner; add it by name instead")
}
name = n
recipeURL = sql.NullString{String: u.String(), Valid: true}
default:
return Dinner{}, invalid("name", "give a name or a recipe_url")
}
var row db.Dinner
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
if err := checkUnique(ctx, q, "", name, recipeURL); err != nil {
return err
}
now := store.Now().String()
var err error
row, err = q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: StatusRegular,
Origin: "regular",
RecipeUrl: recipeURL,
GroundingStatus: "pending",
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return fmt.Errorf("dinners: create: %w", err)
}
return nil
})
if err != nil {
return Dinner{}, err
}
return fromRow(row), nil
}
// List returns the dinners with status, which must be regular or
// quest_log, ordered by normalized name.
func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
if !slices.Contains(listable, status) {
return nil, invalid("status", "must be one of "+strings.Join(listable, ", "))
}
rows, err := db.New(s.st.Reader).ListDinnersByStatus(ctx, status)
if err != nil {
return nil, fmt.Errorf("dinners: list %s: %w", status, err)
}
out := make([]Dinner, len(rows))
for i, row := range rows {
out[i] = fromRow(row)
}
return out, nil
}
// Update applies p to dinner id, recording updatedBy. An invalid patch is a
// *ValidationError and a new name repeating another unarchived dinner a
// *DuplicateError; either changes nothing.
func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: update: no member to record as updated_by")
}
name, err := p.validate()
if err != nil {
return Dinner{}, err
}
var row db.Dinner
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetDinner(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("dinners: read %s: %w", id, err)
}
params := db.UpdateDinnerParams{
ID: cur.ID,
Name: cur.Name,
NormalizedName: cur.NormalizedName,
Status: cur.Status,
Origin: cur.Origin,
Method: cur.Method,
MinutesTotal: cur.MinutesTotal,
Difficulty: cur.Difficulty,
Cuisine: cur.Cuisine,
RecipeID: cur.RecipeID,
PhotoID: cur.PhotoID,
GroundingStatus: cur.GroundingStatus,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
}
if p.Name != nil {
if err := checkUnique(ctx, q, cur.ID, name, sql.NullString{}); err != nil {
return err
}
params.Name = name
params.NormalizedName = harness.Normalize(name)
}
if p.Method != nil {
params.Method = sql.NullString{String: *p.Method, Valid: true}
}
if p.MinutesTotal != nil {
params.MinutesTotal = sql.NullInt64{Int64: int64(*p.MinutesTotal), Valid: true}
}
if p.Difficulty != nil {
params.Difficulty = sql.NullString{String: *p.Difficulty, Valid: true}
}
row, err = q.UpdateDinner(ctx, params)
if err != nil {
return fmt.Errorf("dinners: update %s: %w", id, err)
}
return nil
})
if err != nil {
return Dinner{}, err
}
return fromRow(row), nil
}
// Archive sets dinner id's status to archived, recording updatedBy. The row
// is kept, so its name still counts for Side quest dedupe.
func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: archive: no member to record as updated_by")
}
row, err := db.New(s.st.Writer).ArchiveDinner(ctx, db.ArchiveDinnerParams{
ID: id,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
})
if errors.Is(err, sql.ErrNoRows) {
return Dinner{}, ErrNotFound
}
if err != nil {
return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err)
}
return fromRow(row), nil
}
// ToggleFavorite flips dinner id's is_favorite, recording updatedBy.
func (s *Service) ToggleFavorite(ctx context.Context, id, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: favorite: no member to record as updated_by")
}
row, err := db.New(s.st.Writer).ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{
ID: id,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
})
if errors.Is(err, sql.ErrNoRows) {
return Dinner{}, ErrNotFound
}
if err != nil {
return Dinner{}, fmt.Errorf("dinners: favorite %s: %w", id, err)
}
return fromRow(row), nil
}
// validate checks every field of p and returns its trimmed name.
func (p Patch) validate() (string, error) {
var problems []Problem
var name string
if p.Name == nil && p.Method == nil && p.MinutesTotal == nil && p.Difficulty == nil {
return "", invalid("patch", "no fields to change")
}
if p.Name != nil {
n, err := checkName(*p.Name)
var verr *ValidationError
if errors.As(err, &verr) {
problems = append(problems, verr.Problems...)
}
name = n
}
if p.Method != nil && !slices.Contains(Methods, *p.Method) {
problems = append(problems, Problem{"method", "must be one of " + strings.Join(Methods, ", ")})
}
if p.MinutesTotal != nil && (*p.MinutesTotal < 1 || *p.MinutesTotal > MaxMinutes) {
problems = append(problems, Problem{"minutes_total", fmt.Sprintf("must be 1 to %d", MaxMinutes)})
}
if p.Difficulty != nil && !slices.Contains(Difficulties, *p.Difficulty) {
problems = append(problems, Problem{"difficulty", "must be one of " + strings.Join(Difficulties, ", ")})
}
if problems != nil {
return "", &ValidationError{Problems: problems}
}
return name, nil
}
// checkName returns raw trimmed, refusing a name that is empty, too long or
// has no words left once normalized (so it could not be deduplicated).
func checkName(raw string) (string, error) {
name := strings.TrimSpace(raw)
switch {
case name == "":
return "", invalid("name", "must not be empty")
case utf8.RuneCountInString(name) > MaxNameChars:
return "", invalid("name", fmt.Sprintf("must be at most %d characters", MaxNameChars))
case harness.Normalize(name) == "":
return "", invalid("name", "must name a dish, not only filler words")
}
return name, nil
}
// checkRecipeURL parses raw as an absolute https:// link to a recipe page,
// the only kind recipe grounding fetches.
func checkRecipeURL(raw string) (*url.URL, error) {
raw = strings.TrimSpace(raw)
if len(raw) > MaxRecipeURL {
return nil, invalid("recipe_url", fmt.Sprintf("must be at most %d bytes", MaxRecipeURL))
}
u, err := url.Parse(raw)
switch {
case err != nil:
return nil, invalid("recipe_url", "is not a URL")
case u.Scheme != "https" || u.Hostname() == "":
return nil, invalid("recipe_url", "must be an https:// link")
case u.User != nil:
return nil, invalid("recipe_url", "must not carry a user name or password")
}
return u, nil
}
// nameFromURL names a dinner from the last path segment of u: separators
// become spaces, a file extension and words of only digits are dropped and
// the first letter is capitalized, so
// https://example.com/recipes/12345/chicken-tikka-masala/ is
// "Chicken tikka masala". ok is false when no usable name is left.
func nameFromURL(u *url.URL) (name string, ok bool) {
segments := strings.FieldsFunc(u.Path, func(r rune) bool { return r == '/' })
if len(segments) == 0 {
return "", false
}
slug := segments[len(segments)-1]
if i := strings.LastIndexByte(slug, '.'); i > 0 {
slug = slug[:i]
}
var words []string
for _, w := range strings.FieldsFunc(slug, func(r rune) bool { return r == '-' || r == '_' || r == '+' || unicode.IsSpace(r) }) {
if strings.IndexFunc(w, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
words = append(words, strings.ToLower(w))
}
}
name = strings.Join(words, " ")
if r, size := utf8.DecodeRuneInString(name); r != utf8.RuneError {
name = string(unicode.ToUpper(r)) + name[size:]
}
if _, err := checkName(name); err != nil {
return "", false
}
return name, true
}
// checkUnique returns a *DuplicateError when name is harness.Similar to an
// unarchived dinner other than self, or recipeURL is one's recipe link.
func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) error {
rows, err := q.ListUnarchivedDinners(ctx)
if err != nil {
return fmt.Errorf("dinners: list for dedupe: %w", err)
}
for _, row := range rows {
if row.ID == self {
continue
}
if harness.Similar(name, row.Name) || (recipeURL.Valid && row.RecipeUrl == recipeURL) {
return &DuplicateError{Existing: fromRow(row)}
}
}
return nil
}
// PhotoURL is where the API serves a list-sized copy of photo id.
func PhotoURL(id string) string {
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, photoListWidth)
}
func fromRow(r db.Dinner) Dinner {
d := Dinner{
ID: r.ID,
Name: r.Name,
Status: r.Status,
Origin: r.Origin,
Method: nullString(r.Method),
Difficulty: nullString(r.Difficulty),
Cuisine: nullString(r.Cuisine),
IsFavorite: r.IsFavorite == 1,
RecipeID: nullString(r.RecipeID),
RecipeURL: nullString(r.RecipeUrl),
GroundingStatus: r.GroundingStatus,
GroundingError: nullString(r.GroundingError),
UpdatedBy: nullString(r.UpdatedBy),
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
if r.MinutesTotal.Valid {
d.MinutesTotal = &r.MinutesTotal.Int64
}
if r.PhotoID.Valid {
u := PhotoURL(r.PhotoID.String)
d.PhotoURL = &u
}
return d
}
func nullString(s sql.NullString) *string {
if !s.Valid {
return nil
}
return &s.String
}
+33
View File
@@ -0,0 +1,33 @@
package dinners
import (
"net/url"
"testing"
)
func TestNameFromURL(t *testing.T) {
t.Parallel()
tests := []struct {
link string
want string // "" means no usable name
}{
{"https://example.com/recipes/12345/chicken-tikka-masala/", "Chicken tikka masala"},
{"https://example.com/recipe/beef_stew.html", "Beef stew"},
{"https://example.com/2024/05/best-ever-chili-123", "Best ever chili"},
{"https://example.com/crème-brûlée", "Crème brûlée"},
{"https://example.com/recipes/chili?utm=1#notes", "Chili"},
{"https://example.com/", ""},
{"https://example.com/recipe/98765", ""},
{"https://example.com/the-best-easy-recipe", ""},
}
for _, tt := range tests {
u, err := url.Parse(tt.link)
if err != nil {
t.Fatal(err)
}
got, ok := nameFromURL(u)
if got != tt.want || ok != (tt.want != "") {
t.Errorf("nameFromURL(%s) = %q, %v; want %q", tt.link, got, ok, tt.want)
}
}
}
+14
View File
@@ -0,0 +1,14 @@
package dinners
import (
"testing"
"time"
)
// SetGroundTimeout sets the deadline of grounding by hand until t ends. A
// test calling it must not run in parallel.
func SetGroundTimeout(t *testing.T, d time.Duration) {
old := groundTimeout
groundTimeout = d
t.Cleanup(func() { groundTimeout = old })
}
+497
View File
@@ -0,0 +1,497 @@
package dinners
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// KindGroundDinner is the job that grounds a new Regular in a recipe.
const KindGroundDinner = "ground_dinner"
// Grounding statuses (the dinners.grounding_status column).
const (
GroundingPending = "pending"
GroundingGrounded = "grounded"
GroundingFailed = "failed"
GroundingManual = "manual"
)
// Reasons a dinner's background grounding failed, stored in
// dinners.grounding_error and as the KindGroundDinner job's failure code.
const (
// ReasonNoRecipe: no page the search found holds a usable recipe.
ReasonNoRecipe = "no_recipe_found"
// ReasonRecipeUnusable: the dinner's recipe_url holds no usable recipe.
ReasonRecipeUnusable = "recipe_unusable"
// ReasonSearchNotConfigured: recipe search is not set up.
ReasonSearchNotConfigured = "search_not_configured"
// ReasonSearchFailed: the search provider failed.
ReasonSearchFailed = "search_failed"
// ReasonAIFailed: normalize_recipe's model provider could not be built
// or called.
ReasonAIFailed = "ai_failed"
// ReasonInterrupted: the server stopped while grounding.
ReasonInterrupted = "interrupted"
// ReasonGroundingError: any other failure, such as the database's.
ReasonGroundingError = "grounding_error"
)
// Reasons are every grounding failure reason.
var Reasons = []string{
ReasonNoRecipe, ReasonRecipeUnusable, ReasonSearchNotConfigured, ReasonSearchFailed,
ReasonAIFailed, ReasonInterrupted, ReasonGroundingError,
}
// Limits on candidates and on a recipe entered by hand.
const (
MaxCandidates = 3
MaxManualLines = 100
MaxManualLineChar = 500
// manualSourcePrefix starts the source_url of a recipe entered by hand,
// followed by the recipe's id; it is never a fetchable page.
manualSourcePrefix = "mealquest:manual:"
)
// GroundTimeout bounds grounding a candidate or link by hand, which runs in
// the request: the page fetch (recipes.Timeout), its photo download and
// normalize_recipe (harness.DefaultTimeout), with some slack.
const GroundTimeout = 90 * time.Second
// groundTimeout is GroundTimeout; tests shorten it.
var groundTimeout = GroundTimeout
// ErrGroundTimeout is returned by Ground when grounding a candidate or link
// runs past GroundTimeout; the dinner is left as it was.
var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long")
// GroundJob is the KindGroundDinner payload.
type GroundJob struct {
DinnerID string `json:"dinner_id"`
}
// groundResult is the KindGroundDinner result: the dinner's grounding
// status once the job ends. It is not grounded when the dinner was
// grounded by hand before the job finished.
type groundResult struct {
DinnerID string `json:"dinner_id"`
GroundingStatus string `json:"grounding_status"`
}
func (r groundResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// runGroundJob grounds a pending dinner as its creation asked: its
// recipe_url with GroundURL, else its name with Ground. Success records
// the recipe, photo, method, minutes and difficulty with grounding_status
// grounded; failure records grounding_status failed with its Reason, which
// is also the job's failure code. A dinner no longer pending (grounded by
// hand meanwhile) is left as it is.
func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundResult) error) (groundResult, error) {
row, err := db.New(s.st.Reader).GetDinner(ctx, p.DinnerID)
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", p.DinnerID, err)
}
if row.GroundingStatus != GroundingPending {
return groundResult{DinnerID: row.ID, GroundingStatus: row.GroundingStatus}, nil
}
var gr recipes.Grounded
if row.RecipeUrl.Valid {
gr, err = s.grounder.GroundURL(ctx, row.RecipeUrl.String)
} else {
gr, err = s.grounder.Ground(ctx, row.Name, row.Name)
}
if err != nil {
reason := reasonFor(err, row.RecipeUrl.Valid)
if ctx.Err() != nil {
reason = ReasonInterrupted
}
// Only the reason is logged: the error may quote a dependency's
// response, which jobs never store, return or log.
s.logger.InfoContext(ctx, "dinner grounding failed", "dinner_id", row.ID, "reason", reason)
write := context.WithoutCancel(ctx)
if _, failErr := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{
GroundingError: sql.NullString{String: reason, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
}); failErr != nil {
return groundResult{}, fmt.Errorf("dinners: ground: record failure of %s: %w", row.ID, failErr)
}
if ctx.Err() != nil {
return groundResult{}, ctx.Err()
}
return groundResult{}, &jobs.Failure{Code: reason}
}
n, err := db.New(s.st.Writer).GroundPendingDinner(ctx, db.GroundPendingDinnerParams{
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
Method: sql.NullString{String: gr.Method, Valid: true},
MinutesTotal: nullInt(gr.MinutesTotal),
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
})
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: record %s: %w", row.ID, err)
}
if n == 0 {
cur, err := db.New(s.st.Reader).GetDinner(ctx, row.ID)
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", row.ID, err)
}
return groundResult{DinnerID: cur.ID, GroundingStatus: cur.GroundingStatus}, nil
}
return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil
}
// FailInterruptedGroundings fails, with ReasonInterrupted, every dinner
// still pending whose KindGroundDinner job is no longer queued or running:
// a previous process stopped before enqueuing the job or before its
// handler recorded a result. Call it once at boot, after the job runner's
// Start has failed the jobs that process left running, so no dinner stays
// pending with nothing left to ground it. It returns how many it failed.
func (s *Service) FailInterruptedGroundings(ctx context.Context) (int64, error) {
n, err := db.New(s.st.Writer).FailOrphanedPendingDinnerGroundings(ctx, db.FailOrphanedPendingDinnerGroundingsParams{
GroundingError: sql.NullString{String: ReasonInterrupted, Valid: true},
UpdatedAt: store.Now().String(),
Kind: KindGroundDinner,
})
if err != nil {
return 0, fmt.Errorf("dinners: fail interrupted groundings: %w", err)
}
if n > 0 {
s.logger.WarnContext(ctx, "failed dinner groundings interrupted by restart", "count", n)
}
return n, nil
}
// reasonFor is the Reason for a grounding error; fromURL says whether the
// dinner's recipe_url was grounded rather than its name.
func reasonFor(err error, fromURL bool) string {
var ungroundable *recipes.ErrUngroundable
switch {
case errors.As(err, &ungroundable) && fromURL:
return ReasonRecipeUnusable
case errors.As(err, &ungroundable):
return ReasonNoRecipe
case errors.Is(err, recipes.ErrSearchNotConfigured):
return ReasonSearchNotConfigured
case errors.Is(err, recipes.ErrSearchFailed):
return ReasonSearchFailed
case errors.Is(err, recipes.ErrNormalizeFailed):
return ReasonAIFailed
}
return ReasonGroundingError
}
// Candidate is a recipe page offered to ground a dinner in.
type Candidate struct {
// CandidateID is the page's URL; Fix.CandidateID grounds it.
CandidateID string `json:"candidate_id"`
Title string `json:"title"`
SiteName string `json:"site_name"`
// PhotoURL is the cached photo of a page already grounded, else null.
PhotoURL *string `json:"photo_url"`
Minutes *int `json:"minutes"`
}
// Candidates searches for dinner id's name and returns up to MaxCandidates
// recipe pages to ground it in, best first; the list may be empty. A
// search that fails wraps recipes.ErrSearchFailed.
func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error) {
row, err := s.get(ctx, id)
if err != nil {
return nil, err
}
offers, err := s.grounder.Candidates(ctx, row.Name, row.Name, MaxCandidates)
if err != nil {
return nil, err
}
out := make([]Candidate, len(offers))
for i, o := range offers {
out[i] = Candidate{CandidateID: o.URL, Title: o.Title, SiteName: o.SiteName, Minutes: o.Minutes}
if o.PhotoID != "" {
u := PhotoURL(o.PhotoID)
out[i].PhotoURL = &u
}
}
return out, nil
}
// Fix grounds a dinner by hand: exactly one of a candidate from
// Candidates, a pasted recipe link or a recipe typed in.
type Fix struct {
CandidateID *string
RecipeURL *string
Manual *ManualRecipe
}
// ManualRecipe is a recipe typed in by the household.
type ManualRecipe struct {
// Ingredients are the ingredient lines, at least one.
Ingredients []string
// Steps are the instructions, in order; there may be none.
Steps []string
Minutes *int
Method *string
}
// Ground grounds dinner id as f says, recording updatedBy. A candidate or
// link is grounded with recipes.Grounder.GroundURL and sets
// grounding_status grounded, with the recipe's photo, method, minutes and
// difficulty. A manual recipe is stored as typed — no model runs — and
// sets grounding_status manual with its method and minutes; it has no
// photo, and the dinner keeps its difficulty. An invalid f is a
// *ValidationError; a page that cannot ground the dinner is a
// *recipes.ErrUngroundable and changes nothing.
func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: ground: no member to record as updated_by")
}
link, manual, err := f.validate()
if err != nil {
return Dinner{}, err
}
if manual != nil {
return s.groundManual(ctx, id, *manual, updatedBy)
}
if _, err := s.get(ctx, id); err != nil {
return Dinner{}, err
}
groundCtx, cancel := context.WithTimeout(ctx, groundTimeout)
defer cancel()
gr, err := s.grounder.GroundURL(groundCtx, link)
if err != nil {
if ctx.Err() == nil && errors.Is(groundCtx.Err(), context.DeadlineExceeded) {
return Dinner{}, ErrGroundTimeout
}
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err)
}
row, err := db.New(s.st.Writer).GroundDinner(ctx, db.GroundDinnerParams{
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
Method: sql.NullString{String: gr.Method, Valid: true},
MinutesTotal: nullInt(gr.MinutesTotal),
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
GroundingStatus: GroundingGrounded,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
ID: id,
})
if errors.Is(err, sql.ErrNoRows) {
return Dinner{}, ErrNotFound
}
if err != nil {
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err)
}
return fromRow(row), nil
}
// groundManual stores m as dinner id's recipe, with its ingredients and
// steps, and records it on the dinner, all in one transaction.
func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, updatedBy string) (Dinner, error) {
var row db.Dinner
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetDinner(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("dinners: read %s: %w", id, err)
}
ld, err := json.Marshal(manualJSONLD{
Context: "https://schema.org",
Type: "Recipe",
Name: cur.Name,
Ingredients: m.Ingredients,
Instructions: m.Steps,
TotalTime: fmt.Sprintf("PT%dM", *m.Minutes),
})
if err != nil {
return fmt.Errorf("dinners: encode manual recipe: %w", err)
}
now := store.Now().String()
recipeID := store.NewID()
minutes := sql.NullInt64{Int64: int64(*m.Minutes), Valid: true}
method := sql.NullString{String: *m.Method, Valid: true}
rec, err := q.CreateManualRecipe(ctx, db.CreateManualRecipeParams{
ID: recipeID,
SourceUrl: manualSourcePrefix + recipeID,
Title: cur.Name,
TotalMinutes: minutes,
RawJsonld: string(ld),
Method: method,
MinutesTotal: minutes,
FetchedAt: now,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return fmt.Errorf("dinners: store manual recipe: %w", err)
}
for i, line := range m.Ingredients {
if err := q.CreateRecipeIngredient(ctx, db.CreateRecipeIngredientParams{
ID: store.NewID(),
RecipeID: rec.ID,
Position: int64(i),
RawText: line,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("dinners: store manual ingredient %d: %w", i+1, err)
}
}
for i, text := range m.Steps {
if err := q.CreateRecipeStep(ctx, db.CreateRecipeStepParams{
ID: store.NewID(),
RecipeID: rec.ID,
Position: int64(i),
Text: text,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("dinners: store manual step %d: %w", i+1, err)
}
}
row, err = q.GroundDinner(ctx, db.GroundDinnerParams{
RecipeID: sql.NullString{String: rec.ID, Valid: true},
Method: method,
MinutesTotal: minutes,
Difficulty: cur.Difficulty,
GroundingStatus: GroundingManual,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: now,
ID: cur.ID,
})
if err != nil {
return fmt.Errorf("dinners: ground %s: %w", id, err)
}
return nil
})
if err != nil {
return Dinner{}, err
}
return fromRow(row), nil
}
// manualJSONLD is the schema.org Recipe stored as a manual recipe's
// raw_jsonld, so it reads like a fetched page's.
type manualJSONLD struct {
Context string `json:"@context"`
Type string `json:"@type"`
Name string `json:"name"`
Ingredients []string `json:"recipeIngredient"`
Instructions []string `json:"recipeInstructions"`
TotalTime string `json:"totalTime"`
}
// validate checks f and returns the link to ground, or the manual recipe
// with its lines trimmed.
func (f Fix) validate() (link string, manual *ManualRecipe, err error) {
given := 0
for _, set := range []bool{f.CandidateID != nil, f.RecipeURL != nil, f.Manual != nil} {
if set {
given++
}
}
if given != 1 {
return "", nil, invalid("ground", "give exactly one of candidate_id, recipe_url and manual")
}
switch {
case f.CandidateID != nil:
u, err := checkRecipeURL(*f.CandidateID)
if err != nil {
return "", nil, invalid("candidate_id", "is not a candidate from recipe-candidates")
}
return u.String(), nil, nil
case f.RecipeURL != nil:
u, err := checkRecipeURL(*f.RecipeURL)
if err != nil {
return "", nil, err
}
return u.String(), nil, nil
}
m, err := f.Manual.validate()
if err != nil {
return "", nil, err
}
return "", &m, nil
}
// validate checks every field of m and returns it with its lines trimmed.
func (m ManualRecipe) validate() (ManualRecipe, error) {
var problems []Problem
ings, ingProblem := checkLines(m.Ingredients, 1)
if ingProblem != "" {
problems = append(problems, Problem{"manual.ingredients", ingProblem})
}
steps, stepProblem := checkLines(m.Steps, 0)
if stepProblem != "" {
problems = append(problems, Problem{"manual.steps", stepProblem})
}
if m.Minutes == nil || *m.Minutes < 1 || *m.Minutes > MaxMinutes {
problems = append(problems, Problem{"manual.minutes", fmt.Sprintf("must be 1 to %d", MaxMinutes)})
}
if m.Method == nil || !slices.Contains(Methods, *m.Method) {
problems = append(problems, Problem{"manual.method", "must be one of " + strings.Join(Methods, ", ")})
}
if problems != nil {
return ManualRecipe{}, &ValidationError{Problems: problems}
}
return ManualRecipe{Ingredients: ings, Steps: steps, Minutes: m.Minutes, Method: m.Method}, nil
}
// checkLines returns lines trimmed, or why they are refused: fewer than
// least, more than MaxManualLines, or one blank or over MaxManualLineChar.
func checkLines(lines []string, least int) ([]string, string) {
if len(lines) < least {
return nil, fmt.Sprintf("must have at least %d line", least)
}
if len(lines) > MaxManualLines {
return nil, fmt.Sprintf("must have at most %d lines", MaxManualLines)
}
out := make([]string, len(lines))
for i, line := range lines {
line = strings.TrimSpace(line)
switch {
case line == "":
return nil, fmt.Sprintf("line %d is blank", i+1)
case utf8.RuneCountInString(line) > MaxManualLineChar:
return nil, fmt.Sprintf("line %d is over %d characters", i+1, MaxManualLineChar)
}
out[i] = line
}
return out, ""
}
// get reads dinner id, or ErrNotFound.
func (s *Service) get(ctx context.Context, id string) (db.Dinner, error) {
row, err := db.New(s.st.Reader).GetDinner(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return db.Dinner{}, ErrNotFound
}
if err != nil {
return db.Dinner{}, fmt.Errorf("dinners: read %s: %w", id, err)
}
return row, nil
}
func nullInt(v *int) sql.NullInt64 {
if v == nil {
return sql.NullInt64{}
}
return sql.NullInt64{Int64: int64(*v), Valid: true}
}
+595
View File
@@ -0,0 +1,595 @@
package dinners_test
import (
"context"
"errors"
"io"
"log/slog"
"slices"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const member = "member-1"
// Fixture pages: two curry recipes from search and a stew at a pasted link.
const (
curryA = "https://a.example/chicken-curry"
curryB = "https://b.example/curry"
stew = "https://c.example/recipes/beef-stew"
)
type env struct {
svc *dinners.Service
st *store.Store
runner *jobs.Runner
search *recipestest.Search
pages *recipestest.Pages
model *llm.Fake
}
// newEnv returns a dinners service over a started job runner, grounding
// with fake search, pages, images and model. Searching "Chicken curry"
// finds curryA, curryB and a missing page.
func newEnv(t *testing.T) *env {
t.Helper()
e := newUnstartedEnv(t)
e.start(t)
return e
}
// newUnstartedEnv is newEnv with its job runner not yet started, so jobs
// enqueued stay queued until start.
func newUnstartedEnv(t *testing.T) *env {
t.Helper()
dataDir := t.TempDir()
st, err := store.Open(t.Context(), dataDir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
search := &recipestest.Search{Results: map[string][]recipes.Candidate{
"Chicken curry": {{URL: curryA}, {URL: "https://missing.example/x"}, {URL: curryB}},
}}
pages := &recipestest.Pages{Bodies: map[string][]byte{
curryA: recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40),
curryB: recipestest.RecipePage("Easy Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25),
stew: recipestest.RecipePage("Beef Stew", "Stew Co", "https://c.example/stew.jpg", 150),
}}
images := &recipestest.Images{JPEGs: map[string][]byte{
"https://a.example/curry.jpg": recipestest.JPEG(t, 400, 320, 1),
"https://b.example/curry.jpg": recipestest.JPEG(t, 400, 320, 2),
"https://c.example/stew.jpg": recipestest.JPEG(t, 400, 320, 3),
}}
model := &llm.Fake{}
g := recipestest.NewGrounder(t, st, dataDir, search, pages, images, model)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
svc, err := dinners.NewService(st, g, runner, logger)
if err != nil {
t.Fatal(err)
}
return &env{svc: svc, st: st, runner: runner, search: search, pages: pages, model: model}
}
// start starts the env's job runner, stopping it when the test ends.
func (e *env) start(t *testing.T) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
if err := e.runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
e.runner.Wait()
})
}
func ptr[T any](v T) *T { return &v }
// waitJob waits for job id to finish and returns it.
func (e *env) waitJob(t *testing.T, id string) jobs.Job {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
job, err := e.runner.Get(t.Context(), id)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
return job
}
if time.Now().After(deadline) {
t.Fatalf("job %s never finished; last %+v", id, job)
}
time.Sleep(5 * time.Millisecond)
}
}
func (e *env) dinner(t *testing.T, id string) db.Dinner {
t.Helper()
row, err := db.New(e.st.Reader).GetDinner(t.Context(), id)
if err != nil {
t.Fatal(err)
}
return row
}
func TestCreateGroundsInBackground(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
if c.GroundingStatus != dinners.GroundingPending || c.GroundingJobID == nil {
t.Fatalf("created = %+v, want pending with a job id", c)
}
job := e.waitJob(t, *c.GroundingJobID)
if job.Kind != dinners.KindGroundDinner || job.Status != jobs.StatusSucceeded ||
string(job.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"grounded"}` {
t.Fatalf("job = %+v (result %s)", job, job.Result)
}
row := e.dinner(t, c.ID)
if row.GroundingStatus != "grounded" || !row.RecipeID.Valid || !row.PhotoID.Valid ||
row.Method.String != "stovetop" || row.MinutesTotal.Int64 != 40 || row.Difficulty.String != "easy" || row.GroundingError.Valid {
t.Fatalf("dinner = %+v, want grounded with recipe, photo, stovetop, 40 minutes, easy", row)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != curryA || rec.PhotoID != row.PhotoID {
t.Errorf("recipe = %s photo %s, want %s photo %s", rec.SourceUrl, rec.PhotoID.String, curryA, row.PhotoID.String)
}
if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) {
t.Errorf("searched %q, want the dinner's name", got)
}
}
func TestCreateByRecipeURLGroundsThatURL(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(stew)}, member)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v", job)
}
row := e.dinner(t, c.ID)
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
if err != nil {
t.Fatal(err)
}
if row.GroundingStatus != "grounded" || rec.SourceUrl != stew || row.Method.String != "oven" || row.MinutesTotal.Int64 != 150 {
t.Fatalf("dinner = %+v recipe %s, want grounded in %s", row, rec.SourceUrl, stew)
}
if q := e.search.Queries(); len(q) != 0 {
t.Errorf("searched %q; a recipe_url is grounded directly", q)
}
}
func TestCreateGroundingFailureRecordsReason(t *testing.T) {
t.Parallel()
tests := []struct {
name string
create dinners.NewRegular
setup func(*env)
reason string
}{
{"no candidates", dinners.NewRegular{Name: ptr("Unknown dish")}, nil, dinners.ReasonNoRecipe},
{"unusable link", dinners.NewRegular{RecipeURL: ptr("https://gone.example/recipes/lost-soup")}, nil, dinners.ReasonRecipeUnusable},
{"search fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) { e.search.Err = recipes.ErrSearchRateLimited }, dinners.ReasonSearchFailed},
{"model fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) {
// One provider failure per candidate tried.
e.model.QueueGenerate(llm.GenerateResult{Err: llm.ErrAuth})
}, dinners.ReasonAIFailed},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
if tt.setup != nil {
tt.setup(e)
}
c, err := e.svc.CreateRegular(t.Context(), tt.create, member)
if err != nil {
t.Fatal(err)
}
job := e.waitJob(t, *c.GroundingJobID)
if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != tt.reason {
t.Errorf("job = %+v, want failed with %s", job, tt.reason)
}
row := e.dinner(t, c.ID)
if row.GroundingStatus != "failed" || row.GroundingError.String != tt.reason || row.RecipeID.Valid {
t.Errorf("dinner = %+v, want failed with %s", row, tt.reason)
}
})
}
}
func TestCreateReturnsBeforeGroundingCompletes(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.search.Gate = make(chan struct{})
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
// The search is held, so the job cannot have finished.
if row := e.dinner(t, c.ID); row.GroundingStatus != "pending" {
t.Fatalf("grounding_status = %s right after create, want pending", row.GroundingStatus)
}
if job, err := e.runner.Get(t.Context(), *c.GroundingJobID); err != nil || (job.Status != jobs.StatusQueued && job.Status != jobs.StatusRunning) {
t.Fatalf("job = %+v, %v; want queued or running", job, err)
}
close(e.search.Gate)
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v", job)
}
if row := e.dinner(t, c.ID); row.GroundingStatus != "grounded" {
t.Errorf("grounding_status = %s, want grounded", row.GroundingStatus)
}
}
func TestManualFixWhilePendingIsKept(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.search.Gate = make(chan struct{})
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
if _, err := e.svc.Ground(t.Context(), c.ID, dinners.Fix{Manual: &dinners.ManualRecipe{
Ingredients: []string{"1 chicken"}, Minutes: ptr(30), Method: ptr("oven"),
}}, member); err != nil {
t.Fatal(err)
}
close(e.search.Gate)
job := e.waitJob(t, *c.GroundingJobID)
if job.Status != jobs.StatusSucceeded || string(job.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"manual"}` {
t.Errorf("job = %+v (result %s), want succeeded reporting manual", job, job.Result)
}
if row := e.dinner(t, c.ID); row.GroundingStatus != "manual" || row.Method.String != "oven" {
t.Errorf("dinner = %+v, want the manual grounding kept", row)
}
}
// failedDinner creates a Regular whose background grounding fails.
func failedDinner(t *testing.T, e *env, name string) string {
t.Helper()
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr(name)}, member)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusFailed {
t.Fatalf("job = %+v, want failed", job)
}
return c.ID
}
func TestCandidates(t *testing.T) {
t.Parallel()
e := newEnv(t)
// The background grounding stores curryA with its cached photo.
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v", job)
}
got, err := e.svc.Candidates(t.Context(), c.ID)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("candidates = %+v, want the two usable pages", got)
}
first, second := got[0], got[1]
wantPhoto := dinners.PhotoURL(e.dinner(t, c.ID).PhotoID.String)
if first.CandidateID != curryA || first.Title != "Chicken Curry" || first.SiteName != "Curry House" ||
first.PhotoURL == nil || *first.PhotoURL != wantPhoto || first.Minutes == nil || *first.Minutes != 40 {
t.Errorf("first = %+v, want the stored curryA with photo %s", first, wantPhoto)
}
if second.CandidateID != curryB || second.Title != "Easy Weeknight Curry" || second.SiteName != "B Kitchen" ||
second.PhotoURL != nil || second.Minutes == nil || *second.Minutes != 25 {
t.Errorf("second = %+v, want curryB without a photo", second)
}
if _, err := e.svc.Candidates(t.Context(), "nope"); !errors.Is(err, dinners.ErrNotFound) {
t.Errorf("unknown dinner: err = %v, want ErrNotFound", err)
}
}
func TestCandidatesSearchFails(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.search.Err = recipes.ErrSearchRefused
id := failedDinner(t, e, "Chicken curry")
if _, err := e.svc.Candidates(t.Context(), id); !errors.Is(err, recipes.ErrSearchFailed) {
t.Errorf("err = %v, want ErrSearchFailed", err)
}
}
func TestCandidatesNone(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Fish tacos")
got, err := e.svc.Candidates(t.Context(), id)
if err != nil || len(got) != 0 {
t.Fatalf("no search results: candidates = %+v, %v; want none", got, err)
}
}
func TestFixViaCandidate(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(curryB)}, "member-2")
if err != nil {
t.Fatal(err)
}
if d.GroundingStatus != "grounded" || d.GroundingError != nil || d.RecipeID == nil || d.PhotoURL == nil ||
*d.Method != "stovetop" || *d.MinutesTotal != 25 || *d.Difficulty != "easy" || *d.UpdatedBy != "member-2" {
t.Fatalf("dinner = %+v", d)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), *d.RecipeID)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != curryB {
t.Errorf("recipe source = %s, want %s", rec.SourceUrl, curryB)
}
}
func TestFixViaRecipeURL(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Grandma's stew")
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr(" " + stew + " ")}, member)
if err != nil {
t.Fatal(err)
}
if d.GroundingStatus != "grounded" || d.PhotoURL == nil || *d.Method != "oven" || *d.MinutesTotal != 150 {
t.Fatalf("dinner = %+v", d)
}
// A link holding no recipe changes nothing.
before := e.dinner(t, id)
_, err = e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr("https://gone.example/x")}, member)
var ungroundable *recipes.ErrUngroundable
if !errors.As(err, &ungroundable) {
t.Fatalf("err = %v, want *recipes.ErrUngroundable", err)
}
if after := e.dinner(t, id); after != before {
t.Errorf("dinner changed by a failed fix: %+v, was %+v", after, before)
}
}
func TestFixViaManual(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Family chili")
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Difficulty: ptr("medium")}, member); err != nil {
t.Fatal(err)
}
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{Manual: &dinners.ManualRecipe{
Ingredients: []string{" 1 lb beef ", "1 can beans"},
Steps: []string{"Brown the beef.", "Simmer with the beans."},
Minutes: ptr(240),
Method: ptr("crockpot"),
}}, member)
if err != nil {
t.Fatal(err)
}
if d.GroundingStatus != "manual" || d.GroundingError != nil || d.RecipeID == nil || d.PhotoURL != nil ||
*d.Method != "crockpot" || *d.MinutesTotal != 240 || *d.Difficulty != "medium" {
t.Fatalf("dinner = %+v", d)
}
q := db.New(e.st.Reader)
rec, err := q.GetRecipe(t.Context(), *d.RecipeID)
if err != nil {
t.Fatal(err)
}
if rec.Title != "Family chili" || rec.SourceUrl != "mealquest:manual:"+rec.ID || rec.MinutesTotal.Int64 != 240 || rec.PhotoID.Valid {
t.Errorf("recipe = %+v", rec)
}
ings, err := q.ListRecipeIngredients(t.Context(), rec.ID)
if err != nil {
t.Fatal(err)
}
steps, err := q.ListRecipeSteps(t.Context(), rec.ID)
if err != nil {
t.Fatal(err)
}
if len(ings) != 2 || ings[0].RawText != "1 lb beef" || len(steps) != 2 || steps[1].Text != "Simmer with the beans." {
t.Errorf("ingredients = %+v, steps = %+v", ings, steps)
}
}
func TestFixValidation(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Pad thai")
good := func() *dinners.ManualRecipe {
return &dinners.ManualRecipe{Ingredients: []string{"noodles"}, Minutes: ptr(20), Method: ptr("stovetop")}
}
tests := map[string]dinners.Fix{
"nothing": {},
"two": {CandidateID: ptr(curryA), RecipeURL: ptr(stew)},
"candidate not URL": {CandidateID: ptr("cand-1")},
"http link": {RecipeURL: ptr("http://a.example/x")},
"no ingredients": {Manual: &dinners.ManualRecipe{Minutes: ptr(20), Method: ptr("oven")}},
"blank ingredient": {Manual: &dinners.ManualRecipe{Ingredients: []string{" "}, Minutes: ptr(20), Method: ptr("oven")}},
"blank step": {Manual: func() *dinners.ManualRecipe { m := good(); m.Steps = []string{""}; return m }()},
"no minutes": {Manual: func() *dinners.ManualRecipe { m := good(); m.Minutes = nil; return m }()},
"zero minutes": {Manual: func() *dinners.ManualRecipe { m := good(); m.Minutes = ptr(0); return m }()},
"unknown method": {Manual: func() *dinners.ManualRecipe { m := good(); m.Method = ptr("microwave"); return m }()},
"no method": {Manual: func() *dinners.ManualRecipe { m := good(); m.Method = nil; return m }()},
"too many lines": {Manual: func() *dinners.ManualRecipe { m := good(); m.Ingredients = make([]string, 101); return m }()},
"manual and a link": {RecipeURL: ptr(stew), Manual: good()},
"over-long ing line": {Manual: func() *dinners.ManualRecipe {
m := good()
m.Ingredients = []string{string(make([]byte, 501))}
return m
}()},
}
before := e.dinner(t, id)
for name, fix := range tests {
_, err := e.svc.Ground(t.Context(), id, fix, member)
var invalid *dinners.ValidationError
if !errors.As(err, &invalid) {
t.Errorf("%s: err = %v, want *ValidationError", name, err)
}
}
if after := e.dinner(t, id); after != before {
t.Errorf("dinner changed by invalid fixes: %+v", after)
}
if _, err := e.svc.Ground(t.Context(), "nope", dinners.Fix{Manual: good()}, member); !errors.Is(err, dinners.ErrNotFound) {
t.Errorf("unknown dinner, manual: err = %v, want ErrNotFound", err)
}
if _, err := e.svc.Ground(t.Context(), "nope", dinners.Fix{RecipeURL: ptr(stew)}, member); !errors.Is(err, dinners.ErrNotFound) {
t.Errorf("unknown dinner, link: err = %v, want ErrNotFound", err)
}
}
// TestRestartFailsInterruptedGrounding simulates a crash: one dinner's job
// was left running and another dinner was saved before its job was
// enqueued. At the next boot both end failed as interrupted, while a
// dinner whose job is still queued stays pending and is grounded.
func TestRestartFailsInterruptedGrounding(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.search.Gate = make(chan struct{})
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
running, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Fish pie")}, member)
if err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.ExecContext(t.Context(),
`UPDATE jobs SET status = 'running', started_at = updated_at WHERE id = ?`, *running.GroundingJobID); err != nil {
t.Fatal(err)
}
unqueued, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Lentil soup")}, member)
if err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM jobs WHERE id = ?`, *unqueued.GroundingJobID); err != nil {
t.Fatal(err)
}
queued, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
// Boot: the runner fails the running job, then the service fails the
// dinners nothing is left to ground. The search is held, so the queued
// dinner's job cannot finish meanwhile.
e.start(t)
n, err := e.svc.FailInterruptedGroundings(t.Context())
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Errorf("failed %d dinners, want 2", n)
}
if job, err := e.runner.Get(t.Context(), *running.GroundingJobID); err != nil || job.Status != jobs.StatusFailed {
t.Errorf("running job = %+v, %v; want failed", job, err)
}
for _, id := range []string{running.ID, unqueued.ID} {
if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed || row.GroundingError.String != dinners.ReasonInterrupted {
t.Errorf("dinner %s = %s (%s), want failed (interrupted)", id, row.GroundingStatus, row.GroundingError.String)
}
}
if row := e.dinner(t, queued.ID); row.GroundingStatus != dinners.GroundingPending {
t.Errorf("queued dinner = %s, want pending", row.GroundingStatus)
}
close(e.search.Gate)
if job := e.waitJob(t, *queued.GroundingJobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("queued job = %+v", job)
}
if row := e.dinner(t, queued.ID); row.GroundingStatus != dinners.GroundingGrounded {
t.Errorf("queued dinner = %s, want grounded", row.GroundingStatus)
}
}
// TestCreateEnqueueFailureReturnsFailedDinner: a dinner saved when its job
// cannot be enqueued is returned, with its id, failed and no job id.
func TestCreateEnqueueFailureReturnsFailedDinner(t *testing.T) {
t.Parallel()
e := newEnv(t)
if _, err := e.st.Writer.ExecContext(t.Context(),
`CREATE TRIGGER refuse_jobs BEFORE INSERT ON jobs BEGIN SELECT RAISE(ABORT, 'no jobs'); END`); err != nil {
t.Fatal(err)
}
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
if c.ID == "" || c.GroundingJobID != nil || c.GroundingStatus != dinners.GroundingFailed ||
c.GroundingError == nil || *c.GroundingError != dinners.ReasonGroundingError {
t.Fatalf("created = %+v, want its id, failed (grounding_error) and no job", c)
}
if row := e.dinner(t, c.ID); row.GroundingStatus != dinners.GroundingFailed {
t.Errorf("stored grounding_status = %s, want failed", row.GroundingStatus)
}
}
// TestGroundTimesOut: grounding a link by hand that runs past its deadline
// is ErrGroundTimeout and leaves the dinner as it was. It shortens the
// package deadline, so it does not run in parallel.
func TestGroundTimesOut(t *testing.T) {
dinners.SetGroundTimeout(t, 50*time.Millisecond)
e := newEnv(t)
id := failedDinner(t, e, "Beef stew")
e.pages.Gate = make(chan struct{})
defer close(e.pages.Gate)
_, err := e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr(stew)}, member)
if !errors.Is(err, dinners.ErrGroundTimeout) {
t.Fatalf("err = %v, want ErrGroundTimeout", err)
}
if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed {
t.Errorf("grounding_status = %s, want failed as before", row.GroundingStatus)
}
}
+103 -33
View File
@@ -25,6 +25,13 @@ var (
// ErrTotalTimeNotPositive rejects a recipe whose page gives a total
// time of zero minutes, which normalize_recipe refuses.
ErrTotalTimeNotPositive = errors.New("recipes: recipe total time is not positive")
// ErrSearchFailed wraps every failure to search: the search provider
// could not be built (ErrSearchNotConfigured among them) or its search
// failed.
ErrSearchFailed = errors.New("recipes: recipe search failed")
// ErrNormalizeFailed wraps a normalize_recipe failure that is not the
// recipe's: the model provider could not be built or called.
ErrNormalizeFailed = errors.New("recipes: normalize_recipe failed")
)
// ErrUngroundable is returned when no recipe page could ground a dish or a
@@ -118,43 +125,16 @@ func (g *Grounder) Ground(ctx context.Context, dishName, query string) (Grounded
if strings.TrimSpace(dishName) == "" {
return Grounded{}, errors.New("recipes: ground: dish name is empty")
}
provider, err := g.search.Provider(ctx)
found, err := g.searchRanked(ctx, dishName, query)
if err != nil {
return Grounded{}, fmt.Errorf("recipes: ground %q: %w", dishName, err)
}
cands, err := provider.Search(ctx, query, RankDepth)
if err != nil {
return Grounded{}, fmt.Errorf("recipes: ground %q: search: %w", dishName, err)
}
if len(cands) == 0 {
return Grounded{}, &ErrUngroundable{Target: dishName, Reasons: []error{ErrNoCandidates}}
}
cands = cands[:min(len(cands), RankDepth)]
stored := make(map[string]storedRecipe)
for _, c := range cands {
sr, ok, err := g.storedRecipe(ctx, c.URL)
if err != nil {
return Grounded{}, fmt.Errorf("recipes: ground %q: %w", dishName, err)
}
if ok {
stored[c.URL] = sr
}
}
ranked, rankErr := rank(ctx, dishName, cands, func(ctx context.Context, c Candidate) (Recipe, error) {
if sr, ok := stored[c.URL]; ok {
return sr.recipe, nil
}
return fetchRecipe(ctx, g.pages, c.URL)
})
if err := ctx.Err(); err != nil {
return Grounded{}, err
}
var reasons []error
if rankErr != nil {
reasons = append(reasons, rankErr)
if found.rankErr != nil {
reasons = append(reasons, found.rankErr)
}
for _, r := range ranked {
if sr, ok := stored[r.Candidate.URL]; ok {
for _, r := range found.ranked {
if sr, ok := found.stored[r.Candidate.URL]; ok {
return sr.grounded, nil
}
gr, reason, err := g.ground(ctx, r.Candidate.URL, r.Recipe)
@@ -169,6 +149,96 @@ func (g *Grounder) Ground(ctx context.Context, dishName, query string) (Grounded
return Grounded{}, &ErrUngroundable{Target: dishName, Reasons: reasons}
}
// rankedSearch is a search's candidates ranked: the ranked ones best first,
// those already stored by URL, and why any were not ranked.
type rankedSearch struct {
ranked []Ranked
stored map[string]storedRecipe
// rankErr is Rank's error, or ErrNoCandidates when the search found
// nothing.
rankErr error
}
// searchRanked searches for query and ranks the top RankDepth candidates
// for dishName as Ground describes. A search, database or context failure
// is its error; candidates that do not rank are only rankErr's.
func (g *Grounder) searchRanked(ctx context.Context, dishName, query string) (rankedSearch, error) {
provider, err := g.search.Provider(ctx)
if err != nil {
return rankedSearch{}, fmt.Errorf("%w: %w", ErrSearchFailed, err)
}
cands, err := provider.Search(ctx, query, RankDepth)
if err != nil {
return rankedSearch{}, fmt.Errorf("%w: %w", ErrSearchFailed, err)
}
if len(cands) == 0 {
return rankedSearch{rankErr: ErrNoCandidates}, nil
}
cands = cands[:min(len(cands), RankDepth)]
stored := make(map[string]storedRecipe)
for _, c := range cands {
sr, ok, err := g.storedRecipe(ctx, c.URL)
if err != nil {
return rankedSearch{}, err
}
if ok {
stored[c.URL] = sr
}
}
ranked, rankErr := rank(ctx, dishName, cands, func(ctx context.Context, c Candidate) (Recipe, error) {
if sr, ok := stored[c.URL]; ok {
return sr.recipe, nil
}
return fetchRecipe(ctx, g.pages, c.URL)
})
if err := ctx.Err(); err != nil {
return rankedSearch{}, err
}
return rankedSearch{ranked: ranked, stored: stored, rankErr: rankErr}, nil
}
// Offer is a ranked candidate offered for the household to ground a dish
// in, with GroundURL on its URL.
type Offer struct {
URL string
Title string
SiteName string
// PhotoID is the cached photo of a candidate already stored, "" for
// one that is not: a candidate's photo is cached only when it grounds.
PhotoID string
// Minutes is a stored candidate's normalized minutes_total, else its
// page's total time; nil when unknown.
Minutes *int
}
// Candidates searches for query and returns up to n of the candidates
// Ground would try for dishName, best first. It fetches and extracts pages
// but downloads no photo and runs no model. Candidates that hold no usable
// recipe are not offered, so the list may be empty; a search that fails is
// an error wrapping ErrSearchFailed.
func (g *Grounder) Candidates(ctx context.Context, dishName, query string, n int) ([]Offer, error) {
if strings.TrimSpace(dishName) == "" {
return nil, errors.New("recipes: candidates: dish name is empty")
}
if n < 1 {
return nil, fmt.Errorf("recipes: candidates: n is %d, want at least 1", n)
}
found, err := g.searchRanked(ctx, dishName, query)
if err != nil {
return nil, fmt.Errorf("recipes: candidates for %q: %w", dishName, err)
}
offers := make([]Offer, 0, min(n, len(found.ranked)))
for _, r := range found.ranked[:min(n, len(found.ranked))] {
o := Offer{URL: r.Candidate.URL, Title: r.Recipe.Title, SiteName: r.Recipe.SiteName, Minutes: r.Recipe.TotalMinutes}
if sr, ok := found.stored[r.Candidate.URL]; ok {
o.PhotoID = sr.grounded.PhotoID
o.Minutes = sr.grounded.MinutesTotal
}
offers = append(offers, o)
}
return offers, nil
}
// GroundURL grounds the recipe on the page at rawURL, a pasted URL, as
// Ground does a candidate. An already stored rawURL is returned without
// fetching it. A page that cannot be fetched, holds no usable recipe, or
@@ -344,7 +414,7 @@ func (g *Grounder) ground(ctx context.Context, sourceURL string, r Recipe) (gr G
return Grounded{}, fmt.Errorf("normalize: %w", err), nil
}
if err != nil {
return Grounded{}, nil, fmt.Errorf("normalize: %w", err)
return Grounded{}, nil, fmt.Errorf("%w: %w", ErrNormalizeFailed, err)
}
if len(norm.Ingredients) != len(r.Ingredients) {
return Grounded{}, nil, fmt.Errorf("normalize: %d ingredients for %d lines", len(norm.Ingredients), len(r.Ingredients))
+35
View File
@@ -516,3 +516,38 @@ func TestGroundManyLimitsConcurrency(t *testing.T) {
t.Errorf("max in flight %d of %d calls, want %d of 5", search.maxActive, search.callCount(), GroundConcurrency)
}
}
func TestCandidatesOffersBestFirstUpToN(t *testing.T) {
t.Parallel()
var cands []Candidate
pages := map[string][]byte{}
for i, title := range []string{"Fish Tacos", "Fish Taco Bowls", "Baja Fish Tacos", "Tacos"} {
u := fmt.Sprintf("https://site%d.example/tacos", i)
cands = append(cands, Candidate{URL: u})
pages[u] = groundPage(title, "/img/tacos.jpg")
}
search := &fakeSearch{results: map[string][]Candidate{"fish tacos": cands}}
env := newGroundEnv(t, pages, nil, search)
got, err := env.g.Candidates(t.Context(), "Fish Tacos", "fish tacos", 3)
if err != nil {
t.Fatal(err)
}
if len(got) != 3 || got[0].URL != cands[0].URL || got[0].Title != "Fish Tacos" || got[0].SiteName != "Example Kitchen" ||
got[0].PhotoID != "" || got[0].Minutes == nil || *got[0].Minutes != 40 {
t.Fatalf("Candidates = %+v, want 3 with Fish Tacos first", got)
}
if env.images.requests.Load() != 0 || len(env.llm.Requests()) != 0 {
t.Errorf("Candidates downloaded a photo or ran the model")
}
}
func TestCandidatesSearchFailureWrapsErrSearchFailed(t *testing.T) {
t.Parallel()
search := &fakeSearch{errs: map[string]error{"stew": ErrSearchRateLimited}}
env := newGroundEnv(t, nil, nil, search)
_, err := env.g.Candidates(t.Context(), "Stew", "stew", 3)
if !errors.Is(err, ErrSearchFailed) || !errors.Is(err, ErrSearchRateLimited) {
t.Errorf("err = %v, want ErrSearchFailed wrapping ErrSearchRateLimited", err)
}
}
+16 -10
View File
@@ -65,22 +65,28 @@ var photoDecoders = map[string]func([]byte) (image.Image, error){
"webp": func(b []byte) (image.Image, error) { return webp.Decode(bytes.NewReader(b)) },
}
// PhotoCache downloads photos through a Fetcher and stores each at every
// ImageFetcher fetches an image; *Fetcher satisfies it.
type ImageFetcher interface {
FetchImage(ctx context.Context, rawURL string) (Page, error)
}
// PhotoCache downloads photos through an ImageFetcher and stores each at every
// PhotoSizes length as {id}-{size}.jpg under PhotoDir, with a photos row.
// Photos are deduplicated by the SHA-256 of the downloaded bytes. A
// PhotoCache is safe for concurrent use.
type PhotoCache struct {
fetcher *Fetcher
store *store.Store
images ImageFetcher
store *store.Store
// dir is the absolute photo directory.
dir string
}
// NewPhotoCache returns a PhotoCache storing photos under dataDir, creating
// the photo directory if it is absent.
func NewPhotoCache(fetcher *Fetcher, st *store.Store, dataDir string) (*PhotoCache, error) {
if fetcher == nil {
return nil, errors.New("recipes: nil fetcher")
// NewPhotoCache returns a PhotoCache downloading images with images and
// storing photos under dataDir, creating the photo directory if it is
// absent.
func NewPhotoCache(images ImageFetcher, st *store.Store, dataDir string) (*PhotoCache, error) {
if images == nil {
return nil, errors.New("recipes: nil image fetcher")
}
if st == nil {
return nil, errors.New("recipes: nil store")
@@ -92,7 +98,7 @@ func NewPhotoCache(fetcher *Fetcher, st *store.Store, dataDir string) (*PhotoCac
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("recipes: create photo directory: %w", err)
}
return &PhotoCache{fetcher: fetcher, store: st, dir: dir}, nil
return &PhotoCache{images: images, store: st, dir: dir}, nil
}
// DownloadedPhoto is an image DownloadPhoto has downloaded and decoded,
@@ -108,7 +114,7 @@ type DownloadedPhoto struct {
// whose shorter side is under PhotoMinShortSide. It records nothing, so a
// caller can try another image before opening a transaction.
func (c *PhotoCache) DownloadPhoto(ctx context.Context, rawURL string) (DownloadedPhoto, error) {
page, err := c.fetcher.FetchImage(ctx, rawURL)
page, err := c.images.FetchImage(ctx, rawURL)
if err != nil {
return DownloadedPhoto{}, err
}
+170
View File
@@ -0,0 +1,170 @@
// Package recipestest provides fakes for tests that ground dinners outside
// package recipes: a search, page and image fetchers serving fixtures, and
// a Grounder over them that normalizes through the real harness and
// normalize_recipe skill with a scripted model.
package recipestest
import (
"bytes"
"context"
"encoding/json"
"fmt"
"image"
"image/color"
"image/jpeg"
"sync"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// Search is a recipes.SearchSource and recipes.SearchProvider answering
// each query from Results. Err, when set, fails every search. Gate, when
// set, makes every search wait for it to close.
type Search struct {
Results map[string][]recipes.Candidate
Err error
Gate chan struct{}
mu sync.Mutex
queries []string
}
func (s *Search) Provider(context.Context) (recipes.SearchProvider, error) { return s, nil }
func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Candidate, error) {
s.mu.Lock()
s.queries = append(s.queries, query)
s.mu.Unlock()
if s.Gate != nil {
select {
case <-s.Gate:
case <-ctx.Done():
return nil, ctx.Err()
}
}
if s.Err != nil {
return nil, s.Err
}
c := s.Results[query]
return c[:min(n, len(c))], nil
}
// Queries returns every query searched, in order.
func (s *Search) Queries() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.queries...)
}
// Pages is a recipes.PageFetcher serving Bodies by URL; any other URL is a
// 404. Gate, when set, makes every fetch wait for it to close.
type Pages struct {
Bodies map[string][]byte
Gate chan struct{}
}
func (p *Pages) Fetch(ctx context.Context, rawURL string) (recipes.Page, error) {
if p.Gate != nil {
select {
case <-p.Gate:
case <-ctx.Done():
return recipes.Page{}, ctx.Err()
}
}
body, ok := p.Bodies[rawURL]
if !ok {
return recipes.Page{}, &recipes.StatusError{URL: rawURL, Status: 404}
}
return recipes.Page{URL: rawURL, ContentType: "text/html", Body: body}, nil
}
// Images is a recipes.ImageFetcher serving JPEGs by URL; any other URL is
// a 404.
type Images struct {
JPEGs map[string][]byte
}
func (i *Images) FetchImage(_ context.Context, rawURL string) (recipes.Page, error) {
body, ok := i.JPEGs[rawURL]
if !ok {
return recipes.Page{}, &recipes.StatusError{URL: rawURL, Status: 404}
}
return recipes.Page{URL: rawURL, ContentType: "image/jpeg", Body: body}, nil
}
// JPEG is a w×h JPEG whose pixels depend on seed, so different seeds give
// different photos.
func JPEG(t testing.TB, w, h int, seed uint8) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
img.Set(x, y, color.RGBA{uint8(x), uint8(y), seed, 255})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, nil); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// RecipePage is a page whose schema.org Recipe is title, from site, with
// two ingredients, two steps, a total time of minutes and the image at
// imageURL.
func RecipePage(title, site, imageURL string, minutes int) []byte {
return fmt.Appendf(nil, `<!DOCTYPE html><html><head>
<meta property="og:site_name" content=%q>
<script type="application/ld+json">{
"@context": "https://schema.org",
"@type": "Recipe",
"name": %q,
"image": %q,
"recipeIngredient": ["1 onion", "2 cloves garlic, minced"],
"recipeInstructions": ["Chop the onion.", "Fry with the garlic."],
"totalTime": "PT%dM"
}</script></head><body></body></html>`, site, title, imageURL, minutes)
}
// NormalizedReply is a usable normalize_recipe reply for a RecipePage with
// method and minutes.
func NormalizedReply(method string, minutes int) llm.GenerateResult {
js := fmt.Sprintf(`{"ingredients":[
{"name":"onion","qty":"1","unit":"","note":""},
{"name":"garlic","qty":"2","unit":"cloves","note":"minced"}
],"method":%q,"difficulty":null,"minutes_total":%d}`, method, minutes)
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
}
// NewGrounder returns a Grounder storing in st, with photos cached under
// dataDir, normalizing through the real harness with model.
func NewGrounder(t testing.TB, st *store.Store, dataDir string, search recipes.SearchSource, pages recipes.PageFetcher, images recipes.ImageFetcher, model llm.Provider) *recipes.Grounder {
t.Helper()
photos, err := recipes.NewPhotoCache(images, st, dataDir)
if err != nil {
t.Fatal(err)
}
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
t.Fatal(err)
}
cfg := harness.Config{
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test"},
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return model, nil },
Store: st,
Guard: guard,
}
normalize := func(ctx context.Context, scope string, in skills.RecipeText) (skills.NormalizedRecipe, error) {
return skills.NormalizeRecipe(ctx, cfg, scope, in)
}
g, err := recipes.NewGrounder(st, search, pages, photos, normalize)
if err != nil {
t.Fatal(err)
}
return g
}
+1 -1
View File
@@ -28,7 +28,7 @@ const (
func newAIRouter(t *testing.T) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t),
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+236
View File
@@ -0,0 +1,236 @@
package server
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
)
// maxDinnerBody bounds a POST or PATCH /dinners request body.
const maxDinnerBody = 16 << 10
// maxGroundBody bounds a POST /dinners/{id}/ground body, which may carry a
// manual recipe of dinners.MaxManualLines lines of each kind.
const maxGroundBody = 512 << 10
// dinnerCreate is the POST /dinners body: a name or a recipe_url.
type dinnerCreate struct {
Name *string `json:"name"`
RecipeURL *string `json:"recipe_url"`
}
// dinnerPatch is the PATCH /dinners/{id} body. Every field is optional;
// unknown fields and null values are refused.
type dinnerPatch struct {
Name *string `json:"name"`
Method *string `json:"method"`
MinutesTotal *int `json:"minutes_total"`
Difficulty *string `json:"difficulty"`
}
type dinnerList struct {
Dinners []dinners.Dinner `json:"dinners"`
}
// dinnerGround is the POST /dinners/{id}/ground body: exactly one of
// candidate_id, recipe_url and manual.
type dinnerGround struct {
CandidateID *string `json:"candidate_id"`
RecipeURL *string `json:"recipe_url"`
Manual *dinnerManualRecipe `json:"manual"`
}
type dinnerManualRecipe struct {
Ingredients []string `json:"ingredients"`
Steps []string `json:"steps"`
Minutes *int `json:"minutes"`
Method *string `json:"method"`
}
type candidateList struct {
Candidates []dinners.Candidate `json:"candidates"`
}
// getDinners answers GET /dinners?status=regular|quest_log.
func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
list, err := svc.List(r.Context(), r.URL.Query().Get("status"))
if err != nil {
writeDinnerError(w, r, logger, "list dinners", err)
return
}
writeJSON(w, r, logger, http.StatusOK, dinnerList{Dinners: list})
}
}
// postDinner adds a Regular, recording the session's actor as updated_by,
// and answers 201 with it and its grounding_job_id without waiting for the
// job. When the job cannot be enqueued it still answers 201, with the dinner
// grounding_status failed and grounding_job_id null.
func postDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
body, err := decodePatch[dinnerCreate](http.MaxBytesReader(w, r.Body, maxDinnerBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
d, err := svc.CreateRegular(r.Context(), dinners.NewRegular(body), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "create dinner", err)
return
}
writeJSON(w, r, logger, http.StatusCreated, d)
}
}
// patchDinner edits a dinner, recording the session's actor as updated_by.
func patchDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
patch, err := decodePatch[dinnerPatch](http.MaxBytesReader(w, r.Body, maxDinnerBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
d, err := svc.Update(r.Context(), chi.URLParam(r, "id"), dinners.Patch(patch), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "update dinner", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// getDinnerCandidates answers GET /dinners/{id}/recipe-candidates with up
// to dinners.MaxCandidates recipe pages for the dinner's name.
func getDinnerCandidates(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
list, err := svc.Candidates(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeDinnerError(w, r, logger, "list recipe candidates", err)
return
}
writeJSON(w, r, logger, http.StatusOK, candidateList{Candidates: list})
}
}
// postDinnerGround grounds a dinner by hand in a candidate, a pasted link
// or a recipe typed in, recording the session's actor as updated_by.
func postDinnerGround(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
body, err := decodePatch[dinnerGround](http.MaxBytesReader(w, r.Body, maxGroundBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
fix := dinners.Fix{CandidateID: body.CandidateID, RecipeURL: body.RecipeURL}
if body.Manual != nil {
m := dinners.ManualRecipe(*body.Manual)
fix.Manual = &m
}
d, err := svc.Ground(r.Context(), chi.URLParam(r, "id"), fix, actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "ground dinner", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// deleteDinner archives a dinner; the row is never deleted.
func deleteDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
d, err := svc.Archive(r.Context(), chi.URLParam(r, "id"), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "archive dinner", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// postDinnerFavorite toggles a dinner's is_favorite.
func postDinnerFavorite(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
d, err := svc.ToggleFavorite(r.Context(), chi.URLParam(r, "id"), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "toggle dinner favorite", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// actorOrFail returns the session's actor, or answers 500 when the request
// has none, which behind requireSession is a wiring bug.
func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (auth.Actor, bool) {
actor, ok := auth.ActorFrom(r.Context())
if !ok {
logger.ErrorContext(r.Context(), r.Method+" "+r.URL.Path+" without a session")
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
return actor, ok
}
// writeDinnerError maps a dinners.Service error to its API error. A page
// that cannot ground the dinner is 422 ungroundable; recipe search not set
// up is 409 not_configured; a search or model failure is 502; grounding by
// hand that runs past dinners.GroundTimeout is 504 ground_timeout.
func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var invalid *dinners.ValidationError
var dup *dinners.DuplicateError
var ungroundable *recipes.ErrUngroundable
switch {
case errors.As(err, &ungroundable):
// The reasons quote dependency errors, so they are neither returned
// nor logged.
writeError(w, r, logger, http.StatusUnprocessableEntity, "ungroundable", "no usable recipe was found at that link")
case errors.Is(err, recipes.ErrSearchNotConfigured):
writeError(w, r, logger, http.StatusConflict, "not_configured", "recipe search is not configured")
case errors.Is(err, recipes.ErrSearchFailed):
// The error may quote the search provider's response, so only the
// reason is logged.
logger.WarnContext(r.Context(), action, "reason", "search_failed")
writeError(w, r, logger, http.StatusBadGateway, "search_failed", "recipe search failed")
case errors.Is(err, recipes.ErrNormalizeFailed):
logger.WarnContext(r.Context(), action, "reason", "normalize_recipe failed")
writeError(w, r, logger, http.StatusBadGateway, "ai_failed", "the AI provider failed to read the recipe")
case errors.Is(err, dinners.ErrGroundTimeout):
logger.WarnContext(r.Context(), action, "reason", "ground_timeout")
writeError(w, r, logger, http.StatusGatewayTimeout, "ground_timeout", "grounding the recipe took too long; try again")
case errors.As(err, &invalid):
writeError(w, r, logger, http.StatusBadRequest, "invalid_dinner", invalid.Error())
case errors.As(err, &dup):
writeError(w, r, logger, http.StatusConflict, "duplicate_dinner", dup.Error())
case errors.Is(err, dinners.ErrNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
+541
View File
@@ -0,0 +1,541 @@
package server
import (
"bytes"
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"testing/fstest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const dinnersPath = "/api/v1/dinners"
// newDinnersRouter returns a router whose dinners service runs over the
// returned store. Its job runner is not started, so grounding jobs stay
// queued.
func newDinnersRouter(t *testing.T) (http.Handler, *store.Store) {
t.Helper()
e := newDinnersEnv(t)
return e.h, e.st
}
// dinnersEnv is a router whose dinners service, job runner and grounder
// share one store, grounding with fake search, pages, images and model.
type dinnersEnv struct {
h http.Handler
st *store.Store
runner *jobs.Runner
search *recipestest.Search
pages *recipestest.Pages
images *recipestest.Images
model *llm.Fake
// logs holds everything the router and services log.
logs *logBuffer
}
// logBuffer is a bytes.Buffer safe for concurrent log writes.
type logBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *logBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *logBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
func newDinnersEnv(t *testing.T) *dinnersEnv {
t.Helper()
st := newTestStore(t)
e := &dinnersEnv{
st: st,
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
images: &recipestest.Images{JPEGs: map[string][]byte{}},
model: &llm.Fake{},
logs: &logBuffer{},
}
logger := slog.New(slog.NewTextHandler(e.logs, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
e.runner = runner
g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model)
svc, err := dinners.NewService(st, g, runner, logger)
if err != nil {
t.Fatal(err)
}
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
return e
}
// start starts the env's job runner, stopping it when the test ends.
func (e *dinnersEnv) start(t *testing.T) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
if err := e.runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
e.runner.Wait()
})
}
// callDinners sends a signed-in request with body (none when empty).
func callDinners(t *testing.T, h http.Handler, method, target, body string) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
return serve(t, h, signedIn(method, target, r))
}
// createDinner adds a Regular and returns its id.
func createDinner(t *testing.T, h http.Handler, body string) string {
t.Helper()
rec, got := callDinners(t, h, http.MethodPost, dinnersPath, body)
if rec.Code != http.StatusCreated {
t.Fatalf("POST %s = %d: %s", body, rec.Code, rec.Body)
}
return got["id"].(string)
}
// listNames returns the names GET /dinners?status=status lists, in order.
func listNames(t *testing.T, h http.Handler, status string) []string {
t.Helper()
rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"?status="+status, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET status=%s = %d: %s", status, rec.Code, rec.Body)
}
list, ok := body["dinners"].([]any)
if !ok {
t.Fatalf("GET status=%s has no dinners list: %v", status, body)
}
names := make([]string, len(list))
for i, d := range list {
names[i] = d.(map[string]any)["name"].(string)
}
return names
}
func TestCreateDinnerByName(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":" Chicken Tikka Masala "}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
}
for k, v := range map[string]any{
"name": "Chicken Tikka Masala",
"status": "regular",
"origin": "regular",
"grounding_status": "pending",
"is_favorite": false,
"recipe_url": nil,
"photo_url": nil,
"method": nil,
"minutes_total": nil,
"difficulty": nil,
"updated_by": testSession.Subject,
} {
if got, ok := body[k]; !ok || got != v {
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
}
}
var normalized, updatedBy string
if err := st.Reader.QueryRow(`SELECT normalized_name, updated_by FROM dinners WHERE id = ?`, body["id"]).Scan(&normalized, &updatedBy); err != nil {
t.Fatal(err)
}
if normalized != "chicken tikka masala" || updatedBy != testSession.Subject {
t.Errorf("stored normalized_name, updated_by = %q, %q", normalized, updatedBy)
}
}
func TestCreateDinnerByURL(t *testing.T) {
t.Parallel()
router, _ := newDinnersRouter(t)
link := "https://www.example.com/recipes/12345/beef-and-broccoli-stir-fry/"
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"`+link+`"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
}
for k, v := range map[string]any{
"name": "Beef and broccoli stir fry",
"recipe_url": link,
"status": "regular",
"origin": "regular",
"grounding_status": "pending",
"updated_by": testSession.Subject,
} {
if body[k] != v {
t.Errorf("%s = %#v, want %#v", k, body[k], v)
}
}
// The same link again is a duplicate even though its name would differ.
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Something else entirely"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("unrelated name = %d: %s", rec.Code, rec.Body)
}
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"`+link+`"}`)
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
t.Errorf("same link again = %d %v, want 409 duplicate_dinner", rec.Code, body)
}
for _, tc := range []struct{ name, body string }{
{"plain http", `{"recipe_url":"http://example.com/recipes/chili"}`},
{"not a URL", `{"recipe_url":"chili"}`},
{"credentials", `{"recipe_url":"https://me:pw@example.com/recipes/chili"}`},
{"no name in path", `{"recipe_url":"https://example.com/"}`},
{"numeric slug", `{"recipe_url":"https://example.com/recipe/98765"}`},
{"both fields", `{"name":"Chili","recipe_url":"https://example.com/recipes/chili"}`},
{"neither field", `{}`},
} {
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, tc.body)
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_dinner" {
t.Errorf("%s: %d %v, want 400 invalid_dinner", tc.name, rec.Code, body)
}
}
}
func TestCreateDinnerDuplicate(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
createDinner(t, router, `{"name":"Chicken Tikka Masala"}`)
for _, name := range []string{
"chicken tikka masala",
"Easy Chicken Tikka-Masala!",
"The Best Homemade Chicken Tikka Masala Recipe",
} {
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"`+name+`"}`)
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
t.Errorf("%q: %d %v, want 409 duplicate_dinner", name, rec.Code, body)
}
}
// A slug naming the same dish is a duplicate too.
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"https://example.com/easy-chicken-tikka-masala.html"}`)
if rec.Code != http.StatusConflict {
t.Errorf("URL slug duplicate = %d %v, want 409", rec.Code, body)
}
var n int
if err := st.Reader.QueryRow(`SELECT count(*) FROM dinners`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("dinners rows = %d, want 1", n)
}
// A different dish sharing words is not a duplicate.
createDinner(t, router, `{"name":"Chicken Tikka Wraps"}`)
}
func TestCreateDinnerRejected(t *testing.T) {
t.Parallel()
router, _ := newDinnersRouter(t)
for _, tc := range []struct{ name, body, code string }{
{"empty name", `{"name":" "}`, "invalid_dinner"},
{"filler only", `{"name":"The Best Easy Recipe"}`, "invalid_dinner"},
{"too long", `{"name":"` + strings.Repeat("x", dinners.MaxNameChars+1) + `"}`, "invalid_dinner"},
{"null name", `{"name":null}`, "invalid_request"},
{"unknown field", `{"name":"Chili","status":"quest_log"}`, "invalid_request"},
{"not an object", `"Chili"`, "invalid_request"},
} {
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, tc.body)
if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code {
t.Errorf("%s: %d %v, want 400 %s", tc.name, rec.Code, body, tc.code)
}
}
}
func TestListDinnersFiltersByStatus(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
tacos := createDinner(t, router, `{"name":"Tacos"}`)
createDinner(t, router, `{"name":"Lasagna"}`)
pho := createDinner(t, router, `{"name":"Pho"}`)
gone := createDinner(t, router, `{"name":"Meatloaf"}`)
ctx := context.Background()
if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, pho); err != nil {
t.Fatal(err)
}
if _, err := st.Writer.ExecContext(ctx,
`INSERT INTO photos (id, source_url, local_path, width, height, sha256, fetched_at, created_at, updated_at)
VALUES ('photo1', 'https://example.com/tacos.jpg', 'media/photos/photo1', 1200, 800, 'abc', 't', 't', 't')`); err != nil {
t.Fatal(err)
}
if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET photo_id = 'photo1', grounding_status = 'grounded' WHERE id = ?`, tacos); err != nil {
t.Fatal(err)
}
if rec, _ := callDinners(t, router, http.MethodDelete, dinnersPath+"/"+gone, ""); rec.Code != http.StatusOK {
t.Fatalf("DELETE = %d", rec.Code)
}
if got := strings.Join(listNames(t, router, "regular"), ","); got != "Lasagna,Tacos" {
t.Errorf("regular = %s, want Lasagna,Tacos", got)
}
if got := strings.Join(listNames(t, router, "quest_log"), ","); got != "Pho" {
t.Errorf("quest_log = %s, want Pho", got)
}
_, body := callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
for _, d := range body["dinners"].([]any) {
d := d.(map[string]any)
switch d["name"] {
case "Tacos":
if d["photo_url"] != "/media/photos/photo1-400.jpg" || d["grounding_status"] != "grounded" {
t.Errorf("Tacos photo_url, grounding_status = %v, %v", d["photo_url"], d["grounding_status"])
}
case "Lasagna":
if d["photo_url"] != nil || d["grounding_status"] != "pending" {
t.Errorf("Lasagna photo_url, grounding_status = %v, %v", d["photo_url"], d["grounding_status"])
}
}
}
// An empty list is [] rather than null.
empty, _ := newDinnersRouter(t)
rec, _ := callDinners(t, empty, http.MethodGet, dinnersPath+"?status=regular", "")
if got := rec.Body.String(); got != `{"dinners":[]}` {
t.Errorf("empty list = %s", got)
}
for _, q := range []string{"", "?status=archived", "?status=suggested", "?status=bogus"} {
rec, body := callDinners(t, router, http.MethodGet, dinnersPath+q, "")
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_dinner" {
t.Errorf("GET %q = %d %v, want 400 invalid_dinner", q, rec.Code, body)
}
}
}
func TestPatchDinner(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
id := createDinner(t, router, `{"name":"Chili"}`)
createDinner(t, router, `{"name":"Pot Roast"}`)
if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id,
`{"name":"White Chicken Chili","method":"crockpot","minutes_total":480,"difficulty":"easy"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d: %s", rec.Code, rec.Body)
}
for k, v := range map[string]any{
"name": "White Chicken Chili",
"method": "crockpot",
"minutes_total": float64(480),
"difficulty": "easy",
"status": "regular",
"updated_by": testSession.Subject,
} {
if body[k] != v {
t.Errorf("%s = %#v, want %#v", k, body[k], v)
}
}
var normalized string
if err := st.Reader.QueryRow(`SELECT normalized_name FROM dinners WHERE id = ?`, id).Scan(&normalized); err != nil {
t.Fatal(err)
}
if normalized != "white chicken chili" {
t.Errorf("normalized_name = %q", normalized)
}
// A partial patch leaves the other fields.
rec, body = callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"difficulty":"medium"}`)
if rec.Code != http.StatusOK || body["method"] != "crockpot" || body["difficulty"] != "medium" || body["name"] != "White Chicken Chili" {
t.Errorf("partial patch = %d %v", rec.Code, body)
}
// Renaming to its own name, respelled, is not a duplicate of itself.
if rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"name":"Easy white chicken chili"}`); rec.Code != http.StatusOK {
t.Errorf("self rename = %d %v", rec.Code, body)
}
// Renaming onto another dinner is.
rec, body = callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"name":"The Best Pot Roast"}`)
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
t.Errorf("rename onto Pot Roast = %d %v, want 409", rec.Code, body)
}
}
func TestPatchDinnerValidation(t *testing.T) {
t.Parallel()
router, _ := newDinnersRouter(t)
id := createDinner(t, router, `{"name":"Chili"}`)
for _, tc := range []struct {
name, body, code, in string
}{
{"bad method", `{"method":"microwave"}`, "invalid_dinner", "method"},
{"crock pot spelling", `{"method":"crock pot"}`, "invalid_dinner", "method"},
{"bad difficulty", `{"difficulty":"expert"}`, "invalid_dinner", "difficulty"},
{"zero minutes", `{"minutes_total":0}`, "invalid_dinner", "minutes_total"},
{"negative minutes", `{"minutes_total":-5}`, "invalid_dinner", "minutes_total"},
{"too many minutes", `{"minutes_total":1441}`, "invalid_dinner", "minutes_total"},
{"empty name", `{"name":""}`, "invalid_dinner", "name"},
{"every problem", `{"method":"x","difficulty":"y","minutes_total":0}`, "invalid_dinner", "method: must be one of stovetop, oven, crockpot, grill, other; minutes_total"},
{"empty patch", `{}`, "invalid_dinner", "no fields"},
{"fractional minutes", `{"minutes_total":30.5}`, "invalid_request", "minutes_total"},
{"string minutes", `{"minutes_total":"30"}`, "invalid_request", "minutes_total"},
{"null method", `{"method":null}`, "invalid_request", "method"},
{"read-only field", `{"status":"archived"}`, "invalid_request", "status"},
{"favorite field", `{"is_favorite":true}`, "invalid_request", "is_favorite"},
} {
rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, tc.body)
msg, _ := body["error"].(map[string]any)["message"].(string)
if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code || !strings.Contains(msg, tc.in) {
t.Errorf("%s: %d %v, want 400 %s containing %q", tc.name, rec.Code, body, tc.code, tc.in)
}
}
_, body := callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
d := body["dinners"].([]any)[0].(map[string]any)
if d["method"] != nil || d["difficulty"] != nil || d["minutes_total"] != nil || d["name"] != "Chili" {
t.Errorf("a rejected patch changed the dinner: %v", d)
}
rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/nope", `{"method":"oven"}`)
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("unknown id = %d %v, want 404", rec.Code, body)
}
}
func TestDeleteDinnerArchives(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
id := createDinner(t, router, `{"name":"Shepherd's Pie"}`)
if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, router, http.MethodDelete, dinnersPath+"/"+id, "")
if rec.Code != http.StatusOK || body["status"] != "archived" || body["updated_by"] != testSession.Subject {
t.Fatalf("DELETE = %d %v", rec.Code, body)
}
var status, name, updatedBy string
if err := st.Reader.QueryRow(`SELECT status, name, updated_by FROM dinners WHERE id = ?`, id).Scan(&status, &name, &updatedBy); err != nil {
t.Fatalf("archived row is gone: %v", err)
}
if status != "archived" || name != "Shepherd's Pie" || updatedBy != testSession.Subject {
t.Errorf("row = %s %q %s", status, name, updatedBy)
}
if got := listNames(t, router, "regular"); len(got) != 0 {
t.Errorf("regular after archive = %v", got)
}
// An archived name may be added again.
createDinner(t, router, `{"name":"Shepherds Pie"}`)
rec, body = callDinners(t, router, http.MethodDelete, dinnersPath+"/nope", "")
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("unknown id = %d %v, want 404", rec.Code, body)
}
}
func TestFavoriteDinnerToggles(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
id := createDinner(t, router, `{"name":"Fish Tacos"}`)
if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil {
t.Fatal(err)
}
for _, want := range []bool{true, false, true} {
rec, body := callDinners(t, router, http.MethodPost, dinnersPath+"/"+id+"/favorite", "")
if rec.Code != http.StatusOK || body["is_favorite"] != want || body["updated_by"] != testSession.Subject {
t.Fatalf("favorite = %d %v, want is_favorite %v", rec.Code, body, want)
}
}
var fav int
if err := st.Reader.QueryRow(`SELECT is_favorite FROM dinners WHERE id = ?`, id).Scan(&fav); err != nil {
t.Fatal(err)
}
if fav != 1 {
t.Errorf("stored is_favorite = %d, want 1", fav)
}
rec, body := callDinners(t, router, http.MethodPost, dinnersPath+"/nope/favorite", "")
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("unknown id = %d %v, want 404", rec.Code, body)
}
}
// TestDinnerWritesRequireCSRF sends every dinners write without the CSRF
// header and with a wrong one; each is refused and changes nothing.
func TestDinnerWritesRequireCSRF(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
id := createDinner(t, router, `{"name":"Chili"}`)
var before string
snapshot := `SELECT group_concat(id || name || status || is_favorite || coalesce(method, '') || updated_at, '|') FROM dinners`
if err := st.Reader.QueryRow(snapshot).Scan(&before); err != nil {
t.Fatal(err)
}
writes := []struct{ method, target, body string }{
{http.MethodPost, dinnersPath, `{"name":"Lasagna"}`},
{http.MethodPatch, dinnersPath + "/" + id, `{"method":"oven"}`},
{http.MethodDelete, dinnersPath + "/" + id, ""},
{http.MethodPost, dinnersPath + "/" + id + "/favorite", ""},
}
for _, w := range writes {
for _, header := range []*string{nil, ptr("wrong-token")} {
req := httptest.NewRequest(w.method, w.target, strings.NewReader(w.body))
req.AddCookie(validSessionCookie)
if header != nil {
req.Header.Set(CSRFHeader, *header)
}
rec, body := serve(t, router, req)
if rec.Code != http.StatusForbidden || errorCode(body) != "csrf" {
t.Errorf("%s %s (header %v) = %d %v, want 403 csrf", w.method, w.target, header != nil, rec.Code, body)
}
}
}
var after string
if err := st.Reader.QueryRow(snapshot).Scan(&after); err != nil {
t.Fatal(err)
}
if after != before {
t.Errorf("refused writes changed dinners:\n before %s\n after %s", before, after)
}
// Without a session, the list is 401 too.
req := httptest.NewRequest(http.MethodGet, dinnersPath+"?status=regular", nil)
if rec, body := serve(t, router, req); rec.Code != http.StatusUnauthorized || errorCode(body) != "unauthenticated" {
t.Errorf("GET without a session = %d %v, want 401", rec.Code, body)
}
}
func ptr[T any](v T) *T { return &v }
+303
View File
@@ -0,0 +1,303 @@
package server
import (
"fmt"
"net/http"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
)
// Fixture pages: two curries found by searching "Chicken curry" and a stew
// at a pasted link.
const (
curryPage = "https://a.example/chicken-curry"
curry2Page = "https://b.example/curry"
stewPage = "https://c.example/recipes/beef-stew"
)
// withRecipes adds the fixture pages, their photos and the search for
// "Chicken curry" to e.
func (e *dinnersEnv) withRecipes(t *testing.T) *dinnersEnv {
t.Helper()
e.search.Results["Chicken curry"] = []recipes.Candidate{{URL: curryPage}, {URL: curry2Page}}
e.pages.Bodies[curryPage] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
e.pages.Bodies[curry2Page] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25)
e.pages.Bodies[stewPage] = recipestest.RecipePage("Beef Stew", "Stew Co", "https://c.example/stew.jpg", 150)
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
e.images.JPEGs["https://c.example/stew.jpg"] = recipestest.JPEG(t, 400, 320, 3)
return e
}
// waitJob polls GET /jobs/{id} until the job ends and returns its body.
func waitJob(t *testing.T, h http.Handler, id string) map[string]any {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
rec, body := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET job = %d: %s", rec.Code, rec.Body)
}
if s := body["status"]; s == "succeeded" || s == "failed" {
return body
}
if time.Now().After(deadline) {
t.Fatalf("job %s never finished; last %v", id, body)
}
time.Sleep(5 * time.Millisecond)
}
}
// listedDinner returns dinner id as GET /dinners?status=regular lists it.
func listedDinner(t *testing.T, h http.Handler, id string) map[string]any {
t.Helper()
rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"?status=regular", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET dinners = %d: %s", rec.Code, rec.Body)
}
for _, d := range body["dinners"].([]any) {
if d := d.(map[string]any); d["id"] == id {
return d
}
}
t.Fatalf("dinner %s not listed", id)
return nil
}
func TestPostDinnerReturnsBeforeGroundingCompletes(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
e.search.Gate = make(chan struct{})
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
e.start(t)
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
jobID, _ := created["grounding_job_id"].(string)
if created["grounding_status"] != "pending" || created["grounding_error"] != nil || jobID == "" {
t.Fatalf("created = %v, want pending with a grounding_job_id", created)
}
// The search is held, so grounding cannot have completed.
_, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+jobID, "")
if s := job["status"]; s != "queued" && s != "running" {
t.Fatalf("job right after POST = %v, want queued or running", job)
}
if d := listedDinner(t, e.h, created["id"].(string)); d["grounding_status"] != "pending" {
t.Fatalf("dinner right after POST = %v, want pending", d)
}
close(e.search.Gate)
job = waitJob(t, e.h, jobID)
if job["status"] != "succeeded" || job["kind"] != "ground_dinner" {
t.Fatalf("job = %v, want ground_dinner succeeded", job)
}
d := listedDinner(t, e.h, created["id"].(string))
photo, _ := d["photo_url"].(string)
if d["grounding_status"] != "grounded" || d["recipe_id"] == nil || !strings.HasSuffix(photo, "-400.jpg") ||
d["method"] != "stovetop" || d["minutes_total"] != 40.0 || d["difficulty"] != "easy" || d["grounding_error"] != nil {
t.Errorf("dinner = %v, want grounded with recipe, photo, method, minutes and difficulty", d)
}
}
func TestPostDinnerGroundingFailure(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
e.start(t)
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Nothing matches this"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
job := waitJob(t, e.h, created["grounding_job_id"].(string))
if job["status"] != "failed" || job["error"] != "no_recipe_found" {
t.Errorf("job = %v, want failed with no_recipe_found", job)
}
d := listedDinner(t, e.h, created["id"].(string))
if d["grounding_status"] != "failed" || d["grounding_error"] != "no_recipe_found" || d["recipe_id"] != nil {
t.Errorf("dinner = %v, want failed with no_recipe_found", d)
}
}
func TestGetRecipeCandidates(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
id := createDinner(t, e.h, `{"name":"Chicken curry"}`) // runner not started: stays pending
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
}
list, _ := body["candidates"].([]any)
if len(list) != 2 {
t.Fatalf("candidates = %v, want 2", body)
}
first := list[0].(map[string]any)
want := map[string]any{
"candidate_id": curryPage,
"title": "Chicken Curry",
"site_name": "Curry House",
"photo_url": nil,
"minutes": 40.0,
}
if len(first) != len(want) {
t.Errorf("candidate keys = %v, want exactly %v", first, want)
}
for k, v := range want {
if got, ok := first[k]; !ok || got != v {
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
}
}
rec, body = callDinners(t, e.h, http.MethodGet, dinnersPath+"/nope/recipe-candidates", "")
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("unknown dinner = %d %v, want 404", rec.Code, body)
}
}
func TestGetRecipeCandidatesSearchFails(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
// The provider's text must reach neither the response nor the logs.
const leak = "provider-said-token-abc123"
e.search.Err = fmt.Errorf("%w: %s", recipes.ErrSearchRefused, leak)
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
if rec.Code != http.StatusBadGateway || errorCode(body) != "search_failed" {
t.Errorf("GET = %d %v, want 502 search_failed", rec.Code, body)
}
if strings.Contains(rec.Body.String(), leak) {
t.Errorf("response quotes the provider: %s", rec.Body)
}
logs := e.logs.String()
if strings.Contains(logs, leak) || !strings.Contains(logs, "reason=search_failed") {
t.Errorf("logs = %q, want reason=search_failed and no provider text", logs)
}
}
func TestPostGroundViaCandidate(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"candidate_id":"`+curry2Page+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
photo, _ := d["photo_url"].(string)
if d["grounding_status"] != "grounded" || d["recipe_id"] == nil || photo == "" ||
d["method"] != "stovetop" || d["minutes_total"] != 25.0 || d["difficulty"] != "easy" || d["updated_by"] != testSession.Subject {
t.Errorf("dinner = %v", d)
}
// The candidate is now stored, so it is offered with its photo.
_, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
for _, c := range body["candidates"].([]any) {
if c := c.(map[string]any); c["candidate_id"] == curry2Page && c["photo_url"] != photo {
t.Errorf("grounded candidate photo_url = %v, want %s", c["photo_url"], photo)
}
}
}
func TestPostGroundViaRecipeURL(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+stewPage+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
if d["grounding_status"] != "grounded" || d["photo_url"] == nil || d["method"] != "oven" || d["minutes_total"] != 150.0 {
t.Errorf("dinner = %v", d)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"https://gone.example/soup"}`)
if rec.Code != http.StatusUnprocessableEntity || errorCode(body) != "ungroundable" || strings.Contains(rec.Body.String(), "gone.example") {
t.Errorf("unusable link = %d %v, want 422 ungroundable", rec.Code, body)
}
}
func TestPostGroundViaManual(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Family chili"}`)
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`)
if rec.Code != http.StatusOK {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
if d["grounding_status"] != "manual" || d["recipe_id"] == nil || d["photo_url"] != nil ||
d["method"] != "crockpot" || d["minutes_total"] != 240.0 || d["grounding_error"] != nil {
t.Errorf("dinner = %v", d)
}
var lines int
if err := e.st.Reader.QueryRow(`SELECT count(*) FROM recipe_ingredients WHERE recipe_id = ?`, d["recipe_id"]).Scan(&lines); err != nil {
t.Fatal(err)
}
if lines != 2 {
t.Errorf("stored %d ingredient lines, want 2", lines)
}
}
func TestPostGroundRejects(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Pad thai"}`)
path := dinnersPath + "/" + id + "/ground"
for _, tc := range []struct{ name, body, code string }{
{"empty", `{}`, "invalid_dinner"},
{"two kinds", `{"candidate_id":"https://a.example/x","recipe_url":"https://a.example/x"}`, "invalid_dinner"},
{"candidate not a link", `{"candidate_id":"c1"}`, "invalid_dinner"},
{"http link", `{"recipe_url":"http://a.example/x"}`, "invalid_dinner"},
{"null", `{"recipe_url":null}`, "invalid_request"},
{"unknown field", `{"url":"https://a.example/x"}`, "invalid_request"},
{"unknown manual field", `{"manual":{"ingredients":["x"],"minutes":5,"method":"oven","serves":4}}`, "invalid_request"},
{"manual without method", `{"manual":{"ingredients":["x"],"minutes":5}}`, "invalid_dinner"},
{"manual without ingredients", `{"manual":{"ingredients":[],"minutes":5,"method":"oven"}}`, "invalid_dinner"},
{"manual minutes too many", `{"manual":{"ingredients":["x"],"minutes":5000,"method":"oven"}}`, "invalid_dinner"},
{"not an object", `[]`, "invalid_request"},
} {
rec, body := callDinners(t, e.h, http.MethodPost, path, tc.body)
if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code {
t.Errorf("%s: %d %v, want 400 %s", tc.name, rec.Code, body, tc.code)
}
}
if d := listedDinner(t, e.h, id); d["grounding_status"] != "pending" {
t.Errorf("dinner after rejected fixes = %v, want unchanged", d)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/nope/ground",
`{"manual":{"ingredients":["x"],"minutes":5,"method":"oven"}}`)
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("unknown dinner = %d %v, want 404", rec.Code, body)
}
}
func TestPostDinnerEnqueueFailureAnswersFailedDinner(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
if _, err := e.st.Writer.ExecContext(t.Context(),
`CREATE TRIGGER refuse_jobs BEFORE INSERT ON jobs BEGIN SELECT RAISE(ABORT, 'no jobs'); END`); err != nil {
t.Fatal(err)
}
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
id, _ := created["id"].(string)
if id == "" || created["grounding_status"] != "failed" || created["grounding_error"] != "grounding_error" || created["grounding_job_id"] != nil {
t.Fatalf("created = %v, want its id, failed (grounding_error) and a null grounding_job_id", created)
}
if got := listedDinner(t, e.h, id); got["grounding_status"] != "failed" {
t.Errorf("listed = %v, want failed", got)
}
}
+1 -1
View File
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
t.Fatal(err)
}
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
newTestSearch(t), 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 {
t.Fatal(err)
}
+1 -1
View File
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), 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)}})
if err != nil {
t.Fatal(err)
+1 -1
View File
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
}
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
newTestSearch(t), 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 {
t.Fatal(err)
}
+1 -1
View File
@@ -22,7 +22,7 @@ const (
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, 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)}})
if err != nil {
t.Fatal(err)
+15 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
@@ -34,12 +35,15 @@ type RouteMounter interface {
// /media; settings serves /api/v1/settings/household; search serves
// /api/v1/settings/recipe-search and its /test; ai serves
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
// runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
// GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, 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 {
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 {
return nil, errors.New("server: nil AI settings service")
}
if dinnerSvc == nil {
return nil, errors.New("server: nil dinners service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
@@ -92,6 +99,13 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai))
apiV1.Get("/settings/ai/models", getAIModels(logger, ai))
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc))
apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc))
apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc))
apiV1.Get("/dinners/{id}/recipe-candidates", getDinnerCandidates(logger, dinnerSvc))
apiV1.Post("/dinners/{id}/ground", postDinnerGround(logger, dinnerSvc))
apiV1.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
media.Get("/photos/{name}", getPhoto(logger, photos))
+36 -6
View File
@@ -17,9 +17,12 @@ import (
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
@@ -43,7 +46,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
"index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
}
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), 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 {
t.Fatal(err)
}
@@ -139,6 +142,24 @@ func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
return search
}
// newTestDinners returns a dinners service over a migrated temp database,
// with its own unstarted job runner and a grounder that finds nothing.
func newTestDinners(t *testing.T) *dinners.Service {
t.Helper()
st := newTestStore(t)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
svc, err := dinners.NewService(st, g, runner, logger)
if err != nil {
t.Fatal(err)
}
return svc
}
// newTestBox returns a secret box under a random key.
func newTestBox(t *testing.T) *secretbox.Box {
t.Helper()
@@ -170,7 +191,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
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")
}
}
@@ -179,7 +200,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, 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")
}
}
@@ -188,7 +209,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), 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")
}
}
@@ -197,16 +218,25 @@ func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), 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")
}
}
func TestNewRouterRequiresDinners(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil dinners service")
}
}
func TestNewRouterRequiresJobRunner(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), 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")
}
}
+1 -1
View File
@@ -110,7 +110,7 @@ func decodePatch[T any](body io.Reader) (T, error) {
if errors.As(err, &typeErr) {
return patch, fmt.Errorf("%s: want a JSON %s", typeErr.Field, typeErr.Type)
}
return patch, fmt.Errorf("invalid settings: %v", err)
return patch, fmt.Errorf("invalid request body: %v", err)
}
return patch, nil
}
+1 -1
View File
@@ -216,7 +216,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
newTestSearch(t), 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 {
t.Fatal(err)
}
+4 -4
View File
@@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error", got)
}
}
@@ -196,8 +196,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error", got)
}
if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created")
+5
View File
@@ -0,0 +1,5 @@
-- 0008_dinner_recipe_url: a Regular added from a pasted recipe link keeps
-- that link until grounding resolves it to a recipe (recipe_id). Nothing
-- else records the link: recipes rows exist only for fetched pages.
ALTER TABLE dinners ADD COLUMN recipe_url TEXT;
@@ -0,0 +1,7 @@
-- 0009_dinner_grounding_error: a dinner whose grounding failed keeps why,
-- as one of the reason codes package dinners defines (no_recipe_found,
-- recipe_unusable, search_not_configured, search_failed, ai_failed,
-- interrupted, grounding_error). It is NULL unless grounding_status is
-- failed.
ALTER TABLE dinners ADD COLUMN grounding_error TEXT;
+74 -3
View File
@@ -1,12 +1,12 @@
-- name: CreateDinner :one
INSERT INTO dinners (
id, name, normalized_name, status, origin, method, minutes_total,
difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status,
updated_by, created_at, updated_at
difficulty, cuisine, is_favorite, recipe_id, recipe_url, photo_id,
grounding_status, updated_by, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?,
?, ?, ?
?, ?, ?, ?
)
RETURNING *;
@@ -19,6 +19,12 @@ SELECT * FROM dinners
WHERE status = ?
ORDER BY normalized_name, id;
-- name: ListUnarchivedDinners :many
-- Every dinner a new or renamed dinner must not repeat.
SELECT * FROM dinners
WHERE status <> 'archived'
ORDER BY id;
-- name: UpdateDinner :one
-- An unknown id returns sql.ErrNoRows.
UPDATE dinners
@@ -55,3 +61,68 @@ SET is_favorite = ?,
updated_at = ?
WHERE id = ?
RETURNING *;
-- name: ToggleDinnerFavorite :one
-- An unknown id returns sql.ErrNoRows.
UPDATE dinners
SET is_favorite = 1 - is_favorite,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING *;
-- name: GroundDinner :one
-- Records a dinner's grounding (grounded or manual) and clears any
-- grounding_error. An unknown id returns sql.ErrNoRows.
UPDATE dinners
SET recipe_id = ?,
photo_id = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
grounding_status = ?,
grounding_error = NULL,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING *;
-- name: GroundPendingDinner :execrows
-- Records the background grounding of a dinner still pending; a dinner
-- grounded some other way meanwhile is left as it is (0 rows).
UPDATE dinners
SET recipe_id = ?,
photo_id = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
grounding_status = 'grounded',
grounding_error = NULL,
updated_at = ?
WHERE id = ? AND grounding_status = 'pending';
-- name: FailPendingDinnerGrounding :execrows
-- Records why the background grounding of a dinner still pending failed; a
-- dinner grounded some other way meanwhile is left as it is (0 rows).
UPDATE dinners
SET grounding_status = 'failed',
grounding_error = ?,
updated_at = ?
WHERE id = ? AND grounding_status = 'pending';
-- name: FailOrphanedPendingDinnerGroundings :execrows
-- Fails every dinner still pending whose grounding job is no longer queued
-- or running: the process stopped before the job, or before its handler
-- recorded a result. Run at boot, after the job runner fails the jobs a
-- previous process left running.
UPDATE dinners
SET grounding_status = 'failed',
grounding_error = sqlc.arg(grounding_error),
updated_at = sqlc.arg(updated_at)
WHERE grounding_status = 'pending'
AND NOT EXISTS (
SELECT 1 FROM jobs
WHERE jobs.kind = sqlc.arg(kind)
AND jobs.status IN ('queued', 'running')
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
);
+12
View File
@@ -69,3 +69,15 @@ ORDER BY position, id;
-- name: DeleteRecipeSteps :exec
DELETE FROM recipe_steps
WHERE recipe_id = ?;
-- name: CreateManualRecipe :one
-- A recipe the household typed in. Its source_url is a mealquest:manual:
-- URN rather than a page, so it never matches a fetched page.
INSERT INTO recipes (
id, source_url, title, total_minutes, raw_jsonld, method,
minutes_total, fetched_at, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?,
?, ?, ?, ?
)
RETURNING *;
+64 -12
View File
@@ -27,7 +27,14 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
}
// 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 }
// SIGNED_IN answers what every route reads first: the session, then the
// household settings, onboarding finished.
const SIGNED_IN = {
'/api/v1/me': () => jsonResponse(200, ME),
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
}
describe('App routes', () => {
it('registers every handoff route plus the extra settings pages', () => {
@@ -54,7 +61,7 @@ describe('App routes', () => {
'%s renders its placeholder page inside the shell',
async (path, title) => {
mockViewport(390)
stubFetch(jsonResponse(200, ME))
stubFetchByPath(SIGNED_IN)
renderAt(path)
const heading = await screen.findByRole('heading', { level: 1, name: title })
expect(screen.getByRole('main')).toContainElement(heading)
@@ -77,7 +84,7 @@ describe('App routes', () => {
it('/settings/ai renders the AI assistant screen inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
'/api/v1/me': () => jsonResponse(200, ME),
...SIGNED_IN,
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }),
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
@@ -90,10 +97,7 @@ describe('App routes', () => {
it('/settings/household renders the household settings screen inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
'/api/v1/me': () => jsonResponse(200, ME),
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
})
stubFetchByPath(SIGNED_IN)
renderAt('/settings/household')
const heading = await screen.findByRole('heading', { level: 1, name: 'Household' })
expect(screen.getByRole('main')).toContainElement(heading)
@@ -101,10 +105,53 @@ describe('App routes', () => {
})
})
describe('App first-run redirect', () => {
const FIRST_RUN = {
'/api/v1/me': () => jsonResponse(200, ME),
'/api/v1/settings/household': () => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }),
}
it.each(routes.filter(({ path }) => path !== '/onboarding').map(({ path }) => path.replace(':id', 'abc123')))(
'%s redirects to /onboarding while onboarding is not finished',
async (path) => {
mockViewport(390)
stubFetchByPath(FIRST_RUN)
renderAt(path)
expect(await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })).toBeInTheDocument()
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
},
)
it('stays on an onboarding step', async () => {
mockViewport(390)
stubFetchByPath({ ...FIRST_RUN, '/api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: [] }) })
renderAt('/onboarding/regulars')
expect(await screen.findByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument()
})
it('opens the routes once onboarding is finished', async () => {
mockViewport(390)
stubFetchByPath(SIGNED_IN)
renderAt('/plan')
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
it('reports a household settings failure with a retry instead of rendering the routes', async () => {
mockViewport(390)
stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS))
renderAt('/plan')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
screen.getByRole('button', { name: 'Retry' }).click()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
})
describe('App bootstrap', () => {
it('shows the skeleton until /me loads, then the routes', async () => {
mockViewport(390)
const fetchMock = stubFetch(jsonResponse(200, ME))
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
renderAt('/plan')
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
@@ -138,7 +185,7 @@ describe('App bootstrap', () => {
it('reports a /me failure with a retry instead of rendering the routes', async () => {
mockViewport(390)
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME))
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
renderAt('/')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
@@ -158,7 +205,7 @@ describe('App offline bootstrap', () => {
// visitOnline is an earlier online visit on this device: /me answers with a
// member, then the app is closed.
async function visitOnline() {
stubFetch(jsonResponse(200, ME))
stubFetchByPath(SIGNED_IN)
const { unmount } = renderAt('/')
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
unmount()
@@ -226,12 +273,17 @@ describe('App offline bootstrap', () => {
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
const fetchMock = stubFetch(jsonResponse(200, ME))
const fetchMock = stubFetchByPath(SIGNED_IN)
onLine = true
act(() => {
window.dispatchEvent(new Event('online'))
})
await vi.waitFor(() => expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' }))
await vi.waitFor(() =>
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
'/api/v1/me',
'/api/v1/settings/household',
]),
)
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
+13 -4
View File
@@ -3,6 +3,8 @@ import { QueryClientProvider } from '@tanstack/react-query'
import { Route, Routes } from 'react-router'
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
import { AuthBootstrap } from './AuthBootstrap.tsx'
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
import { OfflinePill } from './pwa/OfflinePill.tsx'
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
@@ -38,13 +40,20 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
}
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
// Onboarding is a full screen of its own, outside the shell; until it is
// finished, OnboardingGate sends every other route to it.
export function AppRoutes() {
return (
<Routes>
<Route element={<AppShell />}>
{routes.map(({ path, title }) => (
<Route key={path} path={path} element={screens[path] ?? <PlaceholderPage title={title} />} />
))}
<Route element={<OnboardingGate />}>
<Route path={`${ONBOARDING_PATH}/*`} element={<OnboardingPage />} />
<Route element={<AppShell />}>
{routes
.filter(({ path }) => path !== ONBOARDING_PATH)
.map(({ path, title }) => (
<Route key={path} path={path} element={screens[path] ?? <PlaceholderPage title={title} />} />
))}
</Route>
</Route>
</Routes>
)
+5
View File
@@ -41,6 +41,11 @@ export function AuthBootstrap({ children }: { children: ReactNode }) {
</main>
)
}
return <LoadingScreen />
}
// LoadingScreen is the full-screen skeleton shown while MealQuest loads.
export function LoadingScreen() {
return (
<main className={styles.screen} aria-busy="true" aria-label="Loading MealQuest">
<div className={styles.loading}>
+119
View File
@@ -0,0 +1,119 @@
import { 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`
// Regular is the part of a dinner the Regulars list reads. 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; pending?: true }
// 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)
}
// 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 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`)
}
return { id: body.id, name: body.name }
}
// useRegulars is the household's Regulars, in the server's order.
export function useRegulars() {
const { apiFetch } = useApi()
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,
})
}
// 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 ?? []))
}
// 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 (POST /api/v1/dinners). It shows in
// the list at once 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 (name: string) =>
parseDinner(`POST ${DINNERS_PATH}`, await apiFetch<unknown>(DINNERS_PATH, { method: 'POST', body: { name } })),
onMutate: async (name) => {
const id = `pending-${++pendingIds}`
await changeOptimistically(queryClient, (list) => [...list, { id, name, pending: true }])
return { id }
},
onSuccess: (dinner, _name, context) => {
setRegulars(queryClient, (list) => list.map((regular) => (regular.id === context.id ? dinner : regular)))
},
onError: (_error, _name, 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>(`${DINNERS_PATH}/${encodeURIComponent(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 ? { id: r.id, name: r.name } : r)))
},
onSettled: () => settle(queryClient),
})
}
+228
View File
@@ -0,0 +1,228 @@
/* 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 1: what MealQuest's words mean. */
.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);
}
.status {
margin-top: 10px;
}
@media (prefers-reduced-motion: reduce) {
.addFace {
transition: none;
}
}
+52
View File
@@ -0,0 +1,52 @@
import { useEffect } from 'react'
import { Navigate, Outlet, useLocation } from 'react-router'
import { useApi } from '../api/index.ts'
import { LoadingScreen } from '../AuthBootstrap.tsx'
import { useHouseholdSettings } from '../settings/api.ts'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import styles from '../pages/FullScreen.module.css'
export const ONBOARDING_PATH = '/onboarding'
function inOnboarding(pathname: string): boolean {
return pathname === ONBOARDING_PATH || pathname.startsWith(`${ONBOARDING_PATH}/`)
}
// OnboardingGate reads the household settings before any page: while
// onboarding_completed_at is null, every route outside /onboarding
// redirects there.
//
// The service worker never caches the settings, so offline on the saved
// pages (see AuthBootstrap) they cannot be read: the saved pages open as they
// are, since onboarding needs the server anyway, and the settings are read
// again when the browser is back online.
export function OnboardingGate() {
const api = useApi()
const { pathname } = useLocation()
const settings = useHouseholdSettings()
const offline = settings.isError && settings.error instanceof TypeError && api.session.verified()
const { refetch } = settings
useEffect(() => {
if (!offline) return
return api.network.subscribe(() => {
if (!api.network.offline()) void refetch()
})
}, [offline, api, refetch])
if (settings.data !== undefined) {
if (settings.data.onboarding_completed_at === null && !inOnboarding(pathname)) {
return <Navigate to={ONBOARDING_PATH} replace />
}
return <Outlet />
}
if (offline) return <Outlet />
if (settings.isError) {
return (
<main className={styles.screen}>
<ErrorBanner onRetry={() => void refetch()}>MealQuest could not load: {settings.error.message}</ErrorBanner>
</main>
)
}
return <LoadingScreen />
}
+410
View File
@@ -0,0 +1,410 @@
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 { 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 = { id: string; name: string }
// 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({ id, name })
request.answer(jsonResponse(201, { id, name, status: 'regular', 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([{ id: 'd1', name: '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('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([{ id: 'd1', name: '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([
{ id: 'd1', name: 'Beef tacos' },
{ id: 'd2', name: '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([
{ id: 'd1', name: 'Beef tacos' },
{ id: 'd2', name: '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([
{ id: 'd1', name: 'Beef tacos' },
{ id: 'd2', name: 'Pizza night' },
{ id: 'd3', name: '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([
{ id: 'd1', name: 'Beef tacos' },
{ id: 'd2', name: 'Pizza night' },
{ id: 'd3', name: '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([
{ id: 'd1', name: 'Beef tacos' },
{ id: 'd2', name: '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([
{ id: 'd1', name: 'Beef tacos' },
{ id: 'd2', name: '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([
{ id: 'd1', name: 'Beef tacos' },
{ id: 'd2', name: 'Pizza night' },
{ id: 'd3', name: '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()
})
})
+77
View File
@@ -0,0 +1,77 @@
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 { 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&apos;s start
</Button>
}
>
<p className={styles.helper}>MealQuest plans the household&apos;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>
)
}
// TODO(Feature 12): onboarding step 3 — notifications and finishing
// onboarding — is a later task; until then the step only says so.
function NotificationsStep() {
return (
<OnboardingStep step={3} title="Stay in the loop">
<p className={styles.helper}>Setting up notifications arrives in a later update.</p>
</OnboardingStep>
)
}
+35
View File
@@ -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>
)
}
+166
View File
@@ -0,0 +1,166 @@
import { useRef, 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 { 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.
export const commonPicks = [
'Beef tacos',
'Spaghetti bolognese',
'Chicken stir-fry',
'Pizza night',
'Chili',
'Pot roast',
'Burgers',
'Fried rice',
]
function sameName(a: string, b: string): boolean {
return a.trim().toLowerCase() === b.trim().toLowerCase()
}
// Refusal is one add or removal 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.
type Refusal = { key: number; change: string; text: string }
// 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('')
// Each change keeps its own refusal: the mutation hooks only report their
// latest change, which would hide a refusal while another change is in
// flight.
const [refusals, setRefusals] = useState<Refusal[]>([])
const attempts = useRef(0)
const list = regulars.data ?? []
const picks = commonPicks.filter((pick) => !list.some((regular) => sameName(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 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}` }])
})
}
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>
)
}
+12 -5
View File
@@ -37,13 +37,17 @@ export type HouseholdSettings = {
suggestions_time: string
chat_autosend_dow: number
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
// the fields that change.
export type HouseholdSettingsPatch = Partial<HouseholdSettings>
export type HouseholdSettingsField = keyof HouseholdSettings
export type HouseholdSettingsPatch = Partial<Pick<HouseholdSettings, HouseholdSettingsField>>
const householdTimeFields = [
'dinner_time',
@@ -355,10 +359,13 @@ function parseHouseholdSettings(where: string, body: unknown): HouseholdSettings
}
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
export function useHouseholdSettings() {
const { apiFetch } = useApi()
+21 -20
View File
@@ -21,7 +21,7 @@ afterEach(() => {
vi.unstubAllGlobals()
})
function renderAt(path: string, width: number) {
async function renderAt(path: string, width: number) {
const resize = mockViewport(width)
stubFetchByPath(SETTINGS_API)
render(
@@ -31,6 +31,8 @@ function renderAt(path: string, width: number) {
</ApiProviders>
</InDataRouter>,
)
// The routes wait for the household settings (OnboardingGate).
await screen.findByRole('navigation', { name: 'Primary' })
return resize
}
@@ -49,8 +51,8 @@ function current() {
}
describe('AppShell', () => {
it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', (width) => {
renderAt('/', width)
it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', async (width) => {
await renderAt('/', width)
expect(navLinks().map((link) => link.textContent)).toEqual(ORDER)
expect(navLinks().map((link) => link.getAttribute('href'))).toEqual([
'/',
@@ -61,8 +63,8 @@ describe('AppShell', () => {
])
})
it('shows only the mobile bottom bar below 768 px', () => {
renderAt('/', 767)
it('shows only the mobile bottom bar below 768 px', async () => {
await renderAt('/', 767)
expect(screen.getAllByRole('navigation')).toHaveLength(1)
expect(nav()).toHaveClass(bottomBarStyles.bar!)
expect(screen.queryByText('M')).not.toBeInTheDocument()
@@ -80,8 +82,8 @@ describe('AppShell', () => {
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', () => {
renderAt('/discover', MOBILE)
it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', async () => {
await renderAt('/discover', MOBILE)
const pills = nav().querySelectorAll(`.${bottomBarStyles.pill}`)
expect(pills).toHaveLength(1)
const pill = declaredStyle(pills[0]!)
@@ -101,24 +103,24 @@ describe('AppShell', () => {
expect(within(inactive).getByText('Plan')).toHaveClass(bottomBarStyles.hiddenLabel!)
})
it('makes the full 62 px bar height the mobile tap target', () => {
renderAt('/', MOBILE)
it('makes the full 62 px bar height the mobile tap target', async () => {
await renderAt('/', MOBILE)
for (const link of navLinks()) {
expect(declaredStyle(link).height).toBe('62px')
expectMinHitArea(link)
}
})
it('reserves at least 100 px below the mobile scroll area', () => {
renderAt('/', MOBILE)
it('reserves at least 100 px below the mobile scroll area', async () => {
await renderAt('/', MOBILE)
const padding = declaredStyle(screen.getByRole('main')).paddingBottom
const reserved = /^calc\((\d+)px \+ env\(safe-area-inset-bottom\)\)$/.exec(padding)
expect(reserved, `padding-bottom ${JSON.stringify(padding)}`).not.toBeNull()
expect(Number(reserved![1])).toBeGreaterThanOrEqual(100)
})
it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', (width) => {
renderAt('/plan', width)
it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', async (width) => {
await renderAt('/plan', width)
expect(screen.getAllByRole('navigation')).toHaveLength(1)
expect(nav()).toHaveClass(railStyles.rail!)
expect(declaredStyle(nav()).width).toBe('96px')
@@ -146,8 +148,8 @@ describe('AppShell', () => {
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) => {
renderAt('/quick', width)
it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', async (width) => {
await renderAt('/quick', width)
expect(screen.getAllByRole('navigation')).toHaveLength(1)
const sidebar = nav().closest('aside')!
expect(sidebar).toHaveClass(sidebarStyles.sidebar!)
@@ -176,8 +178,8 @@ describe('AppShell', () => {
for (const link of navLinks()) expect(declaredStyle(link).minHeight).toBe('44px')
})
it('switches layout when the viewport crosses a breakpoint', () => {
const resize = renderAt('/', MOBILE)
it('switches layout when the viewport crosses a breakpoint', async () => {
const resize = await renderAt('/', MOBILE)
expect(nav()).toHaveClass(bottomBarStyles.bar!)
resize(TABLET)
expect(nav()).toHaveClass(railStyles.rail!)
@@ -206,11 +208,10 @@ describe('AppShell', () => {
['/settings/household', ['Settings']],
['/settings/regulars', ['Settings']],
['/settings/calendar', ['Settings']],
['/onboarding', []],
['/cook-from-pantry', []],
['/dinner/abc123', []],
])('%s marks %j current', (path, expected) => {
renderAt(path, width)
])('%s marks %j current', async (path, expected) => {
await renderAt(path, width)
expect(current()).toEqual(expected)
})
})
+26 -24
View File
@@ -70,7 +70,7 @@ afterEach(() => {
vi.unstubAllGlobals()
})
function renderAt(path: string, reducedMotion = false) {
async function renderAt(path: string, reducedMotion = false) {
mockViewport(MOBILE, { reducedMotion })
stubFetchByPath(SETTINGS_API)
render(
@@ -80,6 +80,8 @@ function renderAt(path: string, reducedMotion = false) {
</ApiProviders>
</InDataRouter>,
)
// The routes wait for the household settings (OnboardingGate).
await screen.findByRole('navigation', { name: 'Primary' })
}
function nav() {
@@ -109,21 +111,21 @@ function animationsOf(element: Element) {
}
describe('BottomBar', () => {
it('places the pill on the active item without animating on first render', () => {
renderAt('/plan')
it('places the pill on the active item without animating on first render', async () => {
await renderAt('/plan')
expect(pill().style.left).toBe('54px')
expect(pill().style.width).toBe('110px')
expect(pill()).toBeVisible()
expect(animate).not.toHaveBeenCalled()
})
it('hides the pill when no item is active', () => {
renderAt('/onboarding')
it('hides the pill when no item is active', async () => {
await renderAt('/cook-from-pantry')
expect(pill()).not.toBeVisible()
})
it('slides the pill from the old item to the new one over 420 ms on the spring curve', () => {
renderAt('/')
it('slides the pill from the old item to the new one over 420 ms on the spring curve', async () => {
await renderAt('/')
fireEvent.click(link('Quick'))
expect(animationsOf(pill())).toEqual([
@@ -139,8 +141,8 @@ describe('BottomBar', () => {
expect(pill().style.width).toBe('110px')
})
it('FLIPs every item that moved from its old left to its new one with the same timing', () => {
renderAt('/')
it('FLIPs every item that moved from its old left to its new one with the same timing', async () => {
await renderAt('/')
fireEvent.click(link('Quick'))
// 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([])
})
it('fades the new label in over 220 ms ease-out after 120 ms', () => {
renderAt('/')
it('fades the new label in over 220 ms ease-out after 120 ms', async () => {
await renderAt('/')
fireEvent.click(link('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', () => {
renderAt('/plan')
it('does not animate when the active item stays the same', async () => {
await renderAt('/plan')
fireEvent.click(link('Plan'))
expect(animate).not.toHaveBeenCalled()
})
it('jumps the pill to the new item with no animation under reduced motion', () => {
renderAt('/', true)
it('jumps the pill to the new item with no animation under reduced motion', async () => {
await renderAt('/', true)
fireEvent.click(link('Settings'))
expect(link('Settings')).toHaveAttribute('aria-current', 'page')
@@ -196,8 +198,8 @@ describe('BottomBar', () => {
expect(pill().style.width).toBe('110px')
})
it('re-places the pill when the viewport resizes', () => {
renderAt('/discover')
it('re-places the pill when the viewport resizes', async () => {
await renderAt('/discover')
expect(pill().style.left).toBe('102px')
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(120)
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96)
@@ -207,8 +209,8 @@ describe('BottomBar', () => {
expect(animate).not.toHaveBeenCalled()
})
it('starts a second slide from where the first one is on screen, not from its destination', () => {
renderAt('/')
it('starts a second slide from where the first one is on screen, not from its destination', async () => {
await renderAt('/')
fireEvent.click(link('Quick'))
const [firstPill] = runningOn(pill())
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', () => {
renderAt('/')
it('stops a running slide and places the pill on the active item when the viewport resizes', async () => {
await renderAt('/')
fireEvent.click(link('Quick'))
const slide = [pill(), link('Plan'), link('Discover'), link('Quick')].flatMap(runningOn)
expect(slide).toHaveLength(4)
@@ -269,7 +271,7 @@ describe('BottomBar', () => {
const ready = new Promise<void>((resolve) => (fontsReady = resolve))
Object.defineProperty(document, 'fonts', { configurable: true, value: { ready } })
try {
renderAt('/discover')
await renderAt('/discover')
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(124)
fontsReady()
await ready
@@ -279,8 +281,8 @@ describe('BottomBar', () => {
}
})
it('crossfades the icon colour with a .2s colour transition', () => {
renderAt('/')
it('crossfades the icon colour with a .2s colour transition', async () => {
await renderAt('/')
for (const item of within(nav()).getAllByRole('link')) {
expect(declaredStyle(item).transition).toBe('color .2s')
}
+2 -2
View File
@@ -21,7 +21,7 @@ export const AI_SETTINGS = {
export const AI_MODELS = { models: [{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }] }
// HOUSEHOLD_SETTINGS is a GET /api/v1/settings/household body: the seed
// times in New York.
// times in New York, onboarding finished.
export const HOUSEHOLD_SETTINGS = {
timezone: 'America/New_York',
dinner_time: '17:30',
@@ -33,7 +33,7 @@ export const HOUSEHOLD_SETTINGS = {
suggestions_time: '08:00',
chat_autosend_dow: 6,
chat_autosend_time: '08:00',
onboarding_completed_at: null,
onboarding_completed_at: '2026-10-01T15:30:00.000Z',
updates_seen_at: null,
updated_by: null,
created_at: '2026-10-01T15:00:00.000Z',