Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 5b3fccae96 Keep the settings sub-tabs mounted below 768 px
CI / ci (push) Successful in 1m59s
CI / ci (pull_request) Successful in 2m0s
Crossing the 768 px breakpoint used to swap SettingsTabs for the mobile
list, unmounting the AI assistant, Household and Recipe search drafts and
their leave guard. SettingsPage now keeps SettingsTabs mounted at every
width and hides it on mobile, so an unsaved draft survives a resize or
rotation and the leave guard still asks before the list navigates away.

Tests that render the mobile settings home now use a data router, which
the mounted leave guard needs, and the shared settings stub answers the
AI models and recipe search reads the hidden sub-tabs make.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:31:57 -04:00
dev_agentandClaude Opus 5.5 42d58aad94 Lock the AI assistant form while a prompt reset is pending
CI / ci (push) Successful in 1m59s
CI / ci (pull_request) Successful in 2m0s
Closing the Reset sheet while the reset request was in flight left the form
editable, so a prompt edit made then was replaced by the reset response and
the leave guard could report the form as saved. The form's fieldset is now
disabled while a prompt reset is pending as well as during a save, so no edit
can be made that the reset response would overwrite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:15:14 -04:00
dev_agentandClaude Opus 5.5 021ed570fe Lock settings forms while a save is pending
CI / ci (push) Successful in 2m0s
CI / ci (pull_request) Successful in 2m0s
The AI assistant, Household and Recipe search forms stayed editable during
a save, then replaced the whole draft with the server response, so an edit
made while the save was in flight was lost and the leave guard reported
the form as saved. Each form now sits in a fieldset that is disabled only
while its save is pending (display: contents, so layout is unchanged), so
no edit can be made that the response would overwrite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:53:15 -04:00
dev_agent 35438f1934 Desktop and tablet settings layout (mock 2e) (#85)
CI / ci (push) Successful in 1m59s
CI / ci (pull_request) Successful in 1m59s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 11:40:32 -04:00
dev_agent 8654b6bcee Household settings screen (#84)
CI / ci (push) Successful in 1m59s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 11:25:13 -04:00
dev_agent d3839fd4a1 Recipe search settings section (#83)
CI / ci (push) Successful in 1m56s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 10:59:31 -04:00
dev_agent e9a35a7f57 Admin AI assistant screen (mock 1o) (#82)
CI / ci (push) Successful in 1m56s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 10:22:07 -04:00
dev_agent de4f539aea Settings home screen (mock 1p) (#81)
CI / ci (push) Successful in 1m55s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 09:50:17 -04:00
dev_agent 8855091b30 AI settings API with key management (#80)
CI / ci (push) Successful in 2m1s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 09:16:09 -04:00
23 changed files with 39 additions and 1364 deletions
+1 -2
View File
@@ -16,7 +16,6 @@ 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"
@@ -106,7 +105,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinners.NewService(db), runner, photos, frontend.Assets())
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, runner, photos, frontend.Assets())
if err != nil {
return err
}
+1 -23
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, recipe_url (a pasted recipe link awaiting grounding), 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, 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,28 +178,6 @@ 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.
+1 -31
View File
@@ -106,22 +106,13 @@ 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{
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,
}
want := db.Dinner(create)
got, err := q.CreateDinner(ctx, create)
if err != nil {
t.Fatalf("CreateDinner: %v", err)
@@ -206,7 +197,6 @@ 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)
@@ -237,17 +227,6 @@ 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,
@@ -261,15 +240,6 @@ 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)
}
+9 -106
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, recipe_url
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
`
type ArchiveDinnerParams struct {
@@ -46,7 +46,6 @@ func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (D
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
)
return i, err
}
@@ -54,14 +53,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, recipe_url, photo_id,
grounding_status, updated_by, created_at, updated_at
difficulty, cuisine, is_favorite, recipe_id, 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, recipe_url
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
`
type CreateDinnerParams struct {
@@ -76,7 +75,6 @@ type CreateDinnerParams struct {
Cuisine sql.NullString
IsFavorite int64
RecipeID sql.NullString
RecipeUrl sql.NullString
PhotoID sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
@@ -97,7 +95,6 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din
arg.Cuisine,
arg.IsFavorite,
arg.RecipeID,
arg.RecipeUrl,
arg.PhotoID,
arg.GroundingStatus,
arg.UpdatedBy,
@@ -122,13 +119,12 @@ 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, recipe_url 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 FROM dinners
WHERE id = ?
`
@@ -152,13 +148,12 @@ 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, recipe_url 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 FROM dinners
WHERE status = ?
ORDER BY normalized_name, id
`
@@ -189,55 +184,6 @@ 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
}
@@ -258,7 +204,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, recipe_url
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
`
type SetDinnerFavoriteParams struct {
@@ -294,48 +240,6 @@ 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
}
@@ -356,7 +260,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, recipe_url
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
`
type UpdateDinnerParams struct {
@@ -412,7 +316,6 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
)
return i, err
}
-1
View File
@@ -67,7 +67,6 @@ type Dinner struct {
UpdatedBy sql.NullString
CreatedAt string
UpdatedAt string
RecipeUrl sql.NullString
}
type HouseholdSetting struct {
-4
View File
@@ -60,16 +60,12 @@ 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.
-467
View File
@@ -1,467 +0,0 @@
// 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
}
-33
View File
@@ -1,33 +0,0 @@
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)
}
}
}
+5 -17
View File
@@ -564,11 +564,9 @@ func TestFetchCacheIsNotSharedWithCallers(t *testing.T) {
}
}
// TestFetchCanceledWaitHoldsNoSlot covers a burst of requests whose
// TestFetchCanceledWaitHoldsNoSlot covers a burst of fetches whose
// rate-limit waits end with their context: they must not push back the
// next request that is allowed through. The burst calls waitTurn, the wait
// turnConn's first Write makes, directly: through Fetch, net/http races the
// failed write against its read loop, so the error it returns varies.
// next request that is allowed through.
func TestFetchCanceledWaitHoldsNoSlot(t *testing.T) {
t.Parallel()
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -579,24 +577,14 @@ func TestFetchCanceledWaitHoldsNoSlot(t *testing.T) {
if _, err := env.f.Fetch(t.Context(), "https://example.com/first"); err != nil {
t.Fatalf("first Fetch: %v", err)
}
firstSent := clock.now()
env.f.sleep = func(context.Context, time.Duration) error { return context.DeadlineExceeded }
for i := range 5 {
// Each request comes later in the interval, so a slot it took
// would delay the request after the burst.
clock.advance(HostInterval / 10)
if _, err := env.f.waitTurn(t.Context(), "example.com"); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("burst waitTurn %d error = %v, want context.DeadlineExceeded", i, err)
if _, err := env.f.Fetch(t.Context(), fmt.Sprintf("https://example.com/burst/%d", i)); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("burst Fetch %d error = %v, want context.DeadlineExceeded", i, err)
}
}
env.f.mu.Lock()
last := env.f.lastSent["example.com"]
env.f.mu.Unlock()
if !last.Equal(firstSent) {
t.Errorf("host slot taken at %s after the burst, want the first send at %s", last, firstSent)
}
env.f.sleep = clock.sleep
clock.advance(firstSent.Add(HostInterval).Sub(clock.now()))
clock.advance(HostInterval)
if _, err := env.f.Fetch(t.Context(), "https://example.com/after"); err != nil {
t.Fatalf("Fetch after the burst: %v", err)
}
+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), newTestDinners(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
-148
View File
@@ -1,148 +0,0 @@
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")
}
}
-464
View File
@@ -1,464 +0,0 @@
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 }
+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), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t),
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestRunner(t),
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+1 -1
View File
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
}
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -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), newTestDinners(t), newTestRunner(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+2 -12
View File
@@ -11,7 +11,6 @@ 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"
@@ -35,13 +34,12 @@ 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} and POST /api/v1/dinners/{id}/favorite; runner serves
// 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, dinnerSvc *dinners.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, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
@@ -51,9 +49,6 @@ 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")
}
@@ -97,11 +92,6 @@ 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))
+6 -22
View File
@@ -17,7 +17,6 @@ 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"
@@ -44,7 +43,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
"index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
}
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), runner, newTestPhotos(t), assets)
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), runner, newTestPhotos(t), assets)
if err != nil {
t.Fatal(err)
}
@@ -140,12 +139,6 @@ 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()
@@ -177,7 +170,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, newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil AI settings service")
}
}
@@ -186,7 +179,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), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil recipe search settings service")
}
}
@@ -195,7 +188,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted nil photo files")
}
}
@@ -204,25 +197,16 @@ 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), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
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), newTestDinners(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner")
}
}
+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 request body: %v", err)
return patch, fmt.Errorf("invalid settings: %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), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+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,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 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)
}
}
@@ -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,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 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 !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created")
-5
View File
@@ -1,5 +0,0 @@
-- 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;
+3 -18
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, recipe_url, photo_id,
grounding_status, updated_by, created_at, updated_at
difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status,
updated_by, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?,
?, ?, ?, ?
?, ?, ?
)
RETURNING *;
@@ -19,12 +19,6 @@ 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
@@ -61,12 +55,3 @@ 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 *;