From 9ba9b1d089531566b4c271bf183b13e32c4af687 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 15:46:33 -0400 Subject: [PATCH] Add the dinners API for Regulars POST /api/v1/dinners adds a Regular from a name or a recipe link (status and origin regular, grounding_status pending), refusing a name harness.Similar to an unarchived dinner, or a known link, with 409. GET /dinners?status=regular|quest_log lists dinners with photo URLs and grounding status; PATCH /dinners/{id} edits name, method, minutes and difficulty; DELETE archives the row; POST /dinners/{id}/favorite toggles is_favorite. Every write records the session subject as updated_by. Migration 0008 adds dinners.recipe_url so a pasted link is kept until grounding resolves it. Maestro task 28d3cb4b-83d1-40e8-991f-d88f0f64d270. Co-Authored-By: Claude Opus 5.5 --- cmd/server/main.go | 3 +- docs/ARCHITECTURE.md | 24 +- internal/db/db_test.go | 32 +- internal/db/dinners.sql.go | 115 ++++++- internal/db/models.go | 1 + internal/db/querier.go | 4 + internal/dinners/dinners.go | 467 ++++++++++++++++++++++++++ internal/dinners/dinners_test.go | 33 ++ internal/server/aisettings_test.go | 2 +- internal/server/dinners.go | 148 ++++++++ internal/server/dinners_test.go | 464 +++++++++++++++++++++++++ internal/server/login_test.go | 2 +- internal/server/media_test.go | 2 +- internal/server/origin_test.go | 2 +- internal/server/recipesearch_test.go | 2 +- internal/server/server.go | 14 +- internal/server/server_test.go | 28 +- internal/server/settings.go | 2 +- internal/server/settings_test.go | 2 +- internal/store/migrate_test.go | 8 +- migrations/0008_dinner_recipe_url.sql | 5 + migrations/queries/dinners.sql | 21 +- 22 files changed, 1347 insertions(+), 34 deletions(-) create mode 100644 internal/dinners/dinners.go create mode 100644 internal/dinners/dinners_test.go create mode 100644 internal/server/dinners.go create mode 100644 internal/server/dinners_test.go create mode 100644 migrations/0008_dinner_recipe_url.sql diff --git a/cmd/server/main.go b/cmd/server/main.go index 4b78236..acda7fb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -16,6 +16,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/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" @@ -105,7 +106,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, dinners.NewService(db), runner, photos, frontend.Assets()) if err != nil { return err } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 226ab04..4e6d7d6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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, 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,28 @@ 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. - `/health` — liveness. - Everything else serves the embedded PWA with SPA fallback. diff --git a/internal/db/db_test.go b/internal/db/db_test.go index bdbba38..2f5f911 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -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) } diff --git a/internal/db/dinners.sql.go b/internal/db/dinners.sql.go index 3b7b213..b62e9b1 100644 --- a/internal/db/dinners.sql.go +++ b/internal/db/dinners.sql.go @@ -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 ` type ArchiveDinnerParams struct { @@ -46,6 +46,7 @@ func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (D &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.RecipeUrl, ) return i, err } @@ -53,14 +54,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 ` type CreateDinnerParams struct { @@ -75,6 +76,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 +97,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 +122,13 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.RecipeUrl, ) return i, err } 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 FROM dinners WHERE id = ? ` @@ -148,12 +152,13 @@ func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) { &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.RecipeUrl, ) return i, err } 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 FROM dinners WHERE status = ? ORDER BY normalized_name, id ` @@ -184,6 +189,55 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.RecipeUrl, + ); 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 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, ); err != nil { return nil, err } @@ -204,7 +258,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 ` type SetDinnerFavoriteParams struct { @@ -240,6 +294,48 @@ func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoritePa &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.RecipeUrl, + ) + 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 +` + +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, ) return i, err } @@ -260,7 +356,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 ` type UpdateDinnerParams struct { @@ -316,6 +412,7 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.RecipeUrl, ) return i, err } diff --git a/internal/db/models.go b/internal/db/models.go index 7879c3e..0166d56 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -67,6 +67,7 @@ type Dinner struct { UpdatedBy sql.NullString CreatedAt string UpdatedAt string + RecipeUrl sql.NullString } type HouseholdSetting struct { diff --git a/internal/db/querier.go b/internal/db/querier.go index 4afff6a..90bbb89 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -60,12 +60,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. diff --git a/internal/dinners/dinners.go b/internal/dinners/dinners.go new file mode 100644 index 0000000..cdf0bfe --- /dev/null +++ b/internal/dinners/dinners.go @@ -0,0 +1,467 @@ +// Package dinners keeps the household's dinners: adding Regulars by name or +// recipe link, listing Regulars and the Quest log, editing, archiving and +// favoriting. 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" + "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/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"` + 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 +} + +// NewService returns a service over st. +func NewService(st *store.Store) *Service { + return &Service{st: st} +} + +// 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. A name +// repeating an unarchived dinner is a *DuplicateError. +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, + 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 +} diff --git a/internal/dinners/dinners_test.go b/internal/dinners/dinners_test.go new file mode 100644 index 0000000..9f8bd80 --- /dev/null +++ b/internal/dinners/dinners_test.go @@ -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) + } + } +} diff --git a/internal/server/aisettings_test.go b/internal/server/aisettings_test.go index 8bbd281..4c58c43 100644 --- a/internal/server/aisettings_test.go +++ b/internal/server/aisettings_test.go @@ -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) diff --git a/internal/server/dinners.go b/internal/server/dinners.go new file mode 100644 index 0000000..b3fbcaa --- /dev/null +++ b/internal/server/dinners.go @@ -0,0 +1,148 @@ +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" +) + +// maxDinnerBody bounds a POST or PATCH /dinners request body. +const maxDinnerBody = 16 << 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"` +} + +// 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. +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) + } +} + +// 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. +func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) { + var invalid *dinners.ValidationError + var dup *dinners.DuplicateError + switch { + 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") + } +} diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go new file mode 100644 index 0000000..75937a5 --- /dev/null +++ b/internal/server/dinners_test.go @@ -0,0 +1,464 @@ +package server + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "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. +func newDinnersRouter(t *testing.T) (http.Handler, *store.Store) { + t.Helper() + st := newTestStore(t) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), + dinners.NewService(st), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + if err != nil { + t.Fatal(err) + } + return r, st +} + +// 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 } diff --git a/internal/server/login_test.go b/internal/server/login_test.go index d7fa939..b801274 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -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) } diff --git a/internal/server/media_test.go b/internal/server/media_test.go index c464737..e1aed7b 100644 --- a/internal/server/media_test.go +++ b/internal/server/media_test.go @@ -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) diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index cf52a47..ef97b38 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -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) } diff --git a/internal/server/recipesearch_test.go b/internal/server/recipesearch_test.go index 09ec29e..ecc0d69 100644 --- a/internal/server/recipesearch_test.go +++ b/internal/server/recipesearch_test.go @@ -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) diff --git a/internal/server/server.go b/internal/server/server.go index b32d9ca..dda300d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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,13 @@ 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; -// runner serves +// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE +// /api/v1/dinners/{id} and POST /api/v1/dinners/{id}/favorite; 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 +51,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 +97,11 @@ 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.Post("/jobs", postJob(logger, runner)) apiV1.Get("/jobs/{id}", getJob(logger, runner)) media.Get("/photos/{name}", getPhoto(logger, photos)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 1704af7..2df926d 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -17,6 +17,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/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" @@ -43,7 +44,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 +140,12 @@ func newTestSearch(t *testing.T) *recipes.SearchSettingsService { return search } +// newTestDinners returns a dinners service over a migrated temp database. +func newTestDinners(t *testing.T) *dinners.Service { + t.Helper() + return dinners.NewService(newTestStore(t)) +} + // newTestBox returns a secret box under a random key. func newTestBox(t *testing.T) *secretbox.Box { t.Helper() @@ -170,7 +177,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 +186,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 +195,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 +204,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") } } diff --git a/internal/server/settings.go b/internal/server/settings.go index 489d092..a5f06b7 100644 --- a/internal/server/settings.go +++ b/internal/server/settings.go @@ -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 } diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index f0072fe..8491eb0 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -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) } diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index 6c00c51..91c4592 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -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" { + 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", 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" { + 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", got) } if !tableExists(t, s, "dinners") { t.Error("table from 0001_init was not created") diff --git a/migrations/0008_dinner_recipe_url.sql b/migrations/0008_dinner_recipe_url.sql new file mode 100644 index 0000000..cb59567 --- /dev/null +++ b/migrations/0008_dinner_recipe_url.sql @@ -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; diff --git a/migrations/queries/dinners.sql b/migrations/queries/dinners.sql index 6b4d39b..a0a9ca1 100644 --- a/migrations/queries/dinners.sql +++ b/migrations/queries/dinners.sql @@ -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,12 @@ 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 *;