Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5a52d5e6d | ||
|
|
8654b6bcee | ||
|
|
d3839fd4a1 | ||
|
|
e9a35a7f57 | ||
|
|
de4f539aea | ||
|
|
8855091b30 |
+5
-42
@@ -14,11 +14,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
@@ -78,10 +75,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// One fetcher serves pages and photos, so its per-host spacing and
|
photos, err := recipes.NewPhotoCache(recipes.NewFetcher(), db, cfg.DataDir)
|
||||||
// page cache cover both.
|
|
||||||
fetcher := recipes.NewFetcher()
|
|
||||||
photos, err := recipes.NewPhotoCache(fetcher, db, cfg.DataDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -94,18 +88,6 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
grounder, err := recipes.NewGrounder(db, search, fetcher, photos, normalizer(ai, guard))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
dinnerSvc, err := dinners.NewService(db, grounder, runner, logger)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -123,7 +105,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, runner, photos, frontend.Assets())
|
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, runner, photos, frontend.Assets())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -139,12 +121,6 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
if err := runner.Start(jobsCtx); err != nil {
|
if err := runner.Start(jobsCtx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// With those jobs failed, a dinner still pending with no grounding job
|
|
||||||
// queued or running is failed as interrupted, before any request can
|
|
||||||
// create one.
|
|
||||||
if _, err := dinnerSvc.FailInterruptedGroundings(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
Handler: router,
|
Handler: router,
|
||||||
@@ -226,7 +202,7 @@ func seedDefaults(ctx context.Context, db *store.Store, defaultTZ string) error
|
|||||||
return household.SeedNotificationPrefs(ctx, db)
|
return household.SeedNotificationPrefs(ctx, db)
|
||||||
}
|
}
|
||||||
|
|
||||||
// jobKinds enqueues the framework's job kinds.
|
// jobKinds enqueues every job kind the server runs.
|
||||||
type jobKinds struct {
|
type jobKinds struct {
|
||||||
builtins *jobs.Builtins
|
builtins *jobs.Builtins
|
||||||
}
|
}
|
||||||
@@ -245,9 +221,8 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
|
|||||||
return runner, kinds, nil
|
return runner, kinds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerJobKinds registers the framework's job kinds on runner. A feature
|
// registerJobKinds registers every job kind's handler on runner. Each
|
||||||
// service registers its own kinds when it is built, before the runner
|
// feature's job kinds are registered here.
|
||||||
// starts (dinners.NewService registers dinners.KindGroundDinner).
|
|
||||||
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
||||||
builtins, err := jobs.RegisterBuiltins(runner)
|
builtins, err := jobs.RegisterBuiltins(runner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -256,18 +231,6 @@ func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
|||||||
return &jobKinds{builtins: builtins}, nil
|
return &jobKinds{builtins: builtins}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizer runs normalize_recipe with the AI settings current at each
|
|
||||||
// run, sharing guard.
|
|
||||||
func normalizer(ai *aisettings.Service, guard *harness.Guard) recipes.Normalizer {
|
|
||||||
return func(ctx context.Context, scope string, in skills.RecipeText) (skills.NormalizedRecipe, error) {
|
|
||||||
cfg, err := ai.HarnessConfig(ctx, guard)
|
|
||||||
if err != nil {
|
|
||||||
return skills.NormalizedRecipe{}, err
|
|
||||||
}
|
|
||||||
return skills.NormalizeRecipe(ctx, cfg, scope, in)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// logListening logs the startup line. Only the public URL's origin is logged
|
// logListening logs the startup line. Only the public URL's origin is logged
|
||||||
// so no credential-bearing URL component can reach the log.
|
// so no credential-bearing URL component can reach the log.
|
||||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||||
|
|||||||
+2
-64
@@ -96,7 +96,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
|
|||||||
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
|
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
|
||||||
| `recipe_steps` | recipe_id, position, text. |
|
| `recipe_steps` | recipe_id, position, text. |
|
||||||
| `photos` | source_url, local_path, width, height, sha256, fetched_at. |
|
| `photos` | source_url, local_path, width, height, sha256, fetched_at. |
|
||||||
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), 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. |
|
| `suggestion_batches` | trigger, status, ai_run_id. |
|
||||||
| `suggestions` | dinner_id, batch_id, reason, cited_regular_ids (JSON), status, decided_at. |
|
| `suggestions` | dinner_id, batch_id, reason, cited_regular_ids (JSON), status, decided_at. |
|
||||||
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. |
|
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. |
|
||||||
@@ -178,65 +178,6 @@ automatic** (D-021).
|
|||||||
restores `household.DefaultPersonaPrompt` (CTXD PROMPTS.MD §4). No
|
restores `household.DefaultPersonaPrompt` (CTXD PROMPTS.MD §4). No
|
||||||
response holds the key. `Service.HarnessConfig` reads the settings for
|
response holds the key. `Service.HarnessConfig` reads the settings for
|
||||||
each skill run, so a saved change applies to the next run.
|
each skill run, so a saved change applies to the next run.
|
||||||
- Dinners (`dinners.Service`). `POST /api/v1/dinners` adds a Regular from
|
|
||||||
exactly one of `{name}` or `{recipe_url}` (an https:// link, kept in
|
|
||||||
`recipe_url` for grounding) with status and origin `regular` and
|
|
||||||
grounding_status `pending`, answering 201 with the dinner. A link's
|
|
||||||
dinner is named from its last path segment (separators become spaces,
|
|
||||||
an extension and all-digit words are dropped: `…/12345/beef-stew/` is
|
|
||||||
"Beef stew"); a link that names nothing is 400 — add it by name. A name
|
|
||||||
is trimmed, at most 120 characters and must keep a word after
|
|
||||||
`harness.Normalize`, which sets `normalized_name`. A name
|
|
||||||
`harness.Similar` to an unarchived dinner's, or a link one already has,
|
|
||||||
is 409 `duplicate_dinner`; archived names may be added again.
|
|
||||||
`GET /api/v1/dinners?status=regular|quest_log` returns
|
|
||||||
`{dinners: [...]}` ordered by normalized name, each with `photo_url`
|
|
||||||
(`/media/photos/{photo_id}-400.jpg`, or null) and `grounding_status`;
|
|
||||||
any other status is 400. `PATCH /api/v1/dinners/{id}` edits `name`
|
|
||||||
(deduplicated against the other unarchived dinners), `method`
|
|
||||||
(`stovetop` / `oven` / `crockpot` / `grill` / `other`), `minutes_total`
|
|
||||||
(1–1440) and `difficulty` (`easy` / `medium` / `hard`); invalid values
|
|
||||||
are 400 `invalid_dinner` and change nothing. `DELETE /api/v1/dinners/{id}`
|
|
||||||
sets status `archived` and keeps the row; `POST
|
|
||||||
/api/v1/dinners/{id}/favorite` toggles `is_favorite`. Every write records
|
|
||||||
the session's subject as `updated_by`; an unknown id is 404.
|
|
||||||
- Dinner grounding. `POST /api/v1/dinners` also enqueues a `ground_dinner`
|
|
||||||
job and answers with its `grounding_job_id` without waiting for it
|
|
||||||
(`GET /api/v1/jobs/{id}` reports it); if the job cannot be enqueued it
|
|
||||||
still answers 201, with grounding_status `failed` (`grounding_error`)
|
|
||||||
and `grounding_job_id` null. The job grounds the dinner's
|
|
||||||
`recipe_url` directly with `GroundURL`, else its name with `Ground`
|
|
||||||
(see Recipe grounding), and sets grounding_status `grounded` with the
|
|
||||||
recipe's recipe_id, photo_id, method, minutes_total and difficulty, or
|
|
||||||
`failed` with `grounding_error` — also the job's failure code — one of
|
|
||||||
`no_recipe_found`, `recipe_unusable` (the pasted link), `search_not_configured`,
|
|
||||||
`search_failed`, `ai_failed`, `interrupted` (the server stopped) or
|
|
||||||
`grounding_error`. It only writes a dinner still `pending`, so a fix-up
|
|
||||||
made meanwhile wins. At boot, after the job runner fails the jobs a
|
|
||||||
previous process left running, every dinner still `pending` with no
|
|
||||||
`ground_dinner` job queued or running is set `failed` with `interrupted`,
|
|
||||||
so no dinner stays pending with nothing left to ground it.
|
|
||||||
`GET /api/v1/dinners/{id}/recipe-candidates`
|
|
||||||
searches for the dinner's name and returns `{candidates: [{candidate_id,
|
|
||||||
title, site_name, photo_url, minutes}]}`, at most 3, ranked as `Ground`
|
|
||||||
ranks them; it fetches pages but downloads no photo and runs no model,
|
|
||||||
so `photo_url` is set only for a page already stored, and `candidate_id`
|
|
||||||
is the page's URL. `POST /api/v1/dinners/{id}/ground` takes exactly one
|
|
||||||
of `{candidate_id}`, `{recipe_url}` (both grounded with `GroundURL`,
|
|
||||||
setting `grounded`) or `{manual: {ingredients[], steps[], minutes,
|
|
||||||
method}}` (1–100 ingredient lines, up to 100 steps, each non-blank and at
|
|
||||||
most 500 characters; minutes 1–1440). A manual recipe is stored as typed
|
|
||||||
— no model runs — as a `recipes` row whose source_url is
|
|
||||||
`mealquest:manual:{recipe_id}` and whose raw_jsonld is a schema.org
|
|
||||||
Recipe built from it, and sets `manual` with its method and minutes, no
|
|
||||||
photo and the dinner's difficulty unchanged. It answers 200 with the
|
|
||||||
dinner; an invalid body is 400, an unknown dinner 404, a page that cannot
|
|
||||||
ground it 422 `ungroundable` (the dinner is unchanged), recipe search not
|
|
||||||
set up 409 `not_configured`, a search or model failure 502
|
|
||||||
`search_failed` / `ai_failed` (only the reason code is logged, never the
|
|
||||||
provider's text), and grounding a candidate or link that runs past 90
|
|
||||||
seconds (fetch, photo and `normalize_recipe`, all in the request) 504
|
|
||||||
`ground_timeout`, leaving the dinner unchanged.
|
|
||||||
- `/health` — liveness.
|
- `/health` — liveness.
|
||||||
- Everything else serves the embedded PWA with SPA fallback.
|
- Everything else serves the embedded PWA with SPA fallback.
|
||||||
|
|
||||||
@@ -405,10 +346,7 @@ automatic** (D-021).
|
|||||||
Rank's per-candidate reasons, photo or normalize failures). Failures
|
Rank's per-candidate reasons, photo or normalize failures). Failures
|
||||||
that are not the candidates' — search, model provider, database — are
|
that are not the candidates' — search, model provider, database — are
|
||||||
returned as they are. `GroundURL(ctx, url)` does the same for one pasted
|
returned as they are. `GroundURL(ctx, url)` does the same for one pasted
|
||||||
URL. `Candidates(ctx, dishName, query, n)` returns the first n of the
|
URL. `GroundMany` grounds items 3 at a time and returns one result per
|
||||||
candidates `Ground` would try, without grounding any. Search failures
|
|
||||||
wrap `recipes.ErrSearchFailed` and model provider failures
|
|
||||||
`recipes.ErrNormalizeFailed`. `GroundMany` grounds items 3 at a time and returns one result per
|
|
||||||
item, so one failure never affects its siblings. A recipe is usable only
|
item, so one failure never affects its siblings. A recipe is usable only
|
||||||
with ingredients, an image and, if given, a positive total time.
|
with ingredients, an image and, if given, a positive total time.
|
||||||
|
|
||||||
|
|||||||
+1
-31
@@ -106,22 +106,13 @@ func TestDinners(t *testing.T) {
|
|||||||
Cuisine: str("Mexican"),
|
Cuisine: str("Mexican"),
|
||||||
IsFavorite: 0,
|
IsFavorite: 0,
|
||||||
RecipeID: str(recipe.ID),
|
RecipeID: str(recipe.ID),
|
||||||
RecipeUrl: str("https://example.com/tacos"),
|
|
||||||
PhotoID: str(photo.ID),
|
PhotoID: str(photo.ID),
|
||||||
GroundingStatus: "grounded",
|
GroundingStatus: "grounded",
|
||||||
UpdatedBy: str("alex@example.com"),
|
UpdatedBy: str("alex@example.com"),
|
||||||
CreatedAt: created,
|
CreatedAt: created,
|
||||||
UpdatedAt: created,
|
UpdatedAt: created,
|
||||||
}
|
}
|
||||||
want := db.Dinner{
|
want := db.Dinner(create)
|
||||||
ID: create.ID, Name: create.Name, NormalizedName: create.NormalizedName,
|
|
||||||
Status: create.Status, Origin: create.Origin, Method: create.Method,
|
|
||||||
MinutesTotal: create.MinutesTotal, Difficulty: create.Difficulty,
|
|
||||||
Cuisine: create.Cuisine, IsFavorite: create.IsFavorite, RecipeID: create.RecipeID,
|
|
||||||
PhotoID: create.PhotoID, GroundingStatus: create.GroundingStatus,
|
|
||||||
UpdatedBy: create.UpdatedBy, CreatedAt: created, UpdatedAt: created,
|
|
||||||
RecipeUrl: create.RecipeUrl,
|
|
||||||
}
|
|
||||||
got, err := q.CreateDinner(ctx, create)
|
got, err := q.CreateDinner(ctx, create)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateDinner: %v", err)
|
t.Fatalf("CreateDinner: %v", err)
|
||||||
@@ -206,7 +197,6 @@ func TestDinners(t *testing.T) {
|
|||||||
Cuisine: update.Cuisine, IsFavorite: 0, RecipeID: update.RecipeID,
|
Cuisine: update.Cuisine, IsFavorite: 0, RecipeID: update.RecipeID,
|
||||||
PhotoID: update.PhotoID, GroundingStatus: update.GroundingStatus,
|
PhotoID: update.PhotoID, GroundingStatus: update.GroundingStatus,
|
||||||
UpdatedBy: update.UpdatedBy, CreatedAt: created, UpdatedAt: updated,
|
UpdatedBy: update.UpdatedBy, CreatedAt: created, UpdatedAt: updated,
|
||||||
RecipeUrl: create.RecipeUrl, // UpdateDinner leaves the recipe link
|
|
||||||
}
|
}
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("UpdateDinner returned %+v, want %+v", got, want)
|
t.Fatalf("UpdateDinner returned %+v, want %+v", got, want)
|
||||||
@@ -237,17 +227,6 @@ func TestDinners(t *testing.T) {
|
|||||||
_, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ID: "missing", IsFavorite: 1, UpdatedAt: updated})
|
_, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ID: "missing", IsFavorite: 1, UpdatedAt: updated})
|
||||||
mustNoRows(t, "SetDinnerFavorite missing", err)
|
mustNoRows(t, "SetDinnerFavorite missing", err)
|
||||||
|
|
||||||
for _, wantFav := range []int64{1, 0} {
|
|
||||||
got, err = q.ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{
|
|
||||||
ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: updated,
|
|
||||||
})
|
|
||||||
if err != nil || got.IsFavorite != wantFav || got.UpdatedBy != str("alex@example.com") {
|
|
||||||
t.Fatalf("ToggleDinnerFavorite = %+v, %v; want is_favorite %d", got, err, wantFav)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, err = q.ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{ID: "missing", UpdatedAt: updated})
|
|
||||||
mustNoRows(t, "ToggleDinnerFavorite missing", err)
|
|
||||||
|
|
||||||
const archivedAt = "2026-10-05T12:00:00.000Z"
|
const archivedAt = "2026-10-05T12:00:00.000Z"
|
||||||
got, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{
|
got, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{
|
||||||
ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: archivedAt,
|
ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: archivedAt,
|
||||||
@@ -261,15 +240,6 @@ func TestDinners(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assertIDs(t, "archived", listIDs("archived"), create.ID)
|
assertIDs(t, "archived", listIDs("archived"), create.ID)
|
||||||
assertIDs(t, "tried after archive", listIDs("tried"))
|
assertIDs(t, "tried after archive", listIDs("tried"))
|
||||||
unarchived, err := q.ListUnarchivedDinners(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListUnarchivedDinners: %v", err)
|
|
||||||
}
|
|
||||||
var unarchivedIDs []string
|
|
||||||
for _, d := range unarchived {
|
|
||||||
unarchivedIDs = append(unarchivedIDs, d.ID)
|
|
||||||
}
|
|
||||||
assertIDs(t, "unarchived", unarchivedIDs, other.ID, third.ID)
|
|
||||||
_, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt})
|
_, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt})
|
||||||
mustNoRows(t, "ArchiveDinner missing", err)
|
mustNoRows(t, "ArchiveDinner missing", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-276
@@ -16,7 +16,7 @@ SET status = 'archived',
|
|||||||
updated_by = ?,
|
updated_by = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
|
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 {
|
type ArchiveDinnerParams struct {
|
||||||
@@ -46,8 +46,6 @@ func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (D
|
|||||||
&i.UpdatedBy,
|
&i.UpdatedBy,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.RecipeUrl,
|
|
||||||
&i.GroundingError,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -55,14 +53,14 @@ func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (D
|
|||||||
const createDinner = `-- name: CreateDinner :one
|
const createDinner = `-- name: CreateDinner :one
|
||||||
INSERT INTO dinners (
|
INSERT INTO dinners (
|
||||||
id, name, normalized_name, status, origin, method, minutes_total,
|
id, name, normalized_name, status, origin, method, minutes_total,
|
||||||
difficulty, cuisine, is_favorite, recipe_id, recipe_url, photo_id,
|
difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status,
|
||||||
grounding_status, updated_by, created_at, updated_at
|
updated_by, created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?, ?, ?,
|
||||||
?, ?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?, ?,
|
||||||
?, ?, ?, ?
|
?, ?, ?
|
||||||
)
|
)
|
||||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
|
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 {
|
type CreateDinnerParams struct {
|
||||||
@@ -77,7 +75,6 @@ type CreateDinnerParams struct {
|
|||||||
Cuisine sql.NullString
|
Cuisine sql.NullString
|
||||||
IsFavorite int64
|
IsFavorite int64
|
||||||
RecipeID sql.NullString
|
RecipeID sql.NullString
|
||||||
RecipeUrl sql.NullString
|
|
||||||
PhotoID sql.NullString
|
PhotoID sql.NullString
|
||||||
GroundingStatus string
|
GroundingStatus string
|
||||||
UpdatedBy sql.NullString
|
UpdatedBy sql.NullString
|
||||||
@@ -98,7 +95,6 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din
|
|||||||
arg.Cuisine,
|
arg.Cuisine,
|
||||||
arg.IsFavorite,
|
arg.IsFavorite,
|
||||||
arg.RecipeID,
|
arg.RecipeID,
|
||||||
arg.RecipeUrl,
|
|
||||||
arg.PhotoID,
|
arg.PhotoID,
|
||||||
arg.GroundingStatus,
|
arg.GroundingStatus,
|
||||||
arg.UpdatedBy,
|
arg.UpdatedBy,
|
||||||
@@ -123,70 +119,12 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din
|
|||||||
&i.UpdatedBy,
|
&i.UpdatedBy,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.RecipeUrl,
|
|
||||||
&i.GroundingError,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const failOrphanedPendingDinnerGroundings = `-- name: FailOrphanedPendingDinnerGroundings :execrows
|
|
||||||
UPDATE dinners
|
|
||||||
SET grounding_status = 'failed',
|
|
||||||
grounding_error = ?1,
|
|
||||||
updated_at = ?2
|
|
||||||
WHERE grounding_status = 'pending'
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM jobs
|
|
||||||
WHERE jobs.kind = ?3
|
|
||||||
AND jobs.status IN ('queued', 'running')
|
|
||||||
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
|
|
||||||
)
|
|
||||||
`
|
|
||||||
|
|
||||||
type FailOrphanedPendingDinnerGroundingsParams struct {
|
|
||||||
GroundingError sql.NullString
|
|
||||||
UpdatedAt string
|
|
||||||
Kind string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fails every dinner still pending whose grounding job is no longer queued
|
|
||||||
// or running: the process stopped before the job, or before its handler
|
|
||||||
// recorded a result. Run at boot, after the job runner fails the jobs a
|
|
||||||
// previous process left running.
|
|
||||||
func (q *Queries) FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error) {
|
|
||||||
result, err := q.db.ExecContext(ctx, failOrphanedPendingDinnerGroundings, arg.GroundingError, arg.UpdatedAt, arg.Kind)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return result.RowsAffected()
|
|
||||||
}
|
|
||||||
|
|
||||||
const failPendingDinnerGrounding = `-- name: FailPendingDinnerGrounding :execrows
|
|
||||||
UPDATE dinners
|
|
||||||
SET grounding_status = 'failed',
|
|
||||||
grounding_error = ?,
|
|
||||||
updated_at = ?
|
|
||||||
WHERE id = ? AND grounding_status = 'pending'
|
|
||||||
`
|
|
||||||
|
|
||||||
type FailPendingDinnerGroundingParams struct {
|
|
||||||
GroundingError sql.NullString
|
|
||||||
UpdatedAt string
|
|
||||||
ID string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Records why the background grounding of a dinner still pending failed; a
|
|
||||||
// dinner grounded some other way meanwhile is left as it is (0 rows).
|
|
||||||
func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error) {
|
|
||||||
result, err := q.db.ExecContext(ctx, failPendingDinnerGrounding, arg.GroundingError, arg.UpdatedAt, arg.ID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return result.RowsAffected()
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDinner = `-- name: GetDinner :one
|
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, grounding_error 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 = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -210,120 +148,12 @@ func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) {
|
|||||||
&i.UpdatedBy,
|
&i.UpdatedBy,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.RecipeUrl,
|
|
||||||
&i.GroundingError,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const groundDinner = `-- name: GroundDinner :one
|
|
||||||
UPDATE dinners
|
|
||||||
SET recipe_id = ?,
|
|
||||||
photo_id = ?,
|
|
||||||
method = ?,
|
|
||||||
minutes_total = ?,
|
|
||||||
difficulty = ?,
|
|
||||||
grounding_status = ?,
|
|
||||||
grounding_error = NULL,
|
|
||||||
updated_by = ?,
|
|
||||||
updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
|
|
||||||
`
|
|
||||||
|
|
||||||
type GroundDinnerParams struct {
|
|
||||||
RecipeID sql.NullString
|
|
||||||
PhotoID sql.NullString
|
|
||||||
Method sql.NullString
|
|
||||||
MinutesTotal sql.NullInt64
|
|
||||||
Difficulty sql.NullString
|
|
||||||
GroundingStatus string
|
|
||||||
UpdatedBy sql.NullString
|
|
||||||
UpdatedAt string
|
|
||||||
ID string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Records a dinner's grounding (grounded or manual) and clears any
|
|
||||||
// grounding_error. An unknown id returns sql.ErrNoRows.
|
|
||||||
func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, groundDinner,
|
|
||||||
arg.RecipeID,
|
|
||||||
arg.PhotoID,
|
|
||||||
arg.Method,
|
|
||||||
arg.MinutesTotal,
|
|
||||||
arg.Difficulty,
|
|
||||||
arg.GroundingStatus,
|
|
||||||
arg.UpdatedBy,
|
|
||||||
arg.UpdatedAt,
|
|
||||||
arg.ID,
|
|
||||||
)
|
|
||||||
var i Dinner
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.NormalizedName,
|
|
||||||
&i.Status,
|
|
||||||
&i.Origin,
|
|
||||||
&i.Method,
|
|
||||||
&i.MinutesTotal,
|
|
||||||
&i.Difficulty,
|
|
||||||
&i.Cuisine,
|
|
||||||
&i.IsFavorite,
|
|
||||||
&i.RecipeID,
|
|
||||||
&i.PhotoID,
|
|
||||||
&i.GroundingStatus,
|
|
||||||
&i.UpdatedBy,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
&i.RecipeUrl,
|
|
||||||
&i.GroundingError,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
|
|
||||||
UPDATE dinners
|
|
||||||
SET recipe_id = ?,
|
|
||||||
photo_id = ?,
|
|
||||||
method = ?,
|
|
||||||
minutes_total = ?,
|
|
||||||
difficulty = ?,
|
|
||||||
grounding_status = 'grounded',
|
|
||||||
grounding_error = NULL,
|
|
||||||
updated_at = ?
|
|
||||||
WHERE id = ? AND grounding_status = 'pending'
|
|
||||||
`
|
|
||||||
|
|
||||||
type GroundPendingDinnerParams struct {
|
|
||||||
RecipeID sql.NullString
|
|
||||||
PhotoID sql.NullString
|
|
||||||
Method sql.NullString
|
|
||||||
MinutesTotal sql.NullInt64
|
|
||||||
Difficulty sql.NullString
|
|
||||||
UpdatedAt string
|
|
||||||
ID string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Records the background grounding of a dinner still pending; a dinner
|
|
||||||
// grounded some other way meanwhile is left as it is (0 rows).
|
|
||||||
func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error) {
|
|
||||||
result, err := q.db.ExecContext(ctx, groundPendingDinner,
|
|
||||||
arg.RecipeID,
|
|
||||||
arg.PhotoID,
|
|
||||||
arg.Method,
|
|
||||||
arg.MinutesTotal,
|
|
||||||
arg.Difficulty,
|
|
||||||
arg.UpdatedAt,
|
|
||||||
arg.ID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return result.RowsAffected()
|
|
||||||
}
|
|
||||||
|
|
||||||
const listDinnersByStatus = `-- name: ListDinnersByStatus :many
|
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, grounding_error 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 = ?
|
WHERE status = ?
|
||||||
ORDER BY normalized_name, id
|
ORDER BY normalized_name, id
|
||||||
`
|
`
|
||||||
@@ -354,57 +184,6 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
|
|||||||
&i.UpdatedBy,
|
&i.UpdatedBy,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.RecipeUrl,
|
|
||||||
&i.GroundingError,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const listUnarchivedDinners = `-- name: ListUnarchivedDinners :many
|
|
||||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error FROM dinners
|
|
||||||
WHERE status <> 'archived'
|
|
||||||
ORDER BY id
|
|
||||||
`
|
|
||||||
|
|
||||||
// Every dinner a new or renamed dinner must not repeat.
|
|
||||||
func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]Dinner, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, listUnarchivedDinners)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []Dinner
|
|
||||||
for rows.Next() {
|
|
||||||
var i Dinner
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.NormalizedName,
|
|
||||||
&i.Status,
|
|
||||||
&i.Origin,
|
|
||||||
&i.Method,
|
|
||||||
&i.MinutesTotal,
|
|
||||||
&i.Difficulty,
|
|
||||||
&i.Cuisine,
|
|
||||||
&i.IsFavorite,
|
|
||||||
&i.RecipeID,
|
|
||||||
&i.PhotoID,
|
|
||||||
&i.GroundingStatus,
|
|
||||||
&i.UpdatedBy,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
&i.RecipeUrl,
|
|
||||||
&i.GroundingError,
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -425,7 +204,7 @@ SET is_favorite = ?,
|
|||||||
updated_by = ?,
|
updated_by = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
|
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 {
|
type SetDinnerFavoriteParams struct {
|
||||||
@@ -461,50 +240,6 @@ func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoritePa
|
|||||||
&i.UpdatedBy,
|
&i.UpdatedBy,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.RecipeUrl,
|
|
||||||
&i.GroundingError,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleDinnerFavorite = `-- name: ToggleDinnerFavorite :one
|
|
||||||
UPDATE dinners
|
|
||||||
SET is_favorite = 1 - is_favorite,
|
|
||||||
updated_by = ?,
|
|
||||||
updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
|
|
||||||
`
|
|
||||||
|
|
||||||
type ToggleDinnerFavoriteParams struct {
|
|
||||||
UpdatedBy sql.NullString
|
|
||||||
UpdatedAt string
|
|
||||||
ID string
|
|
||||||
}
|
|
||||||
|
|
||||||
// An unknown id returns sql.ErrNoRows.
|
|
||||||
func (q *Queries) ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, toggleDinnerFavorite, arg.UpdatedBy, arg.UpdatedAt, arg.ID)
|
|
||||||
var i Dinner
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.NormalizedName,
|
|
||||||
&i.Status,
|
|
||||||
&i.Origin,
|
|
||||||
&i.Method,
|
|
||||||
&i.MinutesTotal,
|
|
||||||
&i.Difficulty,
|
|
||||||
&i.Cuisine,
|
|
||||||
&i.IsFavorite,
|
|
||||||
&i.RecipeID,
|
|
||||||
&i.PhotoID,
|
|
||||||
&i.GroundingStatus,
|
|
||||||
&i.UpdatedBy,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
&i.RecipeUrl,
|
|
||||||
&i.GroundingError,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -525,7 +260,7 @@ SET name = ?,
|
|||||||
updated_by = ?,
|
updated_by = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
|
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 {
|
type UpdateDinnerParams struct {
|
||||||
@@ -581,8 +316,6 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
|
|||||||
&i.UpdatedBy,
|
&i.UpdatedBy,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.RecipeUrl,
|
|
||||||
&i.GroundingError,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,8 +67,6 @@ type Dinner struct {
|
|||||||
UpdatedBy sql.NullString
|
UpdatedBy sql.NullString
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
RecipeUrl sql.NullString
|
|
||||||
GroundingError sql.NullString
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type HouseholdSetting struct {
|
type HouseholdSetting struct {
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ type Querier interface {
|
|||||||
CreateAIRun(ctx context.Context, arg CreateAIRunParams) error
|
CreateAIRun(ctx context.Context, arg CreateAIRunParams) error
|
||||||
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
|
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
|
||||||
CreateJob(ctx context.Context, arg CreateJobParams) error
|
CreateJob(ctx context.Context, arg CreateJobParams) error
|
||||||
// A recipe the household typed in. Its source_url is a mealquest:manual:
|
|
||||||
// URN rather than a page, so it never matches a fetched page.
|
|
||||||
CreateManualRecipe(ctx context.Context, arg CreateManualRecipeParams) (Recipe, error)
|
|
||||||
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
|
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
|
||||||
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
||||||
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
||||||
@@ -34,14 +31,6 @@ type Querier interface {
|
|||||||
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||||
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||||
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
|
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
|
||||||
// Fails every dinner still pending whose grounding job is no longer queued
|
|
||||||
// or running: the process stopped before the job, or before its handler
|
|
||||||
// recorded a result. Run at boot, after the job runner fails the jobs a
|
|
||||||
// previous process left running.
|
|
||||||
FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error)
|
|
||||||
// Records why the background grounding of a dinner still pending failed; a
|
|
||||||
// dinner grounded some other way meanwhile is left as it is (0 rows).
|
|
||||||
FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error)
|
|
||||||
// Fails every job left running, at boot before any worker starts.
|
// Fails every job left running, at boot before any worker starts.
|
||||||
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
|
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
|
||||||
// Returns sql.ErrNoRows until the row exists.
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
@@ -62,12 +51,6 @@ type Querier interface {
|
|||||||
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
|
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
|
||||||
// Returns sql.ErrNoRows until the row exists.
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
|
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
|
||||||
// Records a dinner's grounding (grounded or manual) and clears any
|
|
||||||
// grounding_error. An unknown id returns sql.ErrNoRows.
|
|
||||||
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
|
|
||||||
// Records the background grounding of a dinner still pending; a dinner
|
|
||||||
// grounded some other way meanwhile is left as it is (0 rows).
|
|
||||||
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
|
|
||||||
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
||||||
// The planned dinners dated from from_date up to but not including
|
// The planned dinners dated from from_date up to but not including
|
||||||
// to_date, oldest first. Days with no dinner are left out.
|
// to_date, oldest first. Days with no dinner are left out.
|
||||||
@@ -77,16 +60,12 @@ type Querier interface {
|
|||||||
ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error)
|
ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error)
|
||||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||||
// Every dinner a new or renamed dinner must not repeat.
|
|
||||||
ListUnarchivedDinners(ctx context.Context) ([]Dinner, error)
|
|
||||||
// Stores a partial result of a running job.
|
// Stores a partial result of a running job.
|
||||||
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
||||||
// An unknown id returns sql.ErrNoRows.
|
// An unknown id returns sql.ErrNoRows.
|
||||||
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
||||||
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
|
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
|
||||||
// An unknown id returns sql.ErrNoRows.
|
// An unknown id returns sql.ErrNoRows.
|
||||||
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
|
|
||||||
// An unknown id returns sql.ErrNoRows.
|
|
||||||
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
|
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
|
||||||
// Creates the single row (id 1) or replaces every column except id and
|
// Creates the single row (id 1) or replaces every column except id and
|
||||||
// created_at.
|
// created_at.
|
||||||
|
|||||||
@@ -10,67 +10,6 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
)
|
)
|
||||||
|
|
||||||
const createManualRecipe = `-- name: CreateManualRecipe :one
|
|
||||||
INSERT INTO recipes (
|
|
||||||
id, source_url, title, total_minutes, raw_jsonld, method,
|
|
||||||
minutes_total, fetched_at, created_at, updated_at
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?,
|
|
||||||
?, ?, ?, ?
|
|
||||||
)
|
|
||||||
RETURNING id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at, photo_id, method, difficulty, minutes_total
|
|
||||||
`
|
|
||||||
|
|
||||||
type CreateManualRecipeParams struct {
|
|
||||||
ID string
|
|
||||||
SourceUrl string
|
|
||||||
Title string
|
|
||||||
TotalMinutes sql.NullInt64
|
|
||||||
RawJsonld string
|
|
||||||
Method sql.NullString
|
|
||||||
MinutesTotal sql.NullInt64
|
|
||||||
FetchedAt string
|
|
||||||
CreatedAt string
|
|
||||||
UpdatedAt string
|
|
||||||
}
|
|
||||||
|
|
||||||
// A recipe the household typed in. Its source_url is a mealquest:manual:
|
|
||||||
// URN rather than a page, so it never matches a fetched page.
|
|
||||||
func (q *Queries) CreateManualRecipe(ctx context.Context, arg CreateManualRecipeParams) (Recipe, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, createManualRecipe,
|
|
||||||
arg.ID,
|
|
||||||
arg.SourceUrl,
|
|
||||||
arg.Title,
|
|
||||||
arg.TotalMinutes,
|
|
||||||
arg.RawJsonld,
|
|
||||||
arg.Method,
|
|
||||||
arg.MinutesTotal,
|
|
||||||
arg.FetchedAt,
|
|
||||||
arg.CreatedAt,
|
|
||||||
arg.UpdatedAt,
|
|
||||||
)
|
|
||||||
var i Recipe
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.SourceUrl,
|
|
||||||
&i.SiteName,
|
|
||||||
&i.Title,
|
|
||||||
&i.TotalMinutes,
|
|
||||||
&i.PrepMinutes,
|
|
||||||
&i.CookMinutes,
|
|
||||||
&i.Yield,
|
|
||||||
&i.RawJsonld,
|
|
||||||
&i.FetchedAt,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
&i.PhotoID,
|
|
||||||
&i.Method,
|
|
||||||
&i.Difficulty,
|
|
||||||
&i.MinutesTotal,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const createRecipeIngredient = `-- name: CreateRecipeIngredient :exec
|
const createRecipeIngredient = `-- name: CreateRecipeIngredient :exec
|
||||||
INSERT INTO recipe_ingredients (
|
INSERT INTO recipe_ingredients (
|
||||||
id, recipe_id, position, raw_text, name, qty, unit, note,
|
id, recipe_id, position, raw_text, name, qty, unit, note,
|
||||||
|
|||||||
@@ -1,535 +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, and grounding each dinner in a recipe. Names are deduplicated with the harness's dinner-name rules,
|
|
||||||
// so the Regulars the household types and the Side quests the AI suggests
|
|
||||||
// are compared the same way.
|
|
||||||
package dinners
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"net/url"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Dinner statuses (the dinners.status column).
|
|
||||||
const (
|
|
||||||
StatusSuggested = "suggested"
|
|
||||||
StatusQuestLog = "quest_log"
|
|
||||||
StatusTried = "tried"
|
|
||||||
StatusRegular = "regular"
|
|
||||||
StatusArchived = "archived"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Limits on what a member may enter.
|
|
||||||
const (
|
|
||||||
MaxNameChars = 120
|
|
||||||
MaxRecipeURL = 2048
|
|
||||||
MaxMinutes = 1440 // a day; a crock-pot dinner fits
|
|
||||||
photoListWidth = 400 // the stored photo size lists show
|
|
||||||
)
|
|
||||||
|
|
||||||
// Methods and Difficulties are the values the dinners table allows.
|
|
||||||
var (
|
|
||||||
Methods = []string{"stovetop", "oven", "crockpot", "grill", "other"}
|
|
||||||
Difficulties = []string{"easy", "medium", "hard"}
|
|
||||||
)
|
|
||||||
|
|
||||||
// listable are the statuses List answers for.
|
|
||||||
var listable = []string{StatusRegular, StatusQuestLog}
|
|
||||||
|
|
||||||
// ErrNotFound is returned for an unknown dinner id.
|
|
||||||
var ErrNotFound = errors.New("dinner not found")
|
|
||||||
|
|
||||||
// Dinner is a dinner as the API shows it.
|
|
||||||
type Dinner struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Origin string `json:"origin"`
|
|
||||||
Method *string `json:"method"`
|
|
||||||
MinutesTotal *int64 `json:"minutes_total"`
|
|
||||||
Difficulty *string `json:"difficulty"`
|
|
||||||
Cuisine *string `json:"cuisine"`
|
|
||||||
IsFavorite bool `json:"is_favorite"`
|
|
||||||
RecipeID *string `json:"recipe_id"`
|
|
||||||
RecipeURL *string `json:"recipe_url"`
|
|
||||||
PhotoURL *string `json:"photo_url"`
|
|
||||||
GroundingStatus string `json:"grounding_status"`
|
|
||||||
// GroundingError is why grounding failed, one of the Reason codes;
|
|
||||||
// null unless GroundingStatus is failed.
|
|
||||||
GroundingError *string `json:"grounding_error"`
|
|
||||||
UpdatedBy *string `json:"updated_by"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRegular is a Regular to add: exactly one of Name and RecipeURL.
|
|
||||||
type NewRegular struct {
|
|
||||||
Name *string
|
|
||||||
RecipeURL *string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Patch edits a dinner. Nil fields are left as they are.
|
|
||||||
type Patch struct {
|
|
||||||
Name *string
|
|
||||||
Method *string
|
|
||||||
MinutesTotal *int
|
|
||||||
Difficulty *string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Problem is one invalid field, named by its JSON key.
|
|
||||||
type Problem struct {
|
|
||||||
Field string
|
|
||||||
Reason string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidationError lists every invalid field of a request.
|
|
||||||
type ValidationError struct {
|
|
||||||
Problems []Problem
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ValidationError) Error() string {
|
|
||||||
parts := make([]string, len(e.Problems))
|
|
||||||
for i, p := range e.Problems {
|
|
||||||
parts[i] = p.Field + ": " + p.Reason
|
|
||||||
}
|
|
||||||
return "invalid dinner: " + strings.Join(parts, "; ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func invalid(field, reason string) *ValidationError {
|
|
||||||
return &ValidationError{Problems: []Problem{{Field: field, Reason: reason}}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DuplicateError is returned when a new or renamed dinner repeats an
|
|
||||||
// unarchived one: its name is harness.Similar, or it has the same recipe
|
|
||||||
// link.
|
|
||||||
type DuplicateError struct {
|
|
||||||
Existing Dinner
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *DuplicateError) Error() string {
|
|
||||||
return fmt.Sprintf("%q is already a dinner (%s)", e.Existing.Name, e.Existing.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service reads and changes dinners.
|
|
||||||
type Service struct {
|
|
||||||
st *store.Store
|
|
||||||
grounder *recipes.Grounder
|
|
||||||
logger *slog.Logger
|
|
||||||
groundJob *jobs.Kind[GroundJob]
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewService returns a service over st that grounds dinners with grounder
|
|
||||||
// and registers the KindGroundDinner job on runner, which must not have
|
|
||||||
// started yet.
|
|
||||||
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
|
||||||
switch {
|
|
||||||
case st == nil:
|
|
||||||
return nil, errors.New("dinners: nil store")
|
|
||||||
case grounder == nil:
|
|
||||||
return nil, errors.New("dinners: nil grounder")
|
|
||||||
case runner == nil:
|
|
||||||
return nil, errors.New("dinners: nil job runner")
|
|
||||||
case logger == nil:
|
|
||||||
return nil, errors.New("dinners: nil logger")
|
|
||||||
}
|
|
||||||
s := &Service{st: st, grounder: grounder, logger: logger}
|
|
||||||
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.groundJob = kind
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Created is a new Regular with the id of the job grounding it, which is
|
|
||||||
// nil when the job could not be enqueued and the dinner's grounding is
|
|
||||||
// failed instead.
|
|
||||||
type Created struct {
|
|
||||||
Dinner
|
|
||||||
GroundingJobID *string `json:"grounding_job_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateRegular adds a Regular named by r.Name, or by the last path segment
|
|
||||||
// of r.RecipeURL (whose recipe grounding later replaces), with status and
|
|
||||||
// origin regular and grounding_status pending, recording updatedBy, and
|
|
||||||
// enqueues the KindGroundDinner job that grounds it. It returns without
|
|
||||||
// waiting for the job. A name repeating an unarchived dinner is a
|
|
||||||
// *DuplicateError. When the job cannot be enqueued, the stored dinner is
|
|
||||||
// returned with grounding_status failed (ReasonGroundingError) and no job
|
|
||||||
// id, so the household can fix its recipe by hand.
|
|
||||||
func (s *Service) CreateRegular(ctx context.Context, r NewRegular, updatedBy string) (Created, error) {
|
|
||||||
d, err := s.createRegular(ctx, r, updatedBy)
|
|
||||||
if err != nil {
|
|
||||||
return Created{}, err
|
|
||||||
}
|
|
||||||
jobID, err := s.groundJob.Enqueue(ctx, GroundJob{DinnerID: d.ID})
|
|
||||||
if err == nil {
|
|
||||||
return Created{Dinner: d, GroundingJobID: &jobID}, nil
|
|
||||||
}
|
|
||||||
// The dinner is stored; record that it will not be grounded so it is
|
|
||||||
// not left pending with no job. The error is a database error, never a
|
|
||||||
// dependency's text.
|
|
||||||
s.logger.ErrorContext(ctx, "dinner grounding not enqueued", "dinner_id", d.ID, "error", err)
|
|
||||||
write := context.WithoutCancel(ctx)
|
|
||||||
if _, err := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{
|
|
||||||
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
|
|
||||||
UpdatedAt: store.Now().String(),
|
|
||||||
ID: d.ID,
|
|
||||||
}); err != nil {
|
|
||||||
return Created{}, fmt.Errorf("dinners: record failed grounding of %s: %w", d.ID, err)
|
|
||||||
}
|
|
||||||
row, err := db.New(s.st.Reader).GetDinner(write, d.ID)
|
|
||||||
if err != nil {
|
|
||||||
return Created{}, fmt.Errorf("dinners: read %s: %w", d.ID, err)
|
|
||||||
}
|
|
||||||
return Created{Dinner: fromRow(row)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy string) (Dinner, error) {
|
|
||||||
if updatedBy == "" {
|
|
||||||
return Dinner{}, errors.New("dinners: create: no member to record as updated_by")
|
|
||||||
}
|
|
||||||
var name string
|
|
||||||
var recipeURL sql.NullString
|
|
||||||
switch {
|
|
||||||
case r.Name != nil && r.RecipeURL != nil:
|
|
||||||
return Dinner{}, invalid("name", "give a name or a recipe_url, not both")
|
|
||||||
case r.Name != nil:
|
|
||||||
n, err := checkName(*r.Name)
|
|
||||||
if err != nil {
|
|
||||||
return Dinner{}, err
|
|
||||||
}
|
|
||||||
name = n
|
|
||||||
case r.RecipeURL != nil:
|
|
||||||
u, err := checkRecipeURL(*r.RecipeURL)
|
|
||||||
if err != nil {
|
|
||||||
return Dinner{}, err
|
|
||||||
}
|
|
||||||
n, ok := nameFromURL(u)
|
|
||||||
if !ok {
|
|
||||||
return Dinner{}, invalid("recipe_url", "the link does not name a dinner; add it by name instead")
|
|
||||||
}
|
|
||||||
name = n
|
|
||||||
recipeURL = sql.NullString{String: u.String(), Valid: true}
|
|
||||||
default:
|
|
||||||
return Dinner{}, invalid("name", "give a name or a recipe_url")
|
|
||||||
}
|
|
||||||
|
|
||||||
var row db.Dinner
|
|
||||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
|
||||||
q := db.New(tx)
|
|
||||||
if err := checkUnique(ctx, q, "", name, recipeURL); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
now := store.Now().String()
|
|
||||||
var err error
|
|
||||||
row, err = q.CreateDinner(ctx, db.CreateDinnerParams{
|
|
||||||
ID: store.NewID(),
|
|
||||||
Name: name,
|
|
||||||
NormalizedName: harness.Normalize(name),
|
|
||||||
Status: StatusRegular,
|
|
||||||
Origin: "regular",
|
|
||||||
RecipeUrl: recipeURL,
|
|
||||||
GroundingStatus: "pending",
|
|
||||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("dinners: create: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return Dinner{}, err
|
|
||||||
}
|
|
||||||
return fromRow(row), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns the dinners with status, which must be regular or
|
|
||||||
// quest_log, ordered by normalized name.
|
|
||||||
func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
|
|
||||||
if !slices.Contains(listable, status) {
|
|
||||||
return nil, invalid("status", "must be one of "+strings.Join(listable, ", "))
|
|
||||||
}
|
|
||||||
rows, err := db.New(s.st.Reader).ListDinnersByStatus(ctx, status)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("dinners: list %s: %w", status, err)
|
|
||||||
}
|
|
||||||
out := make([]Dinner, len(rows))
|
|
||||||
for i, row := range rows {
|
|
||||||
out[i] = fromRow(row)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update applies p to dinner id, recording updatedBy. An invalid patch is a
|
|
||||||
// *ValidationError and a new name repeating another unarchived dinner a
|
|
||||||
// *DuplicateError; either changes nothing.
|
|
||||||
func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy string) (Dinner, error) {
|
|
||||||
if updatedBy == "" {
|
|
||||||
return Dinner{}, errors.New("dinners: update: no member to record as updated_by")
|
|
||||||
}
|
|
||||||
name, err := p.validate()
|
|
||||||
if err != nil {
|
|
||||||
return Dinner{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var row db.Dinner
|
|
||||||
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
|
||||||
q := db.New(tx)
|
|
||||||
cur, err := q.GetDinner(ctx, id)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("dinners: read %s: %w", id, err)
|
|
||||||
}
|
|
||||||
params := db.UpdateDinnerParams{
|
|
||||||
ID: cur.ID,
|
|
||||||
Name: cur.Name,
|
|
||||||
NormalizedName: cur.NormalizedName,
|
|
||||||
Status: cur.Status,
|
|
||||||
Origin: cur.Origin,
|
|
||||||
Method: cur.Method,
|
|
||||||
MinutesTotal: cur.MinutesTotal,
|
|
||||||
Difficulty: cur.Difficulty,
|
|
||||||
Cuisine: cur.Cuisine,
|
|
||||||
RecipeID: cur.RecipeID,
|
|
||||||
PhotoID: cur.PhotoID,
|
|
||||||
GroundingStatus: cur.GroundingStatus,
|
|
||||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
|
||||||
UpdatedAt: store.Now().String(),
|
|
||||||
}
|
|
||||||
if p.Name != nil {
|
|
||||||
if err := checkUnique(ctx, q, cur.ID, name, sql.NullString{}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
params.Name = name
|
|
||||||
params.NormalizedName = harness.Normalize(name)
|
|
||||||
}
|
|
||||||
if p.Method != nil {
|
|
||||||
params.Method = sql.NullString{String: *p.Method, Valid: true}
|
|
||||||
}
|
|
||||||
if p.MinutesTotal != nil {
|
|
||||||
params.MinutesTotal = sql.NullInt64{Int64: int64(*p.MinutesTotal), Valid: true}
|
|
||||||
}
|
|
||||||
if p.Difficulty != nil {
|
|
||||||
params.Difficulty = sql.NullString{String: *p.Difficulty, Valid: true}
|
|
||||||
}
|
|
||||||
row, err = q.UpdateDinner(ctx, params)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("dinners: update %s: %w", id, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return Dinner{}, err
|
|
||||||
}
|
|
||||||
return fromRow(row), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Archive sets dinner id's status to archived, recording updatedBy. The row
|
|
||||||
// is kept, so its name still counts for Side quest dedupe.
|
|
||||||
func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, error) {
|
|
||||||
if updatedBy == "" {
|
|
||||||
return Dinner{}, errors.New("dinners: archive: no member to record as updated_by")
|
|
||||||
}
|
|
||||||
row, err := db.New(s.st.Writer).ArchiveDinner(ctx, db.ArchiveDinnerParams{
|
|
||||||
ID: id,
|
|
||||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
|
||||||
UpdatedAt: store.Now().String(),
|
|
||||||
})
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return Dinner{}, ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err)
|
|
||||||
}
|
|
||||||
return fromRow(row), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToggleFavorite flips dinner id's is_favorite, recording updatedBy.
|
|
||||||
func (s *Service) ToggleFavorite(ctx context.Context, id, updatedBy string) (Dinner, error) {
|
|
||||||
if updatedBy == "" {
|
|
||||||
return Dinner{}, errors.New("dinners: favorite: no member to record as updated_by")
|
|
||||||
}
|
|
||||||
row, err := db.New(s.st.Writer).ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{
|
|
||||||
ID: id,
|
|
||||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
|
||||||
UpdatedAt: store.Now().String(),
|
|
||||||
})
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return Dinner{}, ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return Dinner{}, fmt.Errorf("dinners: favorite %s: %w", id, err)
|
|
||||||
}
|
|
||||||
return fromRow(row), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate checks every field of p and returns its trimmed name.
|
|
||||||
func (p Patch) validate() (string, error) {
|
|
||||||
var problems []Problem
|
|
||||||
var name string
|
|
||||||
if p.Name == nil && p.Method == nil && p.MinutesTotal == nil && p.Difficulty == nil {
|
|
||||||
return "", invalid("patch", "no fields to change")
|
|
||||||
}
|
|
||||||
if p.Name != nil {
|
|
||||||
n, err := checkName(*p.Name)
|
|
||||||
var verr *ValidationError
|
|
||||||
if errors.As(err, &verr) {
|
|
||||||
problems = append(problems, verr.Problems...)
|
|
||||||
}
|
|
||||||
name = n
|
|
||||||
}
|
|
||||||
if p.Method != nil && !slices.Contains(Methods, *p.Method) {
|
|
||||||
problems = append(problems, Problem{"method", "must be one of " + strings.Join(Methods, ", ")})
|
|
||||||
}
|
|
||||||
if p.MinutesTotal != nil && (*p.MinutesTotal < 1 || *p.MinutesTotal > MaxMinutes) {
|
|
||||||
problems = append(problems, Problem{"minutes_total", fmt.Sprintf("must be 1 to %d", MaxMinutes)})
|
|
||||||
}
|
|
||||||
if p.Difficulty != nil && !slices.Contains(Difficulties, *p.Difficulty) {
|
|
||||||
problems = append(problems, Problem{"difficulty", "must be one of " + strings.Join(Difficulties, ", ")})
|
|
||||||
}
|
|
||||||
if problems != nil {
|
|
||||||
return "", &ValidationError{Problems: problems}
|
|
||||||
}
|
|
||||||
return name, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkName returns raw trimmed, refusing a name that is empty, too long or
|
|
||||||
// has no words left once normalized (so it could not be deduplicated).
|
|
||||||
func checkName(raw string) (string, error) {
|
|
||||||
name := strings.TrimSpace(raw)
|
|
||||||
switch {
|
|
||||||
case name == "":
|
|
||||||
return "", invalid("name", "must not be empty")
|
|
||||||
case utf8.RuneCountInString(name) > MaxNameChars:
|
|
||||||
return "", invalid("name", fmt.Sprintf("must be at most %d characters", MaxNameChars))
|
|
||||||
case harness.Normalize(name) == "":
|
|
||||||
return "", invalid("name", "must name a dish, not only filler words")
|
|
||||||
}
|
|
||||||
return name, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkRecipeURL parses raw as an absolute https:// link to a recipe page,
|
|
||||||
// the only kind recipe grounding fetches.
|
|
||||||
func checkRecipeURL(raw string) (*url.URL, error) {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if len(raw) > MaxRecipeURL {
|
|
||||||
return nil, invalid("recipe_url", fmt.Sprintf("must be at most %d bytes", MaxRecipeURL))
|
|
||||||
}
|
|
||||||
u, err := url.Parse(raw)
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
return nil, invalid("recipe_url", "is not a URL")
|
|
||||||
case u.Scheme != "https" || u.Hostname() == "":
|
|
||||||
return nil, invalid("recipe_url", "must be an https:// link")
|
|
||||||
case u.User != nil:
|
|
||||||
return nil, invalid("recipe_url", "must not carry a user name or password")
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// nameFromURL names a dinner from the last path segment of u: separators
|
|
||||||
// become spaces, a file extension and words of only digits are dropped and
|
|
||||||
// the first letter is capitalized, so
|
|
||||||
// https://example.com/recipes/12345/chicken-tikka-masala/ is
|
|
||||||
// "Chicken tikka masala". ok is false when no usable name is left.
|
|
||||||
func nameFromURL(u *url.URL) (name string, ok bool) {
|
|
||||||
segments := strings.FieldsFunc(u.Path, func(r rune) bool { return r == '/' })
|
|
||||||
if len(segments) == 0 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
slug := segments[len(segments)-1]
|
|
||||||
if i := strings.LastIndexByte(slug, '.'); i > 0 {
|
|
||||||
slug = slug[:i]
|
|
||||||
}
|
|
||||||
var words []string
|
|
||||||
for _, w := range strings.FieldsFunc(slug, func(r rune) bool { return r == '-' || r == '_' || r == '+' || unicode.IsSpace(r) }) {
|
|
||||||
if strings.IndexFunc(w, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
|
|
||||||
words = append(words, strings.ToLower(w))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
name = strings.Join(words, " ")
|
|
||||||
if r, size := utf8.DecodeRuneInString(name); r != utf8.RuneError {
|
|
||||||
name = string(unicode.ToUpper(r)) + name[size:]
|
|
||||||
}
|
|
||||||
if _, err := checkName(name); err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return name, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkUnique returns a *DuplicateError when name is harness.Similar to an
|
|
||||||
// unarchived dinner other than self, or recipeURL is one's recipe link.
|
|
||||||
func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) error {
|
|
||||||
rows, err := q.ListUnarchivedDinners(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("dinners: list for dedupe: %w", err)
|
|
||||||
}
|
|
||||||
for _, row := range rows {
|
|
||||||
if row.ID == self {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if harness.Similar(name, row.Name) || (recipeURL.Valid && row.RecipeUrl == recipeURL) {
|
|
||||||
return &DuplicateError{Existing: fromRow(row)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PhotoURL is where the API serves a list-sized copy of photo id.
|
|
||||||
func PhotoURL(id string) string {
|
|
||||||
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, photoListWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fromRow(r db.Dinner) Dinner {
|
|
||||||
d := Dinner{
|
|
||||||
ID: r.ID,
|
|
||||||
Name: r.Name,
|
|
||||||
Status: r.Status,
|
|
||||||
Origin: r.Origin,
|
|
||||||
Method: nullString(r.Method),
|
|
||||||
Difficulty: nullString(r.Difficulty),
|
|
||||||
Cuisine: nullString(r.Cuisine),
|
|
||||||
IsFavorite: r.IsFavorite == 1,
|
|
||||||
RecipeID: nullString(r.RecipeID),
|
|
||||||
RecipeURL: nullString(r.RecipeUrl),
|
|
||||||
GroundingStatus: r.GroundingStatus,
|
|
||||||
GroundingError: nullString(r.GroundingError),
|
|
||||||
UpdatedBy: nullString(r.UpdatedBy),
|
|
||||||
CreatedAt: r.CreatedAt,
|
|
||||||
UpdatedAt: r.UpdatedAt,
|
|
||||||
}
|
|
||||||
if r.MinutesTotal.Valid {
|
|
||||||
d.MinutesTotal = &r.MinutesTotal.Int64
|
|
||||||
}
|
|
||||||
if r.PhotoID.Valid {
|
|
||||||
u := PhotoURL(r.PhotoID.String)
|
|
||||||
d.PhotoURL = &u
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
func nullString(s sql.NullString) *string {
|
|
||||||
if !s.Valid {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &s.String
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package dinners
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SetGroundTimeout sets the deadline of grounding by hand until t ends. A
|
|
||||||
// test calling it must not run in parallel.
|
|
||||||
func SetGroundTimeout(t *testing.T, d time.Duration) {
|
|
||||||
old := groundTimeout
|
|
||||||
groundTimeout = d
|
|
||||||
t.Cleanup(func() { groundTimeout = old })
|
|
||||||
}
|
|
||||||
@@ -1,497 +0,0 @@
|
|||||||
package dinners
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// KindGroundDinner is the job that grounds a new Regular in a recipe.
|
|
||||||
const KindGroundDinner = "ground_dinner"
|
|
||||||
|
|
||||||
// Grounding statuses (the dinners.grounding_status column).
|
|
||||||
const (
|
|
||||||
GroundingPending = "pending"
|
|
||||||
GroundingGrounded = "grounded"
|
|
||||||
GroundingFailed = "failed"
|
|
||||||
GroundingManual = "manual"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Reasons a dinner's background grounding failed, stored in
|
|
||||||
// dinners.grounding_error and as the KindGroundDinner job's failure code.
|
|
||||||
const (
|
|
||||||
// ReasonNoRecipe: no page the search found holds a usable recipe.
|
|
||||||
ReasonNoRecipe = "no_recipe_found"
|
|
||||||
// ReasonRecipeUnusable: the dinner's recipe_url holds no usable recipe.
|
|
||||||
ReasonRecipeUnusable = "recipe_unusable"
|
|
||||||
// ReasonSearchNotConfigured: recipe search is not set up.
|
|
||||||
ReasonSearchNotConfigured = "search_not_configured"
|
|
||||||
// ReasonSearchFailed: the search provider failed.
|
|
||||||
ReasonSearchFailed = "search_failed"
|
|
||||||
// ReasonAIFailed: normalize_recipe's model provider could not be built
|
|
||||||
// or called.
|
|
||||||
ReasonAIFailed = "ai_failed"
|
|
||||||
// ReasonInterrupted: the server stopped while grounding.
|
|
||||||
ReasonInterrupted = "interrupted"
|
|
||||||
// ReasonGroundingError: any other failure, such as the database's.
|
|
||||||
ReasonGroundingError = "grounding_error"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Reasons are every grounding failure reason.
|
|
||||||
var Reasons = []string{
|
|
||||||
ReasonNoRecipe, ReasonRecipeUnusable, ReasonSearchNotConfigured, ReasonSearchFailed,
|
|
||||||
ReasonAIFailed, ReasonInterrupted, ReasonGroundingError,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limits on candidates and on a recipe entered by hand.
|
|
||||||
const (
|
|
||||||
MaxCandidates = 3
|
|
||||||
MaxManualLines = 100
|
|
||||||
MaxManualLineChar = 500
|
|
||||||
// manualSourcePrefix starts the source_url of a recipe entered by hand,
|
|
||||||
// followed by the recipe's id; it is never a fetchable page.
|
|
||||||
manualSourcePrefix = "mealquest:manual:"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GroundTimeout bounds grounding a candidate or link by hand, which runs in
|
|
||||||
// the request: the page fetch (recipes.Timeout), its photo download and
|
|
||||||
// normalize_recipe (harness.DefaultTimeout), with some slack.
|
|
||||||
const GroundTimeout = 90 * time.Second
|
|
||||||
|
|
||||||
// groundTimeout is GroundTimeout; tests shorten it.
|
|
||||||
var groundTimeout = GroundTimeout
|
|
||||||
|
|
||||||
// ErrGroundTimeout is returned by Ground when grounding a candidate or link
|
|
||||||
// runs past GroundTimeout; the dinner is left as it was.
|
|
||||||
var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long")
|
|
||||||
|
|
||||||
// GroundJob is the KindGroundDinner payload.
|
|
||||||
type GroundJob struct {
|
|
||||||
DinnerID string `json:"dinner_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// groundResult is the KindGroundDinner result: the dinner's grounding
|
|
||||||
// status once the job ends. It is not grounded when the dinner was
|
|
||||||
// grounded by hand before the job finished.
|
|
||||||
type groundResult struct {
|
|
||||||
DinnerID string `json:"dinner_id"`
|
|
||||||
GroundingStatus string `json:"grounding_status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r groundResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
|
||||||
|
|
||||||
// runGroundJob grounds a pending dinner as its creation asked: its
|
|
||||||
// recipe_url with GroundURL, else its name with Ground. Success records
|
|
||||||
// the recipe, photo, method, minutes and difficulty with grounding_status
|
|
||||||
// grounded; failure records grounding_status failed with its Reason, which
|
|
||||||
// is also the job's failure code. A dinner no longer pending (grounded by
|
|
||||||
// hand meanwhile) is left as it is.
|
|
||||||
func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundResult) error) (groundResult, error) {
|
|
||||||
row, err := db.New(s.st.Reader).GetDinner(ctx, p.DinnerID)
|
|
||||||
if err != nil {
|
|
||||||
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", p.DinnerID, err)
|
|
||||||
}
|
|
||||||
if row.GroundingStatus != GroundingPending {
|
|
||||||
return groundResult{DinnerID: row.ID, GroundingStatus: row.GroundingStatus}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var gr recipes.Grounded
|
|
||||||
if row.RecipeUrl.Valid {
|
|
||||||
gr, err = s.grounder.GroundURL(ctx, row.RecipeUrl.String)
|
|
||||||
} else {
|
|
||||||
gr, err = s.grounder.Ground(ctx, row.Name, row.Name)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
reason := reasonFor(err, row.RecipeUrl.Valid)
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
reason = ReasonInterrupted
|
|
||||||
}
|
|
||||||
// Only the reason is logged: the error may quote a dependency's
|
|
||||||
// response, which jobs never store, return or log.
|
|
||||||
s.logger.InfoContext(ctx, "dinner grounding failed", "dinner_id", row.ID, "reason", reason)
|
|
||||||
write := context.WithoutCancel(ctx)
|
|
||||||
if _, failErr := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{
|
|
||||||
GroundingError: sql.NullString{String: reason, Valid: true},
|
|
||||||
UpdatedAt: store.Now().String(),
|
|
||||||
ID: row.ID,
|
|
||||||
}); failErr != nil {
|
|
||||||
return groundResult{}, fmt.Errorf("dinners: ground: record failure of %s: %w", row.ID, failErr)
|
|
||||||
}
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return groundResult{}, ctx.Err()
|
|
||||||
}
|
|
||||||
return groundResult{}, &jobs.Failure{Code: reason}
|
|
||||||
}
|
|
||||||
|
|
||||||
n, err := db.New(s.st.Writer).GroundPendingDinner(ctx, db.GroundPendingDinnerParams{
|
|
||||||
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
|
||||||
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
|
||||||
Method: sql.NullString{String: gr.Method, Valid: true},
|
|
||||||
MinutesTotal: nullInt(gr.MinutesTotal),
|
|
||||||
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
|
||||||
UpdatedAt: store.Now().String(),
|
|
||||||
ID: row.ID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return groundResult{}, fmt.Errorf("dinners: ground: record %s: %w", row.ID, err)
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
cur, err := db.New(s.st.Reader).GetDinner(ctx, row.ID)
|
|
||||||
if err != nil {
|
|
||||||
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", row.ID, err)
|
|
||||||
}
|
|
||||||
return groundResult{DinnerID: cur.ID, GroundingStatus: cur.GroundingStatus}, nil
|
|
||||||
}
|
|
||||||
return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FailInterruptedGroundings fails, with ReasonInterrupted, every dinner
|
|
||||||
// still pending whose KindGroundDinner job is no longer queued or running:
|
|
||||||
// a previous process stopped before enqueuing the job or before its
|
|
||||||
// handler recorded a result. Call it once at boot, after the job runner's
|
|
||||||
// Start has failed the jobs that process left running, so no dinner stays
|
|
||||||
// pending with nothing left to ground it. It returns how many it failed.
|
|
||||||
func (s *Service) FailInterruptedGroundings(ctx context.Context) (int64, error) {
|
|
||||||
n, err := db.New(s.st.Writer).FailOrphanedPendingDinnerGroundings(ctx, db.FailOrphanedPendingDinnerGroundingsParams{
|
|
||||||
GroundingError: sql.NullString{String: ReasonInterrupted, Valid: true},
|
|
||||||
UpdatedAt: store.Now().String(),
|
|
||||||
Kind: KindGroundDinner,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("dinners: fail interrupted groundings: %w", err)
|
|
||||||
}
|
|
||||||
if n > 0 {
|
|
||||||
s.logger.WarnContext(ctx, "failed dinner groundings interrupted by restart", "count", n)
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// reasonFor is the Reason for a grounding error; fromURL says whether the
|
|
||||||
// dinner's recipe_url was grounded rather than its name.
|
|
||||||
func reasonFor(err error, fromURL bool) string {
|
|
||||||
var ungroundable *recipes.ErrUngroundable
|
|
||||||
switch {
|
|
||||||
case errors.As(err, &ungroundable) && fromURL:
|
|
||||||
return ReasonRecipeUnusable
|
|
||||||
case errors.As(err, &ungroundable):
|
|
||||||
return ReasonNoRecipe
|
|
||||||
case errors.Is(err, recipes.ErrSearchNotConfigured):
|
|
||||||
return ReasonSearchNotConfigured
|
|
||||||
case errors.Is(err, recipes.ErrSearchFailed):
|
|
||||||
return ReasonSearchFailed
|
|
||||||
case errors.Is(err, recipes.ErrNormalizeFailed):
|
|
||||||
return ReasonAIFailed
|
|
||||||
}
|
|
||||||
return ReasonGroundingError
|
|
||||||
}
|
|
||||||
|
|
||||||
// Candidate is a recipe page offered to ground a dinner in.
|
|
||||||
type Candidate struct {
|
|
||||||
// CandidateID is the page's URL; Fix.CandidateID grounds it.
|
|
||||||
CandidateID string `json:"candidate_id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
SiteName string `json:"site_name"`
|
|
||||||
// PhotoURL is the cached photo of a page already grounded, else null.
|
|
||||||
PhotoURL *string `json:"photo_url"`
|
|
||||||
Minutes *int `json:"minutes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Candidates searches for dinner id's name and returns up to MaxCandidates
|
|
||||||
// recipe pages to ground it in, best first; the list may be empty. A
|
|
||||||
// search that fails wraps recipes.ErrSearchFailed.
|
|
||||||
func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error) {
|
|
||||||
row, err := s.get(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
offers, err := s.grounder.Candidates(ctx, row.Name, row.Name, MaxCandidates)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]Candidate, len(offers))
|
|
||||||
for i, o := range offers {
|
|
||||||
out[i] = Candidate{CandidateID: o.URL, Title: o.Title, SiteName: o.SiteName, Minutes: o.Minutes}
|
|
||||||
if o.PhotoID != "" {
|
|
||||||
u := PhotoURL(o.PhotoID)
|
|
||||||
out[i].PhotoURL = &u
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix grounds a dinner by hand: exactly one of a candidate from
|
|
||||||
// Candidates, a pasted recipe link or a recipe typed in.
|
|
||||||
type Fix struct {
|
|
||||||
CandidateID *string
|
|
||||||
RecipeURL *string
|
|
||||||
Manual *ManualRecipe
|
|
||||||
}
|
|
||||||
|
|
||||||
// ManualRecipe is a recipe typed in by the household.
|
|
||||||
type ManualRecipe struct {
|
|
||||||
// Ingredients are the ingredient lines, at least one.
|
|
||||||
Ingredients []string
|
|
||||||
// Steps are the instructions, in order; there may be none.
|
|
||||||
Steps []string
|
|
||||||
Minutes *int
|
|
||||||
Method *string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ground grounds dinner id as f says, recording updatedBy. A candidate or
|
|
||||||
// link is grounded with recipes.Grounder.GroundURL and sets
|
|
||||||
// grounding_status grounded, with the recipe's photo, method, minutes and
|
|
||||||
// difficulty. A manual recipe is stored as typed — no model runs — and
|
|
||||||
// sets grounding_status manual with its method and minutes; it has no
|
|
||||||
// photo, and the dinner keeps its difficulty. An invalid f is a
|
|
||||||
// *ValidationError; a page that cannot ground the dinner is a
|
|
||||||
// *recipes.ErrUngroundable and changes nothing.
|
|
||||||
func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string) (Dinner, error) {
|
|
||||||
if updatedBy == "" {
|
|
||||||
return Dinner{}, errors.New("dinners: ground: no member to record as updated_by")
|
|
||||||
}
|
|
||||||
link, manual, err := f.validate()
|
|
||||||
if err != nil {
|
|
||||||
return Dinner{}, err
|
|
||||||
}
|
|
||||||
if manual != nil {
|
|
||||||
return s.groundManual(ctx, id, *manual, updatedBy)
|
|
||||||
}
|
|
||||||
if _, err := s.get(ctx, id); err != nil {
|
|
||||||
return Dinner{}, err
|
|
||||||
}
|
|
||||||
groundCtx, cancel := context.WithTimeout(ctx, groundTimeout)
|
|
||||||
defer cancel()
|
|
||||||
gr, err := s.grounder.GroundURL(groundCtx, link)
|
|
||||||
if err != nil {
|
|
||||||
if ctx.Err() == nil && errors.Is(groundCtx.Err(), context.DeadlineExceeded) {
|
|
||||||
return Dinner{}, ErrGroundTimeout
|
|
||||||
}
|
|
||||||
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err)
|
|
||||||
}
|
|
||||||
row, err := db.New(s.st.Writer).GroundDinner(ctx, db.GroundDinnerParams{
|
|
||||||
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
|
||||||
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
|
||||||
Method: sql.NullString{String: gr.Method, Valid: true},
|
|
||||||
MinutesTotal: nullInt(gr.MinutesTotal),
|
|
||||||
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
|
||||||
GroundingStatus: GroundingGrounded,
|
|
||||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
|
||||||
UpdatedAt: store.Now().String(),
|
|
||||||
ID: id,
|
|
||||||
})
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return Dinner{}, ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err)
|
|
||||||
}
|
|
||||||
return fromRow(row), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// groundManual stores m as dinner id's recipe, with its ingredients and
|
|
||||||
// steps, and records it on the dinner, all in one transaction.
|
|
||||||
func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, updatedBy string) (Dinner, error) {
|
|
||||||
var row db.Dinner
|
|
||||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
|
||||||
q := db.New(tx)
|
|
||||||
cur, err := q.GetDinner(ctx, id)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("dinners: read %s: %w", id, err)
|
|
||||||
}
|
|
||||||
ld, err := json.Marshal(manualJSONLD{
|
|
||||||
Context: "https://schema.org",
|
|
||||||
Type: "Recipe",
|
|
||||||
Name: cur.Name,
|
|
||||||
Ingredients: m.Ingredients,
|
|
||||||
Instructions: m.Steps,
|
|
||||||
TotalTime: fmt.Sprintf("PT%dM", *m.Minutes),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("dinners: encode manual recipe: %w", err)
|
|
||||||
}
|
|
||||||
now := store.Now().String()
|
|
||||||
recipeID := store.NewID()
|
|
||||||
minutes := sql.NullInt64{Int64: int64(*m.Minutes), Valid: true}
|
|
||||||
method := sql.NullString{String: *m.Method, Valid: true}
|
|
||||||
rec, err := q.CreateManualRecipe(ctx, db.CreateManualRecipeParams{
|
|
||||||
ID: recipeID,
|
|
||||||
SourceUrl: manualSourcePrefix + recipeID,
|
|
||||||
Title: cur.Name,
|
|
||||||
TotalMinutes: minutes,
|
|
||||||
RawJsonld: string(ld),
|
|
||||||
Method: method,
|
|
||||||
MinutesTotal: minutes,
|
|
||||||
FetchedAt: now,
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("dinners: store manual recipe: %w", err)
|
|
||||||
}
|
|
||||||
for i, line := range m.Ingredients {
|
|
||||||
if err := q.CreateRecipeIngredient(ctx, db.CreateRecipeIngredientParams{
|
|
||||||
ID: store.NewID(),
|
|
||||||
RecipeID: rec.ID,
|
|
||||||
Position: int64(i),
|
|
||||||
RawText: line,
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
}); err != nil {
|
|
||||||
return fmt.Errorf("dinners: store manual ingredient %d: %w", i+1, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i, text := range m.Steps {
|
|
||||||
if err := q.CreateRecipeStep(ctx, db.CreateRecipeStepParams{
|
|
||||||
ID: store.NewID(),
|
|
||||||
RecipeID: rec.ID,
|
|
||||||
Position: int64(i),
|
|
||||||
Text: text,
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
}); err != nil {
|
|
||||||
return fmt.Errorf("dinners: store manual step %d: %w", i+1, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
row, err = q.GroundDinner(ctx, db.GroundDinnerParams{
|
|
||||||
RecipeID: sql.NullString{String: rec.ID, Valid: true},
|
|
||||||
Method: method,
|
|
||||||
MinutesTotal: minutes,
|
|
||||||
Difficulty: cur.Difficulty,
|
|
||||||
GroundingStatus: GroundingManual,
|
|
||||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
|
||||||
UpdatedAt: now,
|
|
||||||
ID: cur.ID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("dinners: ground %s: %w", id, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return Dinner{}, err
|
|
||||||
}
|
|
||||||
return fromRow(row), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// manualJSONLD is the schema.org Recipe stored as a manual recipe's
|
|
||||||
// raw_jsonld, so it reads like a fetched page's.
|
|
||||||
type manualJSONLD struct {
|
|
||||||
Context string `json:"@context"`
|
|
||||||
Type string `json:"@type"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Ingredients []string `json:"recipeIngredient"`
|
|
||||||
Instructions []string `json:"recipeInstructions"`
|
|
||||||
TotalTime string `json:"totalTime"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate checks f and returns the link to ground, or the manual recipe
|
|
||||||
// with its lines trimmed.
|
|
||||||
func (f Fix) validate() (link string, manual *ManualRecipe, err error) {
|
|
||||||
given := 0
|
|
||||||
for _, set := range []bool{f.CandidateID != nil, f.RecipeURL != nil, f.Manual != nil} {
|
|
||||||
if set {
|
|
||||||
given++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if given != 1 {
|
|
||||||
return "", nil, invalid("ground", "give exactly one of candidate_id, recipe_url and manual")
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case f.CandidateID != nil:
|
|
||||||
u, err := checkRecipeURL(*f.CandidateID)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, invalid("candidate_id", "is not a candidate from recipe-candidates")
|
|
||||||
}
|
|
||||||
return u.String(), nil, nil
|
|
||||||
case f.RecipeURL != nil:
|
|
||||||
u, err := checkRecipeURL(*f.RecipeURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
return u.String(), nil, nil
|
|
||||||
}
|
|
||||||
m, err := f.Manual.validate()
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
return "", &m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate checks every field of m and returns it with its lines trimmed.
|
|
||||||
func (m ManualRecipe) validate() (ManualRecipe, error) {
|
|
||||||
var problems []Problem
|
|
||||||
ings, ingProblem := checkLines(m.Ingredients, 1)
|
|
||||||
if ingProblem != "" {
|
|
||||||
problems = append(problems, Problem{"manual.ingredients", ingProblem})
|
|
||||||
}
|
|
||||||
steps, stepProblem := checkLines(m.Steps, 0)
|
|
||||||
if stepProblem != "" {
|
|
||||||
problems = append(problems, Problem{"manual.steps", stepProblem})
|
|
||||||
}
|
|
||||||
if m.Minutes == nil || *m.Minutes < 1 || *m.Minutes > MaxMinutes {
|
|
||||||
problems = append(problems, Problem{"manual.minutes", fmt.Sprintf("must be 1 to %d", MaxMinutes)})
|
|
||||||
}
|
|
||||||
if m.Method == nil || !slices.Contains(Methods, *m.Method) {
|
|
||||||
problems = append(problems, Problem{"manual.method", "must be one of " + strings.Join(Methods, ", ")})
|
|
||||||
}
|
|
||||||
if problems != nil {
|
|
||||||
return ManualRecipe{}, &ValidationError{Problems: problems}
|
|
||||||
}
|
|
||||||
return ManualRecipe{Ingredients: ings, Steps: steps, Minutes: m.Minutes, Method: m.Method}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkLines returns lines trimmed, or why they are refused: fewer than
|
|
||||||
// least, more than MaxManualLines, or one blank or over MaxManualLineChar.
|
|
||||||
func checkLines(lines []string, least int) ([]string, string) {
|
|
||||||
if len(lines) < least {
|
|
||||||
return nil, fmt.Sprintf("must have at least %d line", least)
|
|
||||||
}
|
|
||||||
if len(lines) > MaxManualLines {
|
|
||||||
return nil, fmt.Sprintf("must have at most %d lines", MaxManualLines)
|
|
||||||
}
|
|
||||||
out := make([]string, len(lines))
|
|
||||||
for i, line := range lines {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
switch {
|
|
||||||
case line == "":
|
|
||||||
return nil, fmt.Sprintf("line %d is blank", i+1)
|
|
||||||
case utf8.RuneCountInString(line) > MaxManualLineChar:
|
|
||||||
return nil, fmt.Sprintf("line %d is over %d characters", i+1, MaxManualLineChar)
|
|
||||||
}
|
|
||||||
out[i] = line
|
|
||||||
}
|
|
||||||
return out, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// get reads dinner id, or ErrNotFound.
|
|
||||||
func (s *Service) get(ctx context.Context, id string) (db.Dinner, error) {
|
|
||||||
row, err := db.New(s.st.Reader).GetDinner(ctx, id)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return db.Dinner{}, ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return db.Dinner{}, fmt.Errorf("dinners: read %s: %w", id, err)
|
|
||||||
}
|
|
||||||
return row, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func nullInt(v *int) sql.NullInt64 {
|
|
||||||
if v == nil {
|
|
||||||
return sql.NullInt64{}
|
|
||||||
}
|
|
||||||
return sql.NullInt64{Int64: int64(*v), Valid: true}
|
|
||||||
}
|
|
||||||
@@ -1,595 +0,0 @@
|
|||||||
package dinners_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
|
||||||
)
|
|
||||||
|
|
||||||
const member = "member-1"
|
|
||||||
|
|
||||||
// Fixture pages: two curry recipes from search and a stew at a pasted link.
|
|
||||||
const (
|
|
||||||
curryA = "https://a.example/chicken-curry"
|
|
||||||
curryB = "https://b.example/curry"
|
|
||||||
stew = "https://c.example/recipes/beef-stew"
|
|
||||||
)
|
|
||||||
|
|
||||||
type env struct {
|
|
||||||
svc *dinners.Service
|
|
||||||
st *store.Store
|
|
||||||
runner *jobs.Runner
|
|
||||||
search *recipestest.Search
|
|
||||||
pages *recipestest.Pages
|
|
||||||
model *llm.Fake
|
|
||||||
}
|
|
||||||
|
|
||||||
// newEnv returns a dinners service over a started job runner, grounding
|
|
||||||
// with fake search, pages, images and model. Searching "Chicken curry"
|
|
||||||
// finds curryA, curryB and a missing page.
|
|
||||||
func newEnv(t *testing.T) *env {
|
|
||||||
t.Helper()
|
|
||||||
e := newUnstartedEnv(t)
|
|
||||||
e.start(t)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
// newUnstartedEnv is newEnv with its job runner not yet started, so jobs
|
|
||||||
// enqueued stay queued until start.
|
|
||||||
func newUnstartedEnv(t *testing.T) *env {
|
|
||||||
t.Helper()
|
|
||||||
dataDir := t.TempDir()
|
|
||||||
st, err := store.Open(t.Context(), dataDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
if err := st.Close(); err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
search := &recipestest.Search{Results: map[string][]recipes.Candidate{
|
|
||||||
"Chicken curry": {{URL: curryA}, {URL: "https://missing.example/x"}, {URL: curryB}},
|
|
||||||
}}
|
|
||||||
pages := &recipestest.Pages{Bodies: map[string][]byte{
|
|
||||||
curryA: recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40),
|
|
||||||
curryB: recipestest.RecipePage("Easy Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25),
|
|
||||||
stew: recipestest.RecipePage("Beef Stew", "Stew Co", "https://c.example/stew.jpg", 150),
|
|
||||||
}}
|
|
||||||
images := &recipestest.Images{JPEGs: map[string][]byte{
|
|
||||||
"https://a.example/curry.jpg": recipestest.JPEG(t, 400, 320, 1),
|
|
||||||
"https://b.example/curry.jpg": recipestest.JPEG(t, 400, 320, 2),
|
|
||||||
"https://c.example/stew.jpg": recipestest.JPEG(t, 400, 320, 3),
|
|
||||||
}}
|
|
||||||
model := &llm.Fake{}
|
|
||||||
g := recipestest.NewGrounder(t, st, dataDir, search, pages, images, model)
|
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
||||||
runner, err := jobs.NewRunner(st, logger, 1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
svc, err := dinners.NewService(st, g, runner, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return &env{svc: svc, st: st, runner: runner, search: search, pages: pages, model: model}
|
|
||||||
}
|
|
||||||
|
|
||||||
// start starts the env's job runner, stopping it when the test ends.
|
|
||||||
func (e *env) start(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
if err := e.runner.Start(ctx); err != nil {
|
|
||||||
cancel()
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
cancel()
|
|
||||||
e.runner.Wait()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func ptr[T any](v T) *T { return &v }
|
|
||||||
|
|
||||||
// waitJob waits for job id to finish and returns it.
|
|
||||||
func (e *env) waitJob(t *testing.T, id string) jobs.Job {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(10 * time.Second)
|
|
||||||
for {
|
|
||||||
job, err := e.runner.Get(t.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
|
||||||
return job
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatalf("job %s never finished; last %+v", id, job)
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *env) dinner(t *testing.T, id string) db.Dinner {
|
|
||||||
t.Helper()
|
|
||||||
row, err := db.New(e.st.Reader).GetDinner(t.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateGroundsInBackground(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
||||||
|
|
||||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if c.GroundingStatus != dinners.GroundingPending || c.GroundingJobID == nil {
|
|
||||||
t.Fatalf("created = %+v, want pending with a job id", c)
|
|
||||||
}
|
|
||||||
job := e.waitJob(t, *c.GroundingJobID)
|
|
||||||
if job.Kind != dinners.KindGroundDinner || job.Status != jobs.StatusSucceeded ||
|
|
||||||
string(job.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"grounded"}` {
|
|
||||||
t.Fatalf("job = %+v (result %s)", job, job.Result)
|
|
||||||
}
|
|
||||||
|
|
||||||
row := e.dinner(t, c.ID)
|
|
||||||
if row.GroundingStatus != "grounded" || !row.RecipeID.Valid || !row.PhotoID.Valid ||
|
|
||||||
row.Method.String != "stovetop" || row.MinutesTotal.Int64 != 40 || row.Difficulty.String != "easy" || row.GroundingError.Valid {
|
|
||||||
t.Fatalf("dinner = %+v, want grounded with recipe, photo, stovetop, 40 minutes, easy", row)
|
|
||||||
}
|
|
||||||
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if rec.SourceUrl != curryA || rec.PhotoID != row.PhotoID {
|
|
||||||
t.Errorf("recipe = %s photo %s, want %s photo %s", rec.SourceUrl, rec.PhotoID.String, curryA, row.PhotoID.String)
|
|
||||||
}
|
|
||||||
if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) {
|
|
||||||
t.Errorf("searched %q, want the dinner's name", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateByRecipeURLGroundsThatURL(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
|
||||||
|
|
||||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(stew)}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
|
|
||||||
t.Fatalf("job = %+v", job)
|
|
||||||
}
|
|
||||||
row := e.dinner(t, c.ID)
|
|
||||||
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if row.GroundingStatus != "grounded" || rec.SourceUrl != stew || row.Method.String != "oven" || row.MinutesTotal.Int64 != 150 {
|
|
||||||
t.Fatalf("dinner = %+v recipe %s, want grounded in %s", row, rec.SourceUrl, stew)
|
|
||||||
}
|
|
||||||
if q := e.search.Queries(); len(q) != 0 {
|
|
||||||
t.Errorf("searched %q; a recipe_url is grounded directly", q)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateGroundingFailureRecordsReason(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
create dinners.NewRegular
|
|
||||||
setup func(*env)
|
|
||||||
reason string
|
|
||||||
}{
|
|
||||||
{"no candidates", dinners.NewRegular{Name: ptr("Unknown dish")}, nil, dinners.ReasonNoRecipe},
|
|
||||||
{"unusable link", dinners.NewRegular{RecipeURL: ptr("https://gone.example/recipes/lost-soup")}, nil, dinners.ReasonRecipeUnusable},
|
|
||||||
{"search fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) { e.search.Err = recipes.ErrSearchRateLimited }, dinners.ReasonSearchFailed},
|
|
||||||
{"model fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) {
|
|
||||||
// One provider failure per candidate tried.
|
|
||||||
e.model.QueueGenerate(llm.GenerateResult{Err: llm.ErrAuth})
|
|
||||||
}, dinners.ReasonAIFailed},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
if tt.setup != nil {
|
|
||||||
tt.setup(e)
|
|
||||||
}
|
|
||||||
c, err := e.svc.CreateRegular(t.Context(), tt.create, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
job := e.waitJob(t, *c.GroundingJobID)
|
|
||||||
if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != tt.reason {
|
|
||||||
t.Errorf("job = %+v, want failed with %s", job, tt.reason)
|
|
||||||
}
|
|
||||||
row := e.dinner(t, c.ID)
|
|
||||||
if row.GroundingStatus != "failed" || row.GroundingError.String != tt.reason || row.RecipeID.Valid {
|
|
||||||
t.Errorf("dinner = %+v, want failed with %s", row, tt.reason)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateReturnsBeforeGroundingCompletes(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
e.search.Gate = make(chan struct{})
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
||||||
|
|
||||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// The search is held, so the job cannot have finished.
|
|
||||||
if row := e.dinner(t, c.ID); row.GroundingStatus != "pending" {
|
|
||||||
t.Fatalf("grounding_status = %s right after create, want pending", row.GroundingStatus)
|
|
||||||
}
|
|
||||||
if job, err := e.runner.Get(t.Context(), *c.GroundingJobID); err != nil || (job.Status != jobs.StatusQueued && job.Status != jobs.StatusRunning) {
|
|
||||||
t.Fatalf("job = %+v, %v; want queued or running", job, err)
|
|
||||||
}
|
|
||||||
close(e.search.Gate)
|
|
||||||
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
|
|
||||||
t.Fatalf("job = %+v", job)
|
|
||||||
}
|
|
||||||
if row := e.dinner(t, c.ID); row.GroundingStatus != "grounded" {
|
|
||||||
t.Errorf("grounding_status = %s, want grounded", row.GroundingStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManualFixWhilePendingIsKept(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
e.search.Gate = make(chan struct{})
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
||||||
|
|
||||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := e.svc.Ground(t.Context(), c.ID, dinners.Fix{Manual: &dinners.ManualRecipe{
|
|
||||||
Ingredients: []string{"1 chicken"}, Minutes: ptr(30), Method: ptr("oven"),
|
|
||||||
}}, member); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
close(e.search.Gate)
|
|
||||||
job := e.waitJob(t, *c.GroundingJobID)
|
|
||||||
if job.Status != jobs.StatusSucceeded || string(job.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"manual"}` {
|
|
||||||
t.Errorf("job = %+v (result %s), want succeeded reporting manual", job, job.Result)
|
|
||||||
}
|
|
||||||
if row := e.dinner(t, c.ID); row.GroundingStatus != "manual" || row.Method.String != "oven" {
|
|
||||||
t.Errorf("dinner = %+v, want the manual grounding kept", row)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// failedDinner creates a Regular whose background grounding fails.
|
|
||||||
func failedDinner(t *testing.T, e *env, name string) string {
|
|
||||||
t.Helper()
|
|
||||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr(name)}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusFailed {
|
|
||||||
t.Fatalf("job = %+v, want failed", job)
|
|
||||||
}
|
|
||||||
return c.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCandidates(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
// The background grounding stores curryA with its cached photo.
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
||||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
|
|
||||||
t.Fatalf("job = %+v", job)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := e.svc.Candidates(t.Context(), c.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(got) != 2 {
|
|
||||||
t.Fatalf("candidates = %+v, want the two usable pages", got)
|
|
||||||
}
|
|
||||||
first, second := got[0], got[1]
|
|
||||||
wantPhoto := dinners.PhotoURL(e.dinner(t, c.ID).PhotoID.String)
|
|
||||||
if first.CandidateID != curryA || first.Title != "Chicken Curry" || first.SiteName != "Curry House" ||
|
|
||||||
first.PhotoURL == nil || *first.PhotoURL != wantPhoto || first.Minutes == nil || *first.Minutes != 40 {
|
|
||||||
t.Errorf("first = %+v, want the stored curryA with photo %s", first, wantPhoto)
|
|
||||||
}
|
|
||||||
if second.CandidateID != curryB || second.Title != "Easy Weeknight Curry" || second.SiteName != "B Kitchen" ||
|
|
||||||
second.PhotoURL != nil || second.Minutes == nil || *second.Minutes != 25 {
|
|
||||||
t.Errorf("second = %+v, want curryB without a photo", second)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := e.svc.Candidates(t.Context(), "nope"); !errors.Is(err, dinners.ErrNotFound) {
|
|
||||||
t.Errorf("unknown dinner: err = %v, want ErrNotFound", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCandidatesSearchFails(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
e.search.Err = recipes.ErrSearchRefused
|
|
||||||
id := failedDinner(t, e, "Chicken curry")
|
|
||||||
if _, err := e.svc.Candidates(t.Context(), id); !errors.Is(err, recipes.ErrSearchFailed) {
|
|
||||||
t.Errorf("err = %v, want ErrSearchFailed", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCandidatesNone(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
id := failedDinner(t, e, "Fish tacos")
|
|
||||||
got, err := e.svc.Candidates(t.Context(), id)
|
|
||||||
if err != nil || len(got) != 0 {
|
|
||||||
t.Fatalf("no search results: candidates = %+v, %v; want none", got, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFixViaCandidate(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
id := failedDinner(t, e, "Mystery curry")
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
|
||||||
|
|
||||||
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(curryB)}, "member-2")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if d.GroundingStatus != "grounded" || d.GroundingError != nil || d.RecipeID == nil || d.PhotoURL == nil ||
|
|
||||||
*d.Method != "stovetop" || *d.MinutesTotal != 25 || *d.Difficulty != "easy" || *d.UpdatedBy != "member-2" {
|
|
||||||
t.Fatalf("dinner = %+v", d)
|
|
||||||
}
|
|
||||||
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), *d.RecipeID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if rec.SourceUrl != curryB {
|
|
||||||
t.Errorf("recipe source = %s, want %s", rec.SourceUrl, curryB)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFixViaRecipeURL(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
id := failedDinner(t, e, "Grandma's stew")
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
|
||||||
|
|
||||||
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr(" " + stew + " ")}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if d.GroundingStatus != "grounded" || d.PhotoURL == nil || *d.Method != "oven" || *d.MinutesTotal != 150 {
|
|
||||||
t.Fatalf("dinner = %+v", d)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A link holding no recipe changes nothing.
|
|
||||||
before := e.dinner(t, id)
|
|
||||||
_, err = e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr("https://gone.example/x")}, member)
|
|
||||||
var ungroundable *recipes.ErrUngroundable
|
|
||||||
if !errors.As(err, &ungroundable) {
|
|
||||||
t.Fatalf("err = %v, want *recipes.ErrUngroundable", err)
|
|
||||||
}
|
|
||||||
if after := e.dinner(t, id); after != before {
|
|
||||||
t.Errorf("dinner changed by a failed fix: %+v, was %+v", after, before)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFixViaManual(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
id := failedDinner(t, e, "Family chili")
|
|
||||||
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Difficulty: ptr("medium")}, member); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{Manual: &dinners.ManualRecipe{
|
|
||||||
Ingredients: []string{" 1 lb beef ", "1 can beans"},
|
|
||||||
Steps: []string{"Brown the beef.", "Simmer with the beans."},
|
|
||||||
Minutes: ptr(240),
|
|
||||||
Method: ptr("crockpot"),
|
|
||||||
}}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if d.GroundingStatus != "manual" || d.GroundingError != nil || d.RecipeID == nil || d.PhotoURL != nil ||
|
|
||||||
*d.Method != "crockpot" || *d.MinutesTotal != 240 || *d.Difficulty != "medium" {
|
|
||||||
t.Fatalf("dinner = %+v", d)
|
|
||||||
}
|
|
||||||
q := db.New(e.st.Reader)
|
|
||||||
rec, err := q.GetRecipe(t.Context(), *d.RecipeID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if rec.Title != "Family chili" || rec.SourceUrl != "mealquest:manual:"+rec.ID || rec.MinutesTotal.Int64 != 240 || rec.PhotoID.Valid {
|
|
||||||
t.Errorf("recipe = %+v", rec)
|
|
||||||
}
|
|
||||||
ings, err := q.ListRecipeIngredients(t.Context(), rec.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
steps, err := q.ListRecipeSteps(t.Context(), rec.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(ings) != 2 || ings[0].RawText != "1 lb beef" || len(steps) != 2 || steps[1].Text != "Simmer with the beans." {
|
|
||||||
t.Errorf("ingredients = %+v, steps = %+v", ings, steps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFixValidation(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
id := failedDinner(t, e, "Pad thai")
|
|
||||||
good := func() *dinners.ManualRecipe {
|
|
||||||
return &dinners.ManualRecipe{Ingredients: []string{"noodles"}, Minutes: ptr(20), Method: ptr("stovetop")}
|
|
||||||
}
|
|
||||||
tests := map[string]dinners.Fix{
|
|
||||||
"nothing": {},
|
|
||||||
"two": {CandidateID: ptr(curryA), RecipeURL: ptr(stew)},
|
|
||||||
"candidate not URL": {CandidateID: ptr("cand-1")},
|
|
||||||
"http link": {RecipeURL: ptr("http://a.example/x")},
|
|
||||||
"no ingredients": {Manual: &dinners.ManualRecipe{Minutes: ptr(20), Method: ptr("oven")}},
|
|
||||||
"blank ingredient": {Manual: &dinners.ManualRecipe{Ingredients: []string{" "}, Minutes: ptr(20), Method: ptr("oven")}},
|
|
||||||
"blank step": {Manual: func() *dinners.ManualRecipe { m := good(); m.Steps = []string{""}; return m }()},
|
|
||||||
"no minutes": {Manual: func() *dinners.ManualRecipe { m := good(); m.Minutes = nil; return m }()},
|
|
||||||
"zero minutes": {Manual: func() *dinners.ManualRecipe { m := good(); m.Minutes = ptr(0); return m }()},
|
|
||||||
"unknown method": {Manual: func() *dinners.ManualRecipe { m := good(); m.Method = ptr("microwave"); return m }()},
|
|
||||||
"no method": {Manual: func() *dinners.ManualRecipe { m := good(); m.Method = nil; return m }()},
|
|
||||||
"too many lines": {Manual: func() *dinners.ManualRecipe { m := good(); m.Ingredients = make([]string, 101); return m }()},
|
|
||||||
"manual and a link": {RecipeURL: ptr(stew), Manual: good()},
|
|
||||||
"over-long ing line": {Manual: func() *dinners.ManualRecipe {
|
|
||||||
m := good()
|
|
||||||
m.Ingredients = []string{string(make([]byte, 501))}
|
|
||||||
return m
|
|
||||||
}()},
|
|
||||||
}
|
|
||||||
before := e.dinner(t, id)
|
|
||||||
for name, fix := range tests {
|
|
||||||
_, err := e.svc.Ground(t.Context(), id, fix, member)
|
|
||||||
var invalid *dinners.ValidationError
|
|
||||||
if !errors.As(err, &invalid) {
|
|
||||||
t.Errorf("%s: err = %v, want *ValidationError", name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if after := e.dinner(t, id); after != before {
|
|
||||||
t.Errorf("dinner changed by invalid fixes: %+v", after)
|
|
||||||
}
|
|
||||||
if _, err := e.svc.Ground(t.Context(), "nope", dinners.Fix{Manual: good()}, member); !errors.Is(err, dinners.ErrNotFound) {
|
|
||||||
t.Errorf("unknown dinner, manual: err = %v, want ErrNotFound", err)
|
|
||||||
}
|
|
||||||
if _, err := e.svc.Ground(t.Context(), "nope", dinners.Fix{RecipeURL: ptr(stew)}, member); !errors.Is(err, dinners.ErrNotFound) {
|
|
||||||
t.Errorf("unknown dinner, link: err = %v, want ErrNotFound", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRestartFailsInterruptedGrounding simulates a crash: one dinner's job
|
|
||||||
// was left running and another dinner was saved before its job was
|
|
||||||
// enqueued. At the next boot both end failed as interrupted, while a
|
|
||||||
// dinner whose job is still queued stays pending and is grounded.
|
|
||||||
func TestRestartFailsInterruptedGrounding(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newUnstartedEnv(t)
|
|
||||||
e.search.Gate = make(chan struct{})
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
||||||
|
|
||||||
running, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Fish pie")}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := e.st.Writer.ExecContext(t.Context(),
|
|
||||||
`UPDATE jobs SET status = 'running', started_at = updated_at WHERE id = ?`, *running.GroundingJobID); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
unqueued, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Lentil soup")}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM jobs WHERE id = ?`, *unqueued.GroundingJobID); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
queued, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Boot: the runner fails the running job, then the service fails the
|
|
||||||
// dinners nothing is left to ground. The search is held, so the queued
|
|
||||||
// dinner's job cannot finish meanwhile.
|
|
||||||
e.start(t)
|
|
||||||
n, err := e.svc.FailInterruptedGroundings(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if n != 2 {
|
|
||||||
t.Errorf("failed %d dinners, want 2", n)
|
|
||||||
}
|
|
||||||
if job, err := e.runner.Get(t.Context(), *running.GroundingJobID); err != nil || job.Status != jobs.StatusFailed {
|
|
||||||
t.Errorf("running job = %+v, %v; want failed", job, err)
|
|
||||||
}
|
|
||||||
for _, id := range []string{running.ID, unqueued.ID} {
|
|
||||||
if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed || row.GroundingError.String != dinners.ReasonInterrupted {
|
|
||||||
t.Errorf("dinner %s = %s (%s), want failed (interrupted)", id, row.GroundingStatus, row.GroundingError.String)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if row := e.dinner(t, queued.ID); row.GroundingStatus != dinners.GroundingPending {
|
|
||||||
t.Errorf("queued dinner = %s, want pending", row.GroundingStatus)
|
|
||||||
}
|
|
||||||
close(e.search.Gate)
|
|
||||||
if job := e.waitJob(t, *queued.GroundingJobID); job.Status != jobs.StatusSucceeded {
|
|
||||||
t.Fatalf("queued job = %+v", job)
|
|
||||||
}
|
|
||||||
if row := e.dinner(t, queued.ID); row.GroundingStatus != dinners.GroundingGrounded {
|
|
||||||
t.Errorf("queued dinner = %s, want grounded", row.GroundingStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCreateEnqueueFailureReturnsFailedDinner: a dinner saved when its job
|
|
||||||
// cannot be enqueued is returned, with its id, failed and no job id.
|
|
||||||
func TestCreateEnqueueFailureReturnsFailedDinner(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
if _, err := e.st.Writer.ExecContext(t.Context(),
|
|
||||||
`CREATE TRIGGER refuse_jobs BEFORE INSERT ON jobs BEGIN SELECT RAISE(ABORT, 'no jobs'); END`); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if c.ID == "" || c.GroundingJobID != nil || c.GroundingStatus != dinners.GroundingFailed ||
|
|
||||||
c.GroundingError == nil || *c.GroundingError != dinners.ReasonGroundingError {
|
|
||||||
t.Fatalf("created = %+v, want its id, failed (grounding_error) and no job", c)
|
|
||||||
}
|
|
||||||
if row := e.dinner(t, c.ID); row.GroundingStatus != dinners.GroundingFailed {
|
|
||||||
t.Errorf("stored grounding_status = %s, want failed", row.GroundingStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGroundTimesOut: grounding a link by hand that runs past its deadline
|
|
||||||
// is ErrGroundTimeout and leaves the dinner as it was. It shortens the
|
|
||||||
// package deadline, so it does not run in parallel.
|
|
||||||
func TestGroundTimesOut(t *testing.T) {
|
|
||||||
dinners.SetGroundTimeout(t, 50*time.Millisecond)
|
|
||||||
e := newEnv(t)
|
|
||||||
id := failedDinner(t, e, "Beef stew")
|
|
||||||
e.pages.Gate = make(chan struct{})
|
|
||||||
defer close(e.pages.Gate)
|
|
||||||
|
|
||||||
_, err := e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr(stew)}, member)
|
|
||||||
if !errors.Is(err, dinners.ErrGroundTimeout) {
|
|
||||||
t.Fatalf("err = %v, want ErrGroundTimeout", err)
|
|
||||||
}
|
|
||||||
if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed {
|
|
||||||
t.Errorf("grounding_status = %s, want failed as before", row.GroundingStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
// 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
|
// next request that is allowed through.
|
||||||
// turnConn's first Write makes, directly: through Fetch, net/http races the
|
|
||||||
// failed write against its read loop, so the error it returns varies.
|
|
||||||
func TestFetchCanceledWaitHoldsNoSlot(t *testing.T) {
|
func TestFetchCanceledWaitHoldsNoSlot(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if _, err := env.f.Fetch(t.Context(), "https://example.com/first"); err != nil {
|
||||||
t.Fatalf("first Fetch: %v", err)
|
t.Fatalf("first Fetch: %v", err)
|
||||||
}
|
}
|
||||||
firstSent := clock.now()
|
|
||||||
env.f.sleep = func(context.Context, time.Duration) error { return context.DeadlineExceeded }
|
env.f.sleep = func(context.Context, time.Duration) error { return context.DeadlineExceeded }
|
||||||
for i := range 5 {
|
for i := range 5 {
|
||||||
// Each request comes later in the interval, so a slot it took
|
if _, err := env.f.Fetch(t.Context(), fmt.Sprintf("https://example.com/burst/%d", i)); !errors.Is(err, context.DeadlineExceeded) {
|
||||||
// would delay the request after the burst.
|
t.Fatalf("burst Fetch %d error = %v, want context.DeadlineExceeded", i, err)
|
||||||
clock.advance(HostInterval / 10)
|
|
||||||
if _, err := env.f.waitTurn(t.Context(), "example.com"); !errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
t.Fatalf("burst waitTurn %d error = %v, want context.DeadlineExceeded", i, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
env.f.mu.Lock()
|
|
||||||
last := env.f.lastSent["example.com"]
|
|
||||||
env.f.mu.Unlock()
|
|
||||||
if !last.Equal(firstSent) {
|
|
||||||
t.Errorf("host slot taken at %s after the burst, want the first send at %s", last, firstSent)
|
|
||||||
}
|
|
||||||
env.f.sleep = clock.sleep
|
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 {
|
if _, err := env.f.Fetch(t.Context(), "https://example.com/after"); err != nil {
|
||||||
t.Fatalf("Fetch after the burst: %v", err)
|
t.Fatalf("Fetch after the burst: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-91
@@ -25,13 +25,6 @@ var (
|
|||||||
// ErrTotalTimeNotPositive rejects a recipe whose page gives a total
|
// ErrTotalTimeNotPositive rejects a recipe whose page gives a total
|
||||||
// time of zero minutes, which normalize_recipe refuses.
|
// time of zero minutes, which normalize_recipe refuses.
|
||||||
ErrTotalTimeNotPositive = errors.New("recipes: recipe total time is not positive")
|
ErrTotalTimeNotPositive = errors.New("recipes: recipe total time is not positive")
|
||||||
// ErrSearchFailed wraps every failure to search: the search provider
|
|
||||||
// could not be built (ErrSearchNotConfigured among them) or its search
|
|
||||||
// failed.
|
|
||||||
ErrSearchFailed = errors.New("recipes: recipe search failed")
|
|
||||||
// ErrNormalizeFailed wraps a normalize_recipe failure that is not the
|
|
||||||
// recipe's: the model provider could not be built or called.
|
|
||||||
ErrNormalizeFailed = errors.New("recipes: normalize_recipe failed")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrUngroundable is returned when no recipe page could ground a dish or a
|
// ErrUngroundable is returned when no recipe page could ground a dish or a
|
||||||
@@ -125,61 +118,23 @@ func (g *Grounder) Ground(ctx context.Context, dishName, query string) (Grounded
|
|||||||
if strings.TrimSpace(dishName) == "" {
|
if strings.TrimSpace(dishName) == "" {
|
||||||
return Grounded{}, errors.New("recipes: ground: dish name is empty")
|
return Grounded{}, errors.New("recipes: ground: dish name is empty")
|
||||||
}
|
}
|
||||||
found, err := g.searchRanked(ctx, dishName, query)
|
provider, err := g.search.Provider(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Grounded{}, fmt.Errorf("recipes: ground %q: %w", dishName, err)
|
return Grounded{}, fmt.Errorf("recipes: ground %q: %w", dishName, err)
|
||||||
}
|
}
|
||||||
var reasons []error
|
|
||||||
if found.rankErr != nil {
|
|
||||||
reasons = append(reasons, found.rankErr)
|
|
||||||
}
|
|
||||||
for _, r := range found.ranked {
|
|
||||||
if sr, ok := found.stored[r.Candidate.URL]; ok {
|
|
||||||
return sr.grounded, nil
|
|
||||||
}
|
|
||||||
gr, reason, err := g.ground(ctx, r.Candidate.URL, r.Recipe)
|
|
||||||
if err != nil {
|
|
||||||
return Grounded{}, fmt.Errorf("recipes: ground %q: %w", dishName, err)
|
|
||||||
}
|
|
||||||
if reason == nil {
|
|
||||||
return gr, nil
|
|
||||||
}
|
|
||||||
reasons = append(reasons, fmt.Errorf("%s: %w", r.Candidate.URL, reason))
|
|
||||||
}
|
|
||||||
return Grounded{}, &ErrUngroundable{Target: dishName, Reasons: reasons}
|
|
||||||
}
|
|
||||||
|
|
||||||
// rankedSearch is a search's candidates ranked: the ranked ones best first,
|
|
||||||
// those already stored by URL, and why any were not ranked.
|
|
||||||
type rankedSearch struct {
|
|
||||||
ranked []Ranked
|
|
||||||
stored map[string]storedRecipe
|
|
||||||
// rankErr is Rank's error, or ErrNoCandidates when the search found
|
|
||||||
// nothing.
|
|
||||||
rankErr error
|
|
||||||
}
|
|
||||||
|
|
||||||
// searchRanked searches for query and ranks the top RankDepth candidates
|
|
||||||
// for dishName as Ground describes. A search, database or context failure
|
|
||||||
// is its error; candidates that do not rank are only rankErr's.
|
|
||||||
func (g *Grounder) searchRanked(ctx context.Context, dishName, query string) (rankedSearch, error) {
|
|
||||||
provider, err := g.search.Provider(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return rankedSearch{}, fmt.Errorf("%w: %w", ErrSearchFailed, err)
|
|
||||||
}
|
|
||||||
cands, err := provider.Search(ctx, query, RankDepth)
|
cands, err := provider.Search(ctx, query, RankDepth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rankedSearch{}, fmt.Errorf("%w: %w", ErrSearchFailed, err)
|
return Grounded{}, fmt.Errorf("recipes: ground %q: search: %w", dishName, err)
|
||||||
}
|
}
|
||||||
if len(cands) == 0 {
|
if len(cands) == 0 {
|
||||||
return rankedSearch{rankErr: ErrNoCandidates}, nil
|
return Grounded{}, &ErrUngroundable{Target: dishName, Reasons: []error{ErrNoCandidates}}
|
||||||
}
|
}
|
||||||
cands = cands[:min(len(cands), RankDepth)]
|
cands = cands[:min(len(cands), RankDepth)]
|
||||||
stored := make(map[string]storedRecipe)
|
stored := make(map[string]storedRecipe)
|
||||||
for _, c := range cands {
|
for _, c := range cands {
|
||||||
sr, ok, err := g.storedRecipe(ctx, c.URL)
|
sr, ok, err := g.storedRecipe(ctx, c.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rankedSearch{}, err
|
return Grounded{}, fmt.Errorf("recipes: ground %q: %w", dishName, err)
|
||||||
}
|
}
|
||||||
if ok {
|
if ok {
|
||||||
stored[c.URL] = sr
|
stored[c.URL] = sr
|
||||||
@@ -192,51 +147,26 @@ func (g *Grounder) searchRanked(ctx context.Context, dishName, query string) (ra
|
|||||||
return fetchRecipe(ctx, g.pages, c.URL)
|
return fetchRecipe(ctx, g.pages, c.URL)
|
||||||
})
|
})
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return rankedSearch{}, err
|
return Grounded{}, err
|
||||||
}
|
}
|
||||||
return rankedSearch{ranked: ranked, stored: stored, rankErr: rankErr}, nil
|
var reasons []error
|
||||||
}
|
if rankErr != nil {
|
||||||
|
reasons = append(reasons, rankErr)
|
||||||
// Offer is a ranked candidate offered for the household to ground a dish
|
|
||||||
// in, with GroundURL on its URL.
|
|
||||||
type Offer struct {
|
|
||||||
URL string
|
|
||||||
Title string
|
|
||||||
SiteName string
|
|
||||||
// PhotoID is the cached photo of a candidate already stored, "" for
|
|
||||||
// one that is not: a candidate's photo is cached only when it grounds.
|
|
||||||
PhotoID string
|
|
||||||
// Minutes is a stored candidate's normalized minutes_total, else its
|
|
||||||
// page's total time; nil when unknown.
|
|
||||||
Minutes *int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Candidates searches for query and returns up to n of the candidates
|
|
||||||
// Ground would try for dishName, best first. It fetches and extracts pages
|
|
||||||
// but downloads no photo and runs no model. Candidates that hold no usable
|
|
||||||
// recipe are not offered, so the list may be empty; a search that fails is
|
|
||||||
// an error wrapping ErrSearchFailed.
|
|
||||||
func (g *Grounder) Candidates(ctx context.Context, dishName, query string, n int) ([]Offer, error) {
|
|
||||||
if strings.TrimSpace(dishName) == "" {
|
|
||||||
return nil, errors.New("recipes: candidates: dish name is empty")
|
|
||||||
}
|
}
|
||||||
if n < 1 {
|
for _, r := range ranked {
|
||||||
return nil, fmt.Errorf("recipes: candidates: n is %d, want at least 1", n)
|
if sr, ok := stored[r.Candidate.URL]; ok {
|
||||||
}
|
return sr.grounded, nil
|
||||||
found, err := g.searchRanked(ctx, dishName, query)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("recipes: candidates for %q: %w", dishName, err)
|
|
||||||
}
|
|
||||||
offers := make([]Offer, 0, min(n, len(found.ranked)))
|
|
||||||
for _, r := range found.ranked[:min(n, len(found.ranked))] {
|
|
||||||
o := Offer{URL: r.Candidate.URL, Title: r.Recipe.Title, SiteName: r.Recipe.SiteName, Minutes: r.Recipe.TotalMinutes}
|
|
||||||
if sr, ok := found.stored[r.Candidate.URL]; ok {
|
|
||||||
o.PhotoID = sr.grounded.PhotoID
|
|
||||||
o.Minutes = sr.grounded.MinutesTotal
|
|
||||||
}
|
}
|
||||||
offers = append(offers, o)
|
gr, reason, err := g.ground(ctx, r.Candidate.URL, r.Recipe)
|
||||||
|
if err != nil {
|
||||||
|
return Grounded{}, fmt.Errorf("recipes: ground %q: %w", dishName, err)
|
||||||
|
}
|
||||||
|
if reason == nil {
|
||||||
|
return gr, nil
|
||||||
|
}
|
||||||
|
reasons = append(reasons, fmt.Errorf("%s: %w", r.Candidate.URL, reason))
|
||||||
}
|
}
|
||||||
return offers, nil
|
return Grounded{}, &ErrUngroundable{Target: dishName, Reasons: reasons}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroundURL grounds the recipe on the page at rawURL, a pasted URL, as
|
// GroundURL grounds the recipe on the page at rawURL, a pasted URL, as
|
||||||
@@ -414,7 +344,7 @@ func (g *Grounder) ground(ctx context.Context, sourceURL string, r Recipe) (gr G
|
|||||||
return Grounded{}, fmt.Errorf("normalize: %w", err), nil
|
return Grounded{}, fmt.Errorf("normalize: %w", err), nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Grounded{}, nil, fmt.Errorf("%w: %w", ErrNormalizeFailed, err)
|
return Grounded{}, nil, fmt.Errorf("normalize: %w", err)
|
||||||
}
|
}
|
||||||
if len(norm.Ingredients) != len(r.Ingredients) {
|
if len(norm.Ingredients) != len(r.Ingredients) {
|
||||||
return Grounded{}, nil, fmt.Errorf("normalize: %d ingredients for %d lines", len(norm.Ingredients), len(r.Ingredients))
|
return Grounded{}, nil, fmt.Errorf("normalize: %d ingredients for %d lines", len(norm.Ingredients), len(r.Ingredients))
|
||||||
|
|||||||
@@ -516,38 +516,3 @@ func TestGroundManyLimitsConcurrency(t *testing.T) {
|
|||||||
t.Errorf("max in flight %d of %d calls, want %d of 5", search.maxActive, search.callCount(), GroundConcurrency)
|
t.Errorf("max in flight %d of %d calls, want %d of 5", search.maxActive, search.callCount(), GroundConcurrency)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCandidatesOffersBestFirstUpToN(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
var cands []Candidate
|
|
||||||
pages := map[string][]byte{}
|
|
||||||
for i, title := range []string{"Fish Tacos", "Fish Taco Bowls", "Baja Fish Tacos", "Tacos"} {
|
|
||||||
u := fmt.Sprintf("https://site%d.example/tacos", i)
|
|
||||||
cands = append(cands, Candidate{URL: u})
|
|
||||||
pages[u] = groundPage(title, "/img/tacos.jpg")
|
|
||||||
}
|
|
||||||
search := &fakeSearch{results: map[string][]Candidate{"fish tacos": cands}}
|
|
||||||
env := newGroundEnv(t, pages, nil, search)
|
|
||||||
|
|
||||||
got, err := env.g.Candidates(t.Context(), "Fish Tacos", "fish tacos", 3)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(got) != 3 || got[0].URL != cands[0].URL || got[0].Title != "Fish Tacos" || got[0].SiteName != "Example Kitchen" ||
|
|
||||||
got[0].PhotoID != "" || got[0].Minutes == nil || *got[0].Minutes != 40 {
|
|
||||||
t.Fatalf("Candidates = %+v, want 3 with Fish Tacos first", got)
|
|
||||||
}
|
|
||||||
if env.images.requests.Load() != 0 || len(env.llm.Requests()) != 0 {
|
|
||||||
t.Errorf("Candidates downloaded a photo or ran the model")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCandidatesSearchFailureWrapsErrSearchFailed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
search := &fakeSearch{errs: map[string]error{"stew": ErrSearchRateLimited}}
|
|
||||||
env := newGroundEnv(t, nil, nil, search)
|
|
||||||
_, err := env.g.Candidates(t.Context(), "Stew", "stew", 3)
|
|
||||||
if !errors.Is(err, ErrSearchFailed) || !errors.Is(err, ErrSearchRateLimited) {
|
|
||||||
t.Errorf("err = %v, want ErrSearchFailed wrapping ErrSearchRateLimited", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+10
-16
@@ -65,28 +65,22 @@ var photoDecoders = map[string]func([]byte) (image.Image, error){
|
|||||||
"webp": func(b []byte) (image.Image, error) { return webp.Decode(bytes.NewReader(b)) },
|
"webp": func(b []byte) (image.Image, error) { return webp.Decode(bytes.NewReader(b)) },
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImageFetcher fetches an image; *Fetcher satisfies it.
|
// PhotoCache downloads photos through a Fetcher and stores each at every
|
||||||
type ImageFetcher interface {
|
|
||||||
FetchImage(ctx context.Context, rawURL string) (Page, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PhotoCache downloads photos through an ImageFetcher and stores each at every
|
|
||||||
// PhotoSizes length as {id}-{size}.jpg under PhotoDir, with a photos row.
|
// PhotoSizes length as {id}-{size}.jpg under PhotoDir, with a photos row.
|
||||||
// Photos are deduplicated by the SHA-256 of the downloaded bytes. A
|
// Photos are deduplicated by the SHA-256 of the downloaded bytes. A
|
||||||
// PhotoCache is safe for concurrent use.
|
// PhotoCache is safe for concurrent use.
|
||||||
type PhotoCache struct {
|
type PhotoCache struct {
|
||||||
images ImageFetcher
|
fetcher *Fetcher
|
||||||
store *store.Store
|
store *store.Store
|
||||||
// dir is the absolute photo directory.
|
// dir is the absolute photo directory.
|
||||||
dir string
|
dir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPhotoCache returns a PhotoCache downloading images with images and
|
// NewPhotoCache returns a PhotoCache storing photos under dataDir, creating
|
||||||
// storing photos under dataDir, creating the photo directory if it is
|
// the photo directory if it is absent.
|
||||||
// absent.
|
func NewPhotoCache(fetcher *Fetcher, st *store.Store, dataDir string) (*PhotoCache, error) {
|
||||||
func NewPhotoCache(images ImageFetcher, st *store.Store, dataDir string) (*PhotoCache, error) {
|
if fetcher == nil {
|
||||||
if images == nil {
|
return nil, errors.New("recipes: nil fetcher")
|
||||||
return nil, errors.New("recipes: nil image fetcher")
|
|
||||||
}
|
}
|
||||||
if st == nil {
|
if st == nil {
|
||||||
return nil, errors.New("recipes: nil store")
|
return nil, errors.New("recipes: nil store")
|
||||||
@@ -98,7 +92,7 @@ func NewPhotoCache(images ImageFetcher, st *store.Store, dataDir string) (*Photo
|
|||||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
return nil, fmt.Errorf("recipes: create photo directory: %w", err)
|
return nil, fmt.Errorf("recipes: create photo directory: %w", err)
|
||||||
}
|
}
|
||||||
return &PhotoCache{images: images, store: st, dir: dir}, nil
|
return &PhotoCache{fetcher: fetcher, store: st, dir: dir}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadedPhoto is an image DownloadPhoto has downloaded and decoded,
|
// DownloadedPhoto is an image DownloadPhoto has downloaded and decoded,
|
||||||
@@ -114,7 +108,7 @@ type DownloadedPhoto struct {
|
|||||||
// whose shorter side is under PhotoMinShortSide. It records nothing, so a
|
// whose shorter side is under PhotoMinShortSide. It records nothing, so a
|
||||||
// caller can try another image before opening a transaction.
|
// caller can try another image before opening a transaction.
|
||||||
func (c *PhotoCache) DownloadPhoto(ctx context.Context, rawURL string) (DownloadedPhoto, error) {
|
func (c *PhotoCache) DownloadPhoto(ctx context.Context, rawURL string) (DownloadedPhoto, error) {
|
||||||
page, err := c.images.FetchImage(ctx, rawURL)
|
page, err := c.fetcher.FetchImage(ctx, rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DownloadedPhoto{}, err
|
return DownloadedPhoto{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
// Package recipestest provides fakes for tests that ground dinners outside
|
|
||||||
// package recipes: a search, page and image fetchers serving fixtures, and
|
|
||||||
// a Grounder over them that normalizes through the real harness and
|
|
||||||
// normalize_recipe skill with a scripted model.
|
|
||||||
package recipestest
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"image"
|
|
||||||
"image/color"
|
|
||||||
"image/jpeg"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Search is a recipes.SearchSource and recipes.SearchProvider answering
|
|
||||||
// each query from Results. Err, when set, fails every search. Gate, when
|
|
||||||
// set, makes every search wait for it to close.
|
|
||||||
type Search struct {
|
|
||||||
Results map[string][]recipes.Candidate
|
|
||||||
Err error
|
|
||||||
Gate chan struct{}
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
queries []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Search) Provider(context.Context) (recipes.SearchProvider, error) { return s, nil }
|
|
||||||
|
|
||||||
func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Candidate, error) {
|
|
||||||
s.mu.Lock()
|
|
||||||
s.queries = append(s.queries, query)
|
|
||||||
s.mu.Unlock()
|
|
||||||
if s.Gate != nil {
|
|
||||||
select {
|
|
||||||
case <-s.Gate:
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s.Err != nil {
|
|
||||||
return nil, s.Err
|
|
||||||
}
|
|
||||||
c := s.Results[query]
|
|
||||||
return c[:min(n, len(c))], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Queries returns every query searched, in order.
|
|
||||||
func (s *Search) Queries() []string {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
return append([]string(nil), s.queries...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pages is a recipes.PageFetcher serving Bodies by URL; any other URL is a
|
|
||||||
// 404. Gate, when set, makes every fetch wait for it to close.
|
|
||||||
type Pages struct {
|
|
||||||
Bodies map[string][]byte
|
|
||||||
Gate chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Pages) Fetch(ctx context.Context, rawURL string) (recipes.Page, error) {
|
|
||||||
if p.Gate != nil {
|
|
||||||
select {
|
|
||||||
case <-p.Gate:
|
|
||||||
case <-ctx.Done():
|
|
||||||
return recipes.Page{}, ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
body, ok := p.Bodies[rawURL]
|
|
||||||
if !ok {
|
|
||||||
return recipes.Page{}, &recipes.StatusError{URL: rawURL, Status: 404}
|
|
||||||
}
|
|
||||||
return recipes.Page{URL: rawURL, ContentType: "text/html", Body: body}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Images is a recipes.ImageFetcher serving JPEGs by URL; any other URL is
|
|
||||||
// a 404.
|
|
||||||
type Images struct {
|
|
||||||
JPEGs map[string][]byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *Images) FetchImage(_ context.Context, rawURL string) (recipes.Page, error) {
|
|
||||||
body, ok := i.JPEGs[rawURL]
|
|
||||||
if !ok {
|
|
||||||
return recipes.Page{}, &recipes.StatusError{URL: rawURL, Status: 404}
|
|
||||||
}
|
|
||||||
return recipes.Page{URL: rawURL, ContentType: "image/jpeg", Body: body}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// JPEG is a w×h JPEG whose pixels depend on seed, so different seeds give
|
|
||||||
// different photos.
|
|
||||||
func JPEG(t testing.TB, w, h int, seed uint8) []byte {
|
|
||||||
t.Helper()
|
|
||||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
||||||
for y := range h {
|
|
||||||
for x := range w {
|
|
||||||
img.Set(x, y, color.RGBA{uint8(x), uint8(y), seed, 255})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return buf.Bytes()
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecipePage is a page whose schema.org Recipe is title, from site, with
|
|
||||||
// two ingredients, two steps, a total time of minutes and the image at
|
|
||||||
// imageURL.
|
|
||||||
func RecipePage(title, site, imageURL string, minutes int) []byte {
|
|
||||||
return fmt.Appendf(nil, `<!DOCTYPE html><html><head>
|
|
||||||
<meta property="og:site_name" content=%q>
|
|
||||||
<script type="application/ld+json">{
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "Recipe",
|
|
||||||
"name": %q,
|
|
||||||
"image": %q,
|
|
||||||
"recipeIngredient": ["1 onion", "2 cloves garlic, minced"],
|
|
||||||
"recipeInstructions": ["Chop the onion.", "Fry with the garlic."],
|
|
||||||
"totalTime": "PT%dM"
|
|
||||||
}</script></head><body></body></html>`, site, title, imageURL, minutes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NormalizedReply is a usable normalize_recipe reply for a RecipePage with
|
|
||||||
// method and minutes.
|
|
||||||
func NormalizedReply(method string, minutes int) llm.GenerateResult {
|
|
||||||
js := fmt.Sprintf(`{"ingredients":[
|
|
||||||
{"name":"onion","qty":"1","unit":"","note":""},
|
|
||||||
{"name":"garlic","qty":"2","unit":"cloves","note":"minced"}
|
|
||||||
],"method":%q,"difficulty":null,"minutes_total":%d}`, method, minutes)
|
|
||||||
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewGrounder returns a Grounder storing in st, with photos cached under
|
|
||||||
// dataDir, normalizing through the real harness with model.
|
|
||||||
func NewGrounder(t testing.TB, st *store.Store, dataDir string, search recipes.SearchSource, pages recipes.PageFetcher, images recipes.ImageFetcher, model llm.Provider) *recipes.Grounder {
|
|
||||||
t.Helper()
|
|
||||||
photos, err := recipes.NewPhotoCache(images, st, dataDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
cfg := harness.Config{
|
|
||||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test"},
|
|
||||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return model, nil },
|
|
||||||
Store: st,
|
|
||||||
Guard: guard,
|
|
||||||
}
|
|
||||||
normalize := func(ctx context.Context, scope string, in skills.RecipeText) (skills.NormalizedRecipe, error) {
|
|
||||||
return skills.NormalizeRecipe(ctx, cfg, scope, in)
|
|
||||||
}
|
|
||||||
g, err := recipes.NewGrounder(st, search, pages, photos, normalize)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return g
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,7 @@ const (
|
|||||||
func newAIRouter(t *testing.T) http.Handler {
|
func newAIRouter(t *testing.T) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), 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)}})
|
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -1,236 +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"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
|
||||||
)
|
|
||||||
|
|
||||||
// maxDinnerBody bounds a POST or PATCH /dinners request body.
|
|
||||||
const maxDinnerBody = 16 << 10
|
|
||||||
|
|
||||||
// maxGroundBody bounds a POST /dinners/{id}/ground body, which may carry a
|
|
||||||
// manual recipe of dinners.MaxManualLines lines of each kind.
|
|
||||||
const maxGroundBody = 512 << 10
|
|
||||||
|
|
||||||
// dinnerCreate is the POST /dinners body: a name or a recipe_url.
|
|
||||||
type dinnerCreate struct {
|
|
||||||
Name *string `json:"name"`
|
|
||||||
RecipeURL *string `json:"recipe_url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// dinnerPatch is the PATCH /dinners/{id} body. Every field is optional;
|
|
||||||
// unknown fields and null values are refused.
|
|
||||||
type dinnerPatch struct {
|
|
||||||
Name *string `json:"name"`
|
|
||||||
Method *string `json:"method"`
|
|
||||||
MinutesTotal *int `json:"minutes_total"`
|
|
||||||
Difficulty *string `json:"difficulty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type dinnerList struct {
|
|
||||||
Dinners []dinners.Dinner `json:"dinners"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// dinnerGround is the POST /dinners/{id}/ground body: exactly one of
|
|
||||||
// candidate_id, recipe_url and manual.
|
|
||||||
type dinnerGround struct {
|
|
||||||
CandidateID *string `json:"candidate_id"`
|
|
||||||
RecipeURL *string `json:"recipe_url"`
|
|
||||||
Manual *dinnerManualRecipe `json:"manual"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type dinnerManualRecipe struct {
|
|
||||||
Ingredients []string `json:"ingredients"`
|
|
||||||
Steps []string `json:"steps"`
|
|
||||||
Minutes *int `json:"minutes"`
|
|
||||||
Method *string `json:"method"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type candidateList struct {
|
|
||||||
Candidates []dinners.Candidate `json:"candidates"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// getDinners answers GET /dinners?status=regular|quest_log.
|
|
||||||
func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
list, err := svc.List(r.Context(), r.URL.Query().Get("status"))
|
|
||||||
if err != nil {
|
|
||||||
writeDinnerError(w, r, logger, "list dinners", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, r, logger, http.StatusOK, dinnerList{Dinners: list})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// postDinner adds a Regular, recording the session's actor as updated_by,
|
|
||||||
// and answers 201 with it and its grounding_job_id without waiting for the
|
|
||||||
// job. When the job cannot be enqueued it still answers 201, with the dinner
|
|
||||||
// grounding_status failed and grounding_job_id null.
|
|
||||||
func postDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
actor, ok := actorOrFail(w, r, logger)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body, err := decodePatch[dinnerCreate](http.MaxBytesReader(w, r.Body, maxDinnerBody))
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d, err := svc.CreateRegular(r.Context(), dinners.NewRegular(body), actor.Subject)
|
|
||||||
if err != nil {
|
|
||||||
writeDinnerError(w, r, logger, "create dinner", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, r, logger, http.StatusCreated, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// patchDinner edits a dinner, recording the session's actor as updated_by.
|
|
||||||
func patchDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
actor, ok := actorOrFail(w, r, logger)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
patch, err := decodePatch[dinnerPatch](http.MaxBytesReader(w, r.Body, maxDinnerBody))
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d, err := svc.Update(r.Context(), chi.URLParam(r, "id"), dinners.Patch(patch), actor.Subject)
|
|
||||||
if err != nil {
|
|
||||||
writeDinnerError(w, r, logger, "update dinner", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, r, logger, http.StatusOK, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getDinnerCandidates answers GET /dinners/{id}/recipe-candidates with up
|
|
||||||
// to dinners.MaxCandidates recipe pages for the dinner's name.
|
|
||||||
func getDinnerCandidates(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
list, err := svc.Candidates(r.Context(), chi.URLParam(r, "id"))
|
|
||||||
if err != nil {
|
|
||||||
writeDinnerError(w, r, logger, "list recipe candidates", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, r, logger, http.StatusOK, candidateList{Candidates: list})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// postDinnerGround grounds a dinner by hand in a candidate, a pasted link
|
|
||||||
// or a recipe typed in, recording the session's actor as updated_by.
|
|
||||||
func postDinnerGround(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
actor, ok := actorOrFail(w, r, logger)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body, err := decodePatch[dinnerGround](http.MaxBytesReader(w, r.Body, maxGroundBody))
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fix := dinners.Fix{CandidateID: body.CandidateID, RecipeURL: body.RecipeURL}
|
|
||||||
if body.Manual != nil {
|
|
||||||
m := dinners.ManualRecipe(*body.Manual)
|
|
||||||
fix.Manual = &m
|
|
||||||
}
|
|
||||||
d, err := svc.Ground(r.Context(), chi.URLParam(r, "id"), fix, actor.Subject)
|
|
||||||
if err != nil {
|
|
||||||
writeDinnerError(w, r, logger, "ground dinner", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, r, logger, http.StatusOK, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteDinner archives a dinner; the row is never deleted.
|
|
||||||
func deleteDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
actor, ok := actorOrFail(w, r, logger)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d, err := svc.Archive(r.Context(), chi.URLParam(r, "id"), actor.Subject)
|
|
||||||
if err != nil {
|
|
||||||
writeDinnerError(w, r, logger, "archive dinner", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, r, logger, http.StatusOK, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// postDinnerFavorite toggles a dinner's is_favorite.
|
|
||||||
func postDinnerFavorite(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
actor, ok := actorOrFail(w, r, logger)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d, err := svc.ToggleFavorite(r.Context(), chi.URLParam(r, "id"), actor.Subject)
|
|
||||||
if err != nil {
|
|
||||||
writeDinnerError(w, r, logger, "toggle dinner favorite", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, r, logger, http.StatusOK, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// actorOrFail returns the session's actor, or answers 500 when the request
|
|
||||||
// has none, which behind requireSession is a wiring bug.
|
|
||||||
func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (auth.Actor, bool) {
|
|
||||||
actor, ok := auth.ActorFrom(r.Context())
|
|
||||||
if !ok {
|
|
||||||
logger.ErrorContext(r.Context(), r.Method+" "+r.URL.Path+" without a session")
|
|
||||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
||||||
}
|
|
||||||
return actor, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeDinnerError maps a dinners.Service error to its API error. A page
|
|
||||||
// that cannot ground the dinner is 422 ungroundable; recipe search not set
|
|
||||||
// up is 409 not_configured; a search or model failure is 502; grounding by
|
|
||||||
// hand that runs past dinners.GroundTimeout is 504 ground_timeout.
|
|
||||||
func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
|
||||||
var invalid *dinners.ValidationError
|
|
||||||
var dup *dinners.DuplicateError
|
|
||||||
var ungroundable *recipes.ErrUngroundable
|
|
||||||
switch {
|
|
||||||
case errors.As(err, &ungroundable):
|
|
||||||
// The reasons quote dependency errors, so they are neither returned
|
|
||||||
// nor logged.
|
|
||||||
writeError(w, r, logger, http.StatusUnprocessableEntity, "ungroundable", "no usable recipe was found at that link")
|
|
||||||
case errors.Is(err, recipes.ErrSearchNotConfigured):
|
|
||||||
writeError(w, r, logger, http.StatusConflict, "not_configured", "recipe search is not configured")
|
|
||||||
case errors.Is(err, recipes.ErrSearchFailed):
|
|
||||||
// The error may quote the search provider's response, so only the
|
|
||||||
// reason is logged.
|
|
||||||
logger.WarnContext(r.Context(), action, "reason", "search_failed")
|
|
||||||
writeError(w, r, logger, http.StatusBadGateway, "search_failed", "recipe search failed")
|
|
||||||
case errors.Is(err, recipes.ErrNormalizeFailed):
|
|
||||||
logger.WarnContext(r.Context(), action, "reason", "normalize_recipe failed")
|
|
||||||
writeError(w, r, logger, http.StatusBadGateway, "ai_failed", "the AI provider failed to read the recipe")
|
|
||||||
case errors.Is(err, dinners.ErrGroundTimeout):
|
|
||||||
logger.WarnContext(r.Context(), action, "reason", "ground_timeout")
|
|
||||||
writeError(w, r, logger, http.StatusGatewayTimeout, "ground_timeout", "grounding the recipe took too long; try again")
|
|
||||||
case errors.As(err, &invalid):
|
|
||||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_dinner", invalid.Error())
|
|
||||||
case errors.As(err, &dup):
|
|
||||||
writeError(w, r, logger, http.StatusConflict, "duplicate_dinner", dup.Error())
|
|
||||||
case errors.Is(err, dinners.ErrNotFound):
|
|
||||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
|
|
||||||
default:
|
|
||||||
logger.ErrorContext(r.Context(), action, "error", err)
|
|
||||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,541 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"testing/fstest"
|
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
const dinnersPath = "/api/v1/dinners"
|
|
||||||
|
|
||||||
// newDinnersRouter returns a router whose dinners service runs over the
|
|
||||||
// returned store. Its job runner is not started, so grounding jobs stay
|
|
||||||
// queued.
|
|
||||||
func newDinnersRouter(t *testing.T) (http.Handler, *store.Store) {
|
|
||||||
t.Helper()
|
|
||||||
e := newDinnersEnv(t)
|
|
||||||
return e.h, e.st
|
|
||||||
}
|
|
||||||
|
|
||||||
// dinnersEnv is a router whose dinners service, job runner and grounder
|
|
||||||
// share one store, grounding with fake search, pages, images and model.
|
|
||||||
type dinnersEnv struct {
|
|
||||||
h http.Handler
|
|
||||||
st *store.Store
|
|
||||||
runner *jobs.Runner
|
|
||||||
search *recipestest.Search
|
|
||||||
pages *recipestest.Pages
|
|
||||||
images *recipestest.Images
|
|
||||||
model *llm.Fake
|
|
||||||
// logs holds everything the router and services log.
|
|
||||||
logs *logBuffer
|
|
||||||
}
|
|
||||||
|
|
||||||
// logBuffer is a bytes.Buffer safe for concurrent log writes.
|
|
||||||
type logBuffer struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
buf bytes.Buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *logBuffer) Write(p []byte) (int, error) {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
return b.buf.Write(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *logBuffer) String() string {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
return b.buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func newDinnersEnv(t *testing.T) *dinnersEnv {
|
|
||||||
t.Helper()
|
|
||||||
st := newTestStore(t)
|
|
||||||
e := &dinnersEnv{
|
|
||||||
st: st,
|
|
||||||
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
|
||||||
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
|
|
||||||
images: &recipestest.Images{JPEGs: map[string][]byte{}},
|
|
||||||
model: &llm.Fake{},
|
|
||||||
logs: &logBuffer{},
|
|
||||||
}
|
|
||||||
logger := slog.New(slog.NewTextHandler(e.logs, nil))
|
|
||||||
runner, err := jobs.NewRunner(st, logger, 1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
e.runner = runner
|
|
||||||
g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model)
|
|
||||||
svc, err := dinners.NewService(st, g, runner, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
|
||||||
svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
// start starts the env's job runner, stopping it when the test ends.
|
|
||||||
func (e *dinnersEnv) start(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
if err := e.runner.Start(ctx); err != nil {
|
|
||||||
cancel()
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
cancel()
|
|
||||||
e.runner.Wait()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// callDinners sends a signed-in request with body (none when empty).
|
|
||||||
func callDinners(t *testing.T, h http.Handler, method, target, body string) (*httptest.ResponseRecorder, map[string]any) {
|
|
||||||
t.Helper()
|
|
||||||
var r io.Reader
|
|
||||||
if body != "" {
|
|
||||||
r = strings.NewReader(body)
|
|
||||||
}
|
|
||||||
return serve(t, h, signedIn(method, target, r))
|
|
||||||
}
|
|
||||||
|
|
||||||
// createDinner adds a Regular and returns its id.
|
|
||||||
func createDinner(t *testing.T, h http.Handler, body string) string {
|
|
||||||
t.Helper()
|
|
||||||
rec, got := callDinners(t, h, http.MethodPost, dinnersPath, body)
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("POST %s = %d: %s", body, rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
return got["id"].(string)
|
|
||||||
}
|
|
||||||
|
|
||||||
// listNames returns the names GET /dinners?status=status lists, in order.
|
|
||||||
func listNames(t *testing.T, h http.Handler, status string) []string {
|
|
||||||
t.Helper()
|
|
||||||
rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"?status="+status, "")
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET status=%s = %d: %s", status, rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
list, ok := body["dinners"].([]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("GET status=%s has no dinners list: %v", status, body)
|
|
||||||
}
|
|
||||||
names := make([]string, len(list))
|
|
||||||
for i, d := range list {
|
|
||||||
names[i] = d.(map[string]any)["name"].(string)
|
|
||||||
}
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateDinnerByName(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
router, st := newDinnersRouter(t)
|
|
||||||
|
|
||||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":" Chicken Tikka Masala "}`)
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
for k, v := range map[string]any{
|
|
||||||
"name": "Chicken Tikka Masala",
|
|
||||||
"status": "regular",
|
|
||||||
"origin": "regular",
|
|
||||||
"grounding_status": "pending",
|
|
||||||
"is_favorite": false,
|
|
||||||
"recipe_url": nil,
|
|
||||||
"photo_url": nil,
|
|
||||||
"method": nil,
|
|
||||||
"minutes_total": nil,
|
|
||||||
"difficulty": nil,
|
|
||||||
"updated_by": testSession.Subject,
|
|
||||||
} {
|
|
||||||
if got, ok := body[k]; !ok || got != v {
|
|
||||||
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var normalized, updatedBy string
|
|
||||||
if err := st.Reader.QueryRow(`SELECT normalized_name, updated_by FROM dinners WHERE id = ?`, body["id"]).Scan(&normalized, &updatedBy); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if normalized != "chicken tikka masala" || updatedBy != testSession.Subject {
|
|
||||||
t.Errorf("stored normalized_name, updated_by = %q, %q", normalized, updatedBy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateDinnerByURL(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
router, _ := newDinnersRouter(t)
|
|
||||||
|
|
||||||
link := "https://www.example.com/recipes/12345/beef-and-broccoli-stir-fry/"
|
|
||||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"`+link+`"}`)
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
for k, v := range map[string]any{
|
|
||||||
"name": "Beef and broccoli stir fry",
|
|
||||||
"recipe_url": link,
|
|
||||||
"status": "regular",
|
|
||||||
"origin": "regular",
|
|
||||||
"grounding_status": "pending",
|
|
||||||
"updated_by": testSession.Subject,
|
|
||||||
} {
|
|
||||||
if body[k] != v {
|
|
||||||
t.Errorf("%s = %#v, want %#v", k, body[k], v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The same link again is a duplicate even though its name would differ.
|
|
||||||
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Something else entirely"}`)
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("unrelated name = %d: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"`+link+`"}`)
|
|
||||||
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
|
|
||||||
t.Errorf("same link again = %d %v, want 409 duplicate_dinner", rec.Code, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range []struct{ name, body string }{
|
|
||||||
{"plain http", `{"recipe_url":"http://example.com/recipes/chili"}`},
|
|
||||||
{"not a URL", `{"recipe_url":"chili"}`},
|
|
||||||
{"credentials", `{"recipe_url":"https://me:pw@example.com/recipes/chili"}`},
|
|
||||||
{"no name in path", `{"recipe_url":"https://example.com/"}`},
|
|
||||||
{"numeric slug", `{"recipe_url":"https://example.com/recipe/98765"}`},
|
|
||||||
{"both fields", `{"name":"Chili","recipe_url":"https://example.com/recipes/chili"}`},
|
|
||||||
{"neither field", `{}`},
|
|
||||||
} {
|
|
||||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, tc.body)
|
|
||||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_dinner" {
|
|
||||||
t.Errorf("%s: %d %v, want 400 invalid_dinner", tc.name, rec.Code, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateDinnerDuplicate(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
router, st := newDinnersRouter(t)
|
|
||||||
createDinner(t, router, `{"name":"Chicken Tikka Masala"}`)
|
|
||||||
|
|
||||||
for _, name := range []string{
|
|
||||||
"chicken tikka masala",
|
|
||||||
"Easy Chicken Tikka-Masala!",
|
|
||||||
"The Best Homemade Chicken Tikka Masala Recipe",
|
|
||||||
} {
|
|
||||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"`+name+`"}`)
|
|
||||||
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
|
|
||||||
t.Errorf("%q: %d %v, want 409 duplicate_dinner", name, rec.Code, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A slug naming the same dish is a duplicate too.
|
|
||||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"https://example.com/easy-chicken-tikka-masala.html"}`)
|
|
||||||
if rec.Code != http.StatusConflict {
|
|
||||||
t.Errorf("URL slug duplicate = %d %v, want 409", rec.Code, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
var n int
|
|
||||||
if err := st.Reader.QueryRow(`SELECT count(*) FROM dinners`).Scan(&n); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if n != 1 {
|
|
||||||
t.Errorf("dinners rows = %d, want 1", n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A different dish sharing words is not a duplicate.
|
|
||||||
createDinner(t, router, `{"name":"Chicken Tikka Wraps"}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateDinnerRejected(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
router, _ := newDinnersRouter(t)
|
|
||||||
for _, tc := range []struct{ name, body, code string }{
|
|
||||||
{"empty name", `{"name":" "}`, "invalid_dinner"},
|
|
||||||
{"filler only", `{"name":"The Best Easy Recipe"}`, "invalid_dinner"},
|
|
||||||
{"too long", `{"name":"` + strings.Repeat("x", dinners.MaxNameChars+1) + `"}`, "invalid_dinner"},
|
|
||||||
{"null name", `{"name":null}`, "invalid_request"},
|
|
||||||
{"unknown field", `{"name":"Chili","status":"quest_log"}`, "invalid_request"},
|
|
||||||
{"not an object", `"Chili"`, "invalid_request"},
|
|
||||||
} {
|
|
||||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, tc.body)
|
|
||||||
if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code {
|
|
||||||
t.Errorf("%s: %d %v, want 400 %s", tc.name, rec.Code, body, tc.code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListDinnersFiltersByStatus(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
router, st := newDinnersRouter(t)
|
|
||||||
tacos := createDinner(t, router, `{"name":"Tacos"}`)
|
|
||||||
createDinner(t, router, `{"name":"Lasagna"}`)
|
|
||||||
pho := createDinner(t, router, `{"name":"Pho"}`)
|
|
||||||
gone := createDinner(t, router, `{"name":"Meatloaf"}`)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, pho); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := st.Writer.ExecContext(ctx,
|
|
||||||
`INSERT INTO photos (id, source_url, local_path, width, height, sha256, fetched_at, created_at, updated_at)
|
|
||||||
VALUES ('photo1', 'https://example.com/tacos.jpg', 'media/photos/photo1', 1200, 800, 'abc', 't', 't', 't')`); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET photo_id = 'photo1', grounding_status = 'grounded' WHERE id = ?`, tacos); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if rec, _ := callDinners(t, router, http.MethodDelete, dinnersPath+"/"+gone, ""); rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("DELETE = %d", rec.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := strings.Join(listNames(t, router, "regular"), ","); got != "Lasagna,Tacos" {
|
|
||||||
t.Errorf("regular = %s, want Lasagna,Tacos", got)
|
|
||||||
}
|
|
||||||
if got := strings.Join(listNames(t, router, "quest_log"), ","); got != "Pho" {
|
|
||||||
t.Errorf("quest_log = %s, want Pho", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, body := callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
|
|
||||||
for _, d := range body["dinners"].([]any) {
|
|
||||||
d := d.(map[string]any)
|
|
||||||
switch d["name"] {
|
|
||||||
case "Tacos":
|
|
||||||
if d["photo_url"] != "/media/photos/photo1-400.jpg" || d["grounding_status"] != "grounded" {
|
|
||||||
t.Errorf("Tacos photo_url, grounding_status = %v, %v", d["photo_url"], d["grounding_status"])
|
|
||||||
}
|
|
||||||
case "Lasagna":
|
|
||||||
if d["photo_url"] != nil || d["grounding_status"] != "pending" {
|
|
||||||
t.Errorf("Lasagna photo_url, grounding_status = %v, %v", d["photo_url"], d["grounding_status"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An empty list is [] rather than null.
|
|
||||||
empty, _ := newDinnersRouter(t)
|
|
||||||
rec, _ := callDinners(t, empty, http.MethodGet, dinnersPath+"?status=regular", "")
|
|
||||||
if got := rec.Body.String(); got != `{"dinners":[]}` {
|
|
||||||
t.Errorf("empty list = %s", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, q := range []string{"", "?status=archived", "?status=suggested", "?status=bogus"} {
|
|
||||||
rec, body := callDinners(t, router, http.MethodGet, dinnersPath+q, "")
|
|
||||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_dinner" {
|
|
||||||
t.Errorf("GET %q = %d %v, want 400 invalid_dinner", q, rec.Code, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPatchDinner(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
router, st := newDinnersRouter(t)
|
|
||||||
id := createDinner(t, router, `{"name":"Chili"}`)
|
|
||||||
createDinner(t, router, `{"name":"Pot Roast"}`)
|
|
||||||
if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id,
|
|
||||||
`{"name":"White Chicken Chili","method":"crockpot","minutes_total":480,"difficulty":"easy"}`)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
for k, v := range map[string]any{
|
|
||||||
"name": "White Chicken Chili",
|
|
||||||
"method": "crockpot",
|
|
||||||
"minutes_total": float64(480),
|
|
||||||
"difficulty": "easy",
|
|
||||||
"status": "regular",
|
|
||||||
"updated_by": testSession.Subject,
|
|
||||||
} {
|
|
||||||
if body[k] != v {
|
|
||||||
t.Errorf("%s = %#v, want %#v", k, body[k], v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var normalized string
|
|
||||||
if err := st.Reader.QueryRow(`SELECT normalized_name FROM dinners WHERE id = ?`, id).Scan(&normalized); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if normalized != "white chicken chili" {
|
|
||||||
t.Errorf("normalized_name = %q", normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A partial patch leaves the other fields.
|
|
||||||
rec, body = callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"difficulty":"medium"}`)
|
|
||||||
if rec.Code != http.StatusOK || body["method"] != "crockpot" || body["difficulty"] != "medium" || body["name"] != "White Chicken Chili" {
|
|
||||||
t.Errorf("partial patch = %d %v", rec.Code, body)
|
|
||||||
}
|
|
||||||
// Renaming to its own name, respelled, is not a duplicate of itself.
|
|
||||||
if rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"name":"Easy white chicken chili"}`); rec.Code != http.StatusOK {
|
|
||||||
t.Errorf("self rename = %d %v", rec.Code, body)
|
|
||||||
}
|
|
||||||
// Renaming onto another dinner is.
|
|
||||||
rec, body = callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"name":"The Best Pot Roast"}`)
|
|
||||||
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
|
|
||||||
t.Errorf("rename onto Pot Roast = %d %v, want 409", rec.Code, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPatchDinnerValidation(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
router, _ := newDinnersRouter(t)
|
|
||||||
id := createDinner(t, router, `{"name":"Chili"}`)
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name, body, code, in string
|
|
||||||
}{
|
|
||||||
{"bad method", `{"method":"microwave"}`, "invalid_dinner", "method"},
|
|
||||||
{"crock pot spelling", `{"method":"crock pot"}`, "invalid_dinner", "method"},
|
|
||||||
{"bad difficulty", `{"difficulty":"expert"}`, "invalid_dinner", "difficulty"},
|
|
||||||
{"zero minutes", `{"minutes_total":0}`, "invalid_dinner", "minutes_total"},
|
|
||||||
{"negative minutes", `{"minutes_total":-5}`, "invalid_dinner", "minutes_total"},
|
|
||||||
{"too many minutes", `{"minutes_total":1441}`, "invalid_dinner", "minutes_total"},
|
|
||||||
{"empty name", `{"name":""}`, "invalid_dinner", "name"},
|
|
||||||
{"every problem", `{"method":"x","difficulty":"y","minutes_total":0}`, "invalid_dinner", "method: must be one of stovetop, oven, crockpot, grill, other; minutes_total"},
|
|
||||||
{"empty patch", `{}`, "invalid_dinner", "no fields"},
|
|
||||||
{"fractional minutes", `{"minutes_total":30.5}`, "invalid_request", "minutes_total"},
|
|
||||||
{"string minutes", `{"minutes_total":"30"}`, "invalid_request", "minutes_total"},
|
|
||||||
{"null method", `{"method":null}`, "invalid_request", "method"},
|
|
||||||
{"read-only field", `{"status":"archived"}`, "invalid_request", "status"},
|
|
||||||
{"favorite field", `{"is_favorite":true}`, "invalid_request", "is_favorite"},
|
|
||||||
} {
|
|
||||||
rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, tc.body)
|
|
||||||
msg, _ := body["error"].(map[string]any)["message"].(string)
|
|
||||||
if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code || !strings.Contains(msg, tc.in) {
|
|
||||||
t.Errorf("%s: %d %v, want 400 %s containing %q", tc.name, rec.Code, body, tc.code, tc.in)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, body := callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
|
|
||||||
d := body["dinners"].([]any)[0].(map[string]any)
|
|
||||||
if d["method"] != nil || d["difficulty"] != nil || d["minutes_total"] != nil || d["name"] != "Chili" {
|
|
||||||
t.Errorf("a rejected patch changed the dinner: %v", d)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/nope", `{"method":"oven"}`)
|
|
||||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
|
||||||
t.Errorf("unknown id = %d %v, want 404", rec.Code, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteDinnerArchives(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
router, st := newDinnersRouter(t)
|
|
||||||
id := createDinner(t, router, `{"name":"Shepherd's Pie"}`)
|
|
||||||
if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec, body := callDinners(t, router, http.MethodDelete, dinnersPath+"/"+id, "")
|
|
||||||
if rec.Code != http.StatusOK || body["status"] != "archived" || body["updated_by"] != testSession.Subject {
|
|
||||||
t.Fatalf("DELETE = %d %v", rec.Code, body)
|
|
||||||
}
|
|
||||||
var status, name, updatedBy string
|
|
||||||
if err := st.Reader.QueryRow(`SELECT status, name, updated_by FROM dinners WHERE id = ?`, id).Scan(&status, &name, &updatedBy); err != nil {
|
|
||||||
t.Fatalf("archived row is gone: %v", err)
|
|
||||||
}
|
|
||||||
if status != "archived" || name != "Shepherd's Pie" || updatedBy != testSession.Subject {
|
|
||||||
t.Errorf("row = %s %q %s", status, name, updatedBy)
|
|
||||||
}
|
|
||||||
if got := listNames(t, router, "regular"); len(got) != 0 {
|
|
||||||
t.Errorf("regular after archive = %v", got)
|
|
||||||
}
|
|
||||||
// An archived name may be added again.
|
|
||||||
createDinner(t, router, `{"name":"Shepherds Pie"}`)
|
|
||||||
|
|
||||||
rec, body = callDinners(t, router, http.MethodDelete, dinnersPath+"/nope", "")
|
|
||||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
|
||||||
t.Errorf("unknown id = %d %v, want 404", rec.Code, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFavoriteDinnerToggles(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
router, st := newDinnersRouter(t)
|
|
||||||
id := createDinner(t, router, `{"name":"Fish Tacos"}`)
|
|
||||||
if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, want := range []bool{true, false, true} {
|
|
||||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath+"/"+id+"/favorite", "")
|
|
||||||
if rec.Code != http.StatusOK || body["is_favorite"] != want || body["updated_by"] != testSession.Subject {
|
|
||||||
t.Fatalf("favorite = %d %v, want is_favorite %v", rec.Code, body, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var fav int
|
|
||||||
if err := st.Reader.QueryRow(`SELECT is_favorite FROM dinners WHERE id = ?`, id).Scan(&fav); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if fav != 1 {
|
|
||||||
t.Errorf("stored is_favorite = %d, want 1", fav)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath+"/nope/favorite", "")
|
|
||||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
|
||||||
t.Errorf("unknown id = %d %v, want 404", rec.Code, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDinnerWritesRequireCSRF sends every dinners write without the CSRF
|
|
||||||
// header and with a wrong one; each is refused and changes nothing.
|
|
||||||
func TestDinnerWritesRequireCSRF(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
router, st := newDinnersRouter(t)
|
|
||||||
id := createDinner(t, router, `{"name":"Chili"}`)
|
|
||||||
|
|
||||||
var before string
|
|
||||||
snapshot := `SELECT group_concat(id || name || status || is_favorite || coalesce(method, '') || updated_at, '|') FROM dinners`
|
|
||||||
if err := st.Reader.QueryRow(snapshot).Scan(&before); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
writes := []struct{ method, target, body string }{
|
|
||||||
{http.MethodPost, dinnersPath, `{"name":"Lasagna"}`},
|
|
||||||
{http.MethodPatch, dinnersPath + "/" + id, `{"method":"oven"}`},
|
|
||||||
{http.MethodDelete, dinnersPath + "/" + id, ""},
|
|
||||||
{http.MethodPost, dinnersPath + "/" + id + "/favorite", ""},
|
|
||||||
}
|
|
||||||
for _, w := range writes {
|
|
||||||
for _, header := range []*string{nil, ptr("wrong-token")} {
|
|
||||||
req := httptest.NewRequest(w.method, w.target, strings.NewReader(w.body))
|
|
||||||
req.AddCookie(validSessionCookie)
|
|
||||||
if header != nil {
|
|
||||||
req.Header.Set(CSRFHeader, *header)
|
|
||||||
}
|
|
||||||
rec, body := serve(t, router, req)
|
|
||||||
if rec.Code != http.StatusForbidden || errorCode(body) != "csrf" {
|
|
||||||
t.Errorf("%s %s (header %v) = %d %v, want 403 csrf", w.method, w.target, header != nil, rec.Code, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var after string
|
|
||||||
if err := st.Reader.QueryRow(snapshot).Scan(&after); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if after != before {
|
|
||||||
t.Errorf("refused writes changed dinners:\n before %s\n after %s", before, after)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Without a session, the list is 401 too.
|
|
||||||
req := httptest.NewRequest(http.MethodGet, dinnersPath+"?status=regular", nil)
|
|
||||||
if rec, body := serve(t, router, req); rec.Code != http.StatusUnauthorized || errorCode(body) != "unauthenticated" {
|
|
||||||
t.Errorf("GET without a session = %d %v, want 401", rec.Code, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ptr[T any](v T) *T { return &v }
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Fixture pages: two curries found by searching "Chicken curry" and a stew
|
|
||||||
// at a pasted link.
|
|
||||||
const (
|
|
||||||
curryPage = "https://a.example/chicken-curry"
|
|
||||||
curry2Page = "https://b.example/curry"
|
|
||||||
stewPage = "https://c.example/recipes/beef-stew"
|
|
||||||
)
|
|
||||||
|
|
||||||
// withRecipes adds the fixture pages, their photos and the search for
|
|
||||||
// "Chicken curry" to e.
|
|
||||||
func (e *dinnersEnv) withRecipes(t *testing.T) *dinnersEnv {
|
|
||||||
t.Helper()
|
|
||||||
e.search.Results["Chicken curry"] = []recipes.Candidate{{URL: curryPage}, {URL: curry2Page}}
|
|
||||||
e.pages.Bodies[curryPage] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
|
|
||||||
e.pages.Bodies[curry2Page] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25)
|
|
||||||
e.pages.Bodies[stewPage] = recipestest.RecipePage("Beef Stew", "Stew Co", "https://c.example/stew.jpg", 150)
|
|
||||||
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
|
|
||||||
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
|
|
||||||
e.images.JPEGs["https://c.example/stew.jpg"] = recipestest.JPEG(t, 400, 320, 3)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitJob polls GET /jobs/{id} until the job ends and returns its body.
|
|
||||||
func waitJob(t *testing.T, h http.Handler, id string) map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(10 * time.Second)
|
|
||||||
for {
|
|
||||||
rec, body := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET job = %d: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
if s := body["status"]; s == "succeeded" || s == "failed" {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatalf("job %s never finished; last %v", id, body)
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// listedDinner returns dinner id as GET /dinners?status=regular lists it.
|
|
||||||
func listedDinner(t *testing.T, h http.Handler, id string) map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"?status=regular", "")
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET dinners = %d: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
for _, d := range body["dinners"].([]any) {
|
|
||||||
if d := d.(map[string]any); d["id"] == id {
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Fatalf("dinner %s not listed", id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPostDinnerReturnsBeforeGroundingCompletes(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newDinnersEnv(t).withRecipes(t)
|
|
||||||
e.search.Gate = make(chan struct{})
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
||||||
e.start(t)
|
|
||||||
|
|
||||||
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
jobID, _ := created["grounding_job_id"].(string)
|
|
||||||
if created["grounding_status"] != "pending" || created["grounding_error"] != nil || jobID == "" {
|
|
||||||
t.Fatalf("created = %v, want pending with a grounding_job_id", created)
|
|
||||||
}
|
|
||||||
// The search is held, so grounding cannot have completed.
|
|
||||||
_, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+jobID, "")
|
|
||||||
if s := job["status"]; s != "queued" && s != "running" {
|
|
||||||
t.Fatalf("job right after POST = %v, want queued or running", job)
|
|
||||||
}
|
|
||||||
if d := listedDinner(t, e.h, created["id"].(string)); d["grounding_status"] != "pending" {
|
|
||||||
t.Fatalf("dinner right after POST = %v, want pending", d)
|
|
||||||
}
|
|
||||||
|
|
||||||
close(e.search.Gate)
|
|
||||||
job = waitJob(t, e.h, jobID)
|
|
||||||
if job["status"] != "succeeded" || job["kind"] != "ground_dinner" {
|
|
||||||
t.Fatalf("job = %v, want ground_dinner succeeded", job)
|
|
||||||
}
|
|
||||||
d := listedDinner(t, e.h, created["id"].(string))
|
|
||||||
photo, _ := d["photo_url"].(string)
|
|
||||||
if d["grounding_status"] != "grounded" || d["recipe_id"] == nil || !strings.HasSuffix(photo, "-400.jpg") ||
|
|
||||||
d["method"] != "stovetop" || d["minutes_total"] != 40.0 || d["difficulty"] != "easy" || d["grounding_error"] != nil {
|
|
||||||
t.Errorf("dinner = %v, want grounded with recipe, photo, method, minutes and difficulty", d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPostDinnerGroundingFailure(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newDinnersEnv(t).withRecipes(t)
|
|
||||||
e.start(t)
|
|
||||||
|
|
||||||
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Nothing matches this"}`)
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
job := waitJob(t, e.h, created["grounding_job_id"].(string))
|
|
||||||
if job["status"] != "failed" || job["error"] != "no_recipe_found" {
|
|
||||||
t.Errorf("job = %v, want failed with no_recipe_found", job)
|
|
||||||
}
|
|
||||||
d := listedDinner(t, e.h, created["id"].(string))
|
|
||||||
if d["grounding_status"] != "failed" || d["grounding_error"] != "no_recipe_found" || d["recipe_id"] != nil {
|
|
||||||
t.Errorf("dinner = %v, want failed with no_recipe_found", d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetRecipeCandidates(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newDinnersEnv(t).withRecipes(t)
|
|
||||||
id := createDinner(t, e.h, `{"name":"Chicken curry"}`) // runner not started: stays pending
|
|
||||||
|
|
||||||
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
list, _ := body["candidates"].([]any)
|
|
||||||
if len(list) != 2 {
|
|
||||||
t.Fatalf("candidates = %v, want 2", body)
|
|
||||||
}
|
|
||||||
first := list[0].(map[string]any)
|
|
||||||
want := map[string]any{
|
|
||||||
"candidate_id": curryPage,
|
|
||||||
"title": "Chicken Curry",
|
|
||||||
"site_name": "Curry House",
|
|
||||||
"photo_url": nil,
|
|
||||||
"minutes": 40.0,
|
|
||||||
}
|
|
||||||
if len(first) != len(want) {
|
|
||||||
t.Errorf("candidate keys = %v, want exactly %v", first, want)
|
|
||||||
}
|
|
||||||
for k, v := range want {
|
|
||||||
if got, ok := first[k]; !ok || got != v {
|
|
||||||
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rec, body = callDinners(t, e.h, http.MethodGet, dinnersPath+"/nope/recipe-candidates", "")
|
|
||||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
|
||||||
t.Errorf("unknown dinner = %d %v, want 404", rec.Code, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetRecipeCandidatesSearchFails(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newDinnersEnv(t)
|
|
||||||
// The provider's text must reach neither the response nor the logs.
|
|
||||||
const leak = "provider-said-token-abc123"
|
|
||||||
e.search.Err = fmt.Errorf("%w: %s", recipes.ErrSearchRefused, leak)
|
|
||||||
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
|
|
||||||
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
|
||||||
if rec.Code != http.StatusBadGateway || errorCode(body) != "search_failed" {
|
|
||||||
t.Errorf("GET = %d %v, want 502 search_failed", rec.Code, body)
|
|
||||||
}
|
|
||||||
if strings.Contains(rec.Body.String(), leak) {
|
|
||||||
t.Errorf("response quotes the provider: %s", rec.Body)
|
|
||||||
}
|
|
||||||
logs := e.logs.String()
|
|
||||||
if strings.Contains(logs, leak) || !strings.Contains(logs, "reason=search_failed") {
|
|
||||||
t.Errorf("logs = %q, want reason=search_failed and no provider text", logs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPostGroundViaCandidate(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newDinnersEnv(t).withRecipes(t)
|
|
||||||
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
|
||||||
|
|
||||||
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"candidate_id":"`+curry2Page+`"}`)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
photo, _ := d["photo_url"].(string)
|
|
||||||
if d["grounding_status"] != "grounded" || d["recipe_id"] == nil || photo == "" ||
|
|
||||||
d["method"] != "stovetop" || d["minutes_total"] != 25.0 || d["difficulty"] != "easy" || d["updated_by"] != testSession.Subject {
|
|
||||||
t.Errorf("dinner = %v", d)
|
|
||||||
}
|
|
||||||
// The candidate is now stored, so it is offered with its photo.
|
|
||||||
_, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
|
||||||
for _, c := range body["candidates"].([]any) {
|
|
||||||
if c := c.(map[string]any); c["candidate_id"] == curry2Page && c["photo_url"] != photo {
|
|
||||||
t.Errorf("grounded candidate photo_url = %v, want %s", c["photo_url"], photo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPostGroundViaRecipeURL(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newDinnersEnv(t).withRecipes(t)
|
|
||||||
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
|
||||||
|
|
||||||
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+stewPage+`"}`)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
if d["grounding_status"] != "grounded" || d["photo_url"] == nil || d["method"] != "oven" || d["minutes_total"] != 150.0 {
|
|
||||||
t.Errorf("dinner = %v", d)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"https://gone.example/soup"}`)
|
|
||||||
if rec.Code != http.StatusUnprocessableEntity || errorCode(body) != "ungroundable" || strings.Contains(rec.Body.String(), "gone.example") {
|
|
||||||
t.Errorf("unusable link = %d %v, want 422 ungroundable", rec.Code, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPostGroundViaManual(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newDinnersEnv(t)
|
|
||||||
id := createDinner(t, e.h, `{"name":"Family chili"}`)
|
|
||||||
|
|
||||||
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
|
|
||||||
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
if d["grounding_status"] != "manual" || d["recipe_id"] == nil || d["photo_url"] != nil ||
|
|
||||||
d["method"] != "crockpot" || d["minutes_total"] != 240.0 || d["grounding_error"] != nil {
|
|
||||||
t.Errorf("dinner = %v", d)
|
|
||||||
}
|
|
||||||
var lines int
|
|
||||||
if err := e.st.Reader.QueryRow(`SELECT count(*) FROM recipe_ingredients WHERE recipe_id = ?`, d["recipe_id"]).Scan(&lines); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if lines != 2 {
|
|
||||||
t.Errorf("stored %d ingredient lines, want 2", lines)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPostGroundRejects(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newDinnersEnv(t)
|
|
||||||
id := createDinner(t, e.h, `{"name":"Pad thai"}`)
|
|
||||||
path := dinnersPath + "/" + id + "/ground"
|
|
||||||
|
|
||||||
for _, tc := range []struct{ name, body, code string }{
|
|
||||||
{"empty", `{}`, "invalid_dinner"},
|
|
||||||
{"two kinds", `{"candidate_id":"https://a.example/x","recipe_url":"https://a.example/x"}`, "invalid_dinner"},
|
|
||||||
{"candidate not a link", `{"candidate_id":"c1"}`, "invalid_dinner"},
|
|
||||||
{"http link", `{"recipe_url":"http://a.example/x"}`, "invalid_dinner"},
|
|
||||||
{"null", `{"recipe_url":null}`, "invalid_request"},
|
|
||||||
{"unknown field", `{"url":"https://a.example/x"}`, "invalid_request"},
|
|
||||||
{"unknown manual field", `{"manual":{"ingredients":["x"],"minutes":5,"method":"oven","serves":4}}`, "invalid_request"},
|
|
||||||
{"manual without method", `{"manual":{"ingredients":["x"],"minutes":5}}`, "invalid_dinner"},
|
|
||||||
{"manual without ingredients", `{"manual":{"ingredients":[],"minutes":5,"method":"oven"}}`, "invalid_dinner"},
|
|
||||||
{"manual minutes too many", `{"manual":{"ingredients":["x"],"minutes":5000,"method":"oven"}}`, "invalid_dinner"},
|
|
||||||
{"not an object", `[]`, "invalid_request"},
|
|
||||||
} {
|
|
||||||
rec, body := callDinners(t, e.h, http.MethodPost, path, tc.body)
|
|
||||||
if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code {
|
|
||||||
t.Errorf("%s: %d %v, want 400 %s", tc.name, rec.Code, body, tc.code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if d := listedDinner(t, e.h, id); d["grounding_status"] != "pending" {
|
|
||||||
t.Errorf("dinner after rejected fixes = %v, want unchanged", d)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/nope/ground",
|
|
||||||
`{"manual":{"ingredients":["x"],"minutes":5,"method":"oven"}}`)
|
|
||||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
|
||||||
t.Errorf("unknown dinner = %d %v, want 404", rec.Code, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPostDinnerEnqueueFailureAnswersFailedDinner(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newDinnersEnv(t)
|
|
||||||
if _, err := e.st.Writer.ExecContext(t.Context(),
|
|
||||||
`CREATE TRIGGER refuse_jobs BEFORE INSERT ON jobs BEGIN SELECT RAISE(ABORT, 'no jobs'); END`); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
id, _ := created["id"].(string)
|
|
||||||
if id == "" || created["grounding_status"] != "failed" || created["grounding_error"] != "grounding_error" || created["grounding_job_id"] != nil {
|
|
||||||
t.Fatalf("created = %v, want its id, failed (grounding_error) and a null grounding_job_id", created)
|
|
||||||
}
|
|
||||||
if got := listedDinner(t, e.h, id); got["grounding_status"] != "failed" {
|
|
||||||
t.Errorf("listed = %v, want failed", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||||
newTestSearch(t), newTestAI(t), 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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), 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)}})
|
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
|||||||
}
|
}
|
||||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||||
newTestSearch(t), newTestAI(t), 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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const (
|
|||||||
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), 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)}})
|
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
@@ -35,15 +34,12 @@ type RouteMounter interface {
|
|||||||
// /media; settings serves /api/v1/settings/household; search serves
|
// /media; settings serves /api/v1/settings/household; search serves
|
||||||
// /api/v1/settings/recipe-search and its /test; ai serves
|
// /api/v1/settings/recipe-search and its /test; ai serves
|
||||||
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
|
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
|
||||||
// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE
|
|
||||||
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
|
|
||||||
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
|
|
||||||
// runner serves
|
// runner serves
|
||||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||||
// served for every other GET. /health, /auth and the web build are public.
|
// served for every other GET. /health, /auth and the web build are public.
|
||||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, 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 {
|
if settings == nil {
|
||||||
return nil, errors.New("server: nil household settings service")
|
return nil, errors.New("server: nil household settings service")
|
||||||
}
|
}
|
||||||
@@ -53,9 +49,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
if ai == nil {
|
if ai == nil {
|
||||||
return nil, errors.New("server: nil AI settings service")
|
return nil, errors.New("server: nil AI settings service")
|
||||||
}
|
}
|
||||||
if dinnerSvc == nil {
|
|
||||||
return nil, errors.New("server: nil dinners service")
|
|
||||||
}
|
|
||||||
if runner == nil {
|
if runner == nil {
|
||||||
return nil, errors.New("server: nil job runner")
|
return nil, errors.New("server: nil job runner")
|
||||||
}
|
}
|
||||||
@@ -99,13 +92,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai))
|
apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai))
|
||||||
apiV1.Get("/settings/ai/models", getAIModels(logger, ai))
|
apiV1.Get("/settings/ai/models", getAIModels(logger, ai))
|
||||||
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
|
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
|
||||||
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
|
|
||||||
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
|
|
||||||
apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc))
|
|
||||||
apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc))
|
|
||||||
apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc))
|
|
||||||
apiV1.Get("/dinners/{id}/recipe-candidates", getDinnerCandidates(logger, dinnerSvc))
|
|
||||||
apiV1.Post("/dinners/{id}/ground", postDinnerGround(logger, dinnerSvc))
|
|
||||||
apiV1.Post("/jobs", postJob(logger, runner))
|
apiV1.Post("/jobs", postJob(logger, runner))
|
||||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||||
media.Get("/photos/{name}", getPhoto(logger, photos))
|
media.Get("/photos/{name}", getPhoto(logger, photos))
|
||||||
|
|||||||
@@ -17,12 +17,9 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||||
@@ -46,7 +43,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
|
|||||||
"index.html": {Data: []byte(fixtureIndex)},
|
"index.html": {Data: []byte(fixtureIndex)},
|
||||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||||
}
|
}
|
||||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), 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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -142,24 +139,6 @@ func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
|
|||||||
return search
|
return search
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTestDinners returns a dinners service over a migrated temp database,
|
|
||||||
// with its own unstarted job runner and a grounder that finds nothing.
|
|
||||||
func newTestDinners(t *testing.T) *dinners.Service {
|
|
||||||
t.Helper()
|
|
||||||
st := newTestStore(t)
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
||||||
runner, err := jobs.NewRunner(st, logger, 1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
|
||||||
svc, err := dinners.NewService(st, g, runner, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return svc
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTestBox returns a secret box under a random key.
|
// newTestBox returns a secret box under a random key.
|
||||||
func newTestBox(t *testing.T) *secretbox.Box {
|
func newTestBox(t *testing.T) *secretbox.Box {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -191,7 +170,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, 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")
|
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,7 +179,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), 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")
|
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,7 +188,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), 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")
|
t.Fatal("NewRouter accepted nil photo files")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,25 +197,16 @@ func TestNewRouterRequiresSettings(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), 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")
|
t.Fatal("NewRouter accepted a nil settings service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewRouterRequiresDinners(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
|
||||||
t.Fatal("NewRouter accepted a nil dinners service")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewRouterRequiresJobRunner(t *testing.T) {
|
func TestNewRouterRequiresJobRunner(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), 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")
|
t.Fatal("NewRouter accepted a nil job runner")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ func decodePatch[T any](body io.Reader) (T, error) {
|
|||||||
if errors.As(err, &typeErr) {
|
if errors.As(err, &typeErr) {
|
||||||
return patch, fmt.Errorf("%s: want a JSON %s", typeErr.Field, typeErr.Type)
|
return patch, fmt.Errorf("%s: want a JSON %s", typeErr.Field, typeErr.Type)
|
||||||
}
|
}
|
||||||
return patch, fmt.Errorf("invalid request body: %v", err)
|
return patch, fmt.Errorf("invalid settings: %v", err)
|
||||||
}
|
}
|
||||||
return patch, nil
|
return patch, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||||
newTestSearch(t), newTestAI(t), 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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
|||||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error" {
|
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,0008_dinner_recipe_url,0009_dinner_grounding_error", got)
|
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,8 +196,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
|||||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error" {
|
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,0008_dinner_recipe_url,0009_dinner_grounding_error", got)
|
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding", got)
|
||||||
}
|
}
|
||||||
if !tableExists(t, s, "dinners") {
|
if !tableExists(t, s, "dinners") {
|
||||||
t.Error("table from 0001_init was not created")
|
t.Error("table from 0001_init was not created")
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
-- 0009_dinner_grounding_error: a dinner whose grounding failed keeps why,
|
|
||||||
-- as one of the reason codes package dinners defines (no_recipe_found,
|
|
||||||
-- recipe_unusable, search_not_configured, search_failed, ai_failed,
|
|
||||||
-- interrupted, grounding_error). It is NULL unless grounding_status is
|
|
||||||
-- failed.
|
|
||||||
|
|
||||||
ALTER TABLE dinners ADD COLUMN grounding_error TEXT;
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
-- name: CreateDinner :one
|
-- name: CreateDinner :one
|
||||||
INSERT INTO dinners (
|
INSERT INTO dinners (
|
||||||
id, name, normalized_name, status, origin, method, minutes_total,
|
id, name, normalized_name, status, origin, method, minutes_total,
|
||||||
difficulty, cuisine, is_favorite, recipe_id, recipe_url, photo_id,
|
difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status,
|
||||||
grounding_status, updated_by, created_at, updated_at
|
updated_by, created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?, ?, ?,
|
||||||
?, ?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?, ?,
|
||||||
?, ?, ?, ?
|
?, ?, ?
|
||||||
)
|
)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
@@ -19,12 +19,6 @@ SELECT * FROM dinners
|
|||||||
WHERE status = ?
|
WHERE status = ?
|
||||||
ORDER BY normalized_name, id;
|
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
|
-- name: UpdateDinner :one
|
||||||
-- An unknown id returns sql.ErrNoRows.
|
-- An unknown id returns sql.ErrNoRows.
|
||||||
UPDATE dinners
|
UPDATE dinners
|
||||||
@@ -61,68 +55,3 @@ SET is_favorite = ?,
|
|||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: ToggleDinnerFavorite :one
|
|
||||||
-- An unknown id returns sql.ErrNoRows.
|
|
||||||
UPDATE dinners
|
|
||||||
SET is_favorite = 1 - is_favorite,
|
|
||||||
updated_by = ?,
|
|
||||||
updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: GroundDinner :one
|
|
||||||
-- Records a dinner's grounding (grounded or manual) and clears any
|
|
||||||
-- grounding_error. An unknown id returns sql.ErrNoRows.
|
|
||||||
UPDATE dinners
|
|
||||||
SET recipe_id = ?,
|
|
||||||
photo_id = ?,
|
|
||||||
method = ?,
|
|
||||||
minutes_total = ?,
|
|
||||||
difficulty = ?,
|
|
||||||
grounding_status = ?,
|
|
||||||
grounding_error = NULL,
|
|
||||||
updated_by = ?,
|
|
||||||
updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: GroundPendingDinner :execrows
|
|
||||||
-- Records the background grounding of a dinner still pending; a dinner
|
|
||||||
-- grounded some other way meanwhile is left as it is (0 rows).
|
|
||||||
UPDATE dinners
|
|
||||||
SET recipe_id = ?,
|
|
||||||
photo_id = ?,
|
|
||||||
method = ?,
|
|
||||||
minutes_total = ?,
|
|
||||||
difficulty = ?,
|
|
||||||
grounding_status = 'grounded',
|
|
||||||
grounding_error = NULL,
|
|
||||||
updated_at = ?
|
|
||||||
WHERE id = ? AND grounding_status = 'pending';
|
|
||||||
|
|
||||||
-- name: FailPendingDinnerGrounding :execrows
|
|
||||||
-- Records why the background grounding of a dinner still pending failed; a
|
|
||||||
-- dinner grounded some other way meanwhile is left as it is (0 rows).
|
|
||||||
UPDATE dinners
|
|
||||||
SET grounding_status = 'failed',
|
|
||||||
grounding_error = ?,
|
|
||||||
updated_at = ?
|
|
||||||
WHERE id = ? AND grounding_status = 'pending';
|
|
||||||
|
|
||||||
-- name: FailOrphanedPendingDinnerGroundings :execrows
|
|
||||||
-- Fails every dinner still pending whose grounding job is no longer queued
|
|
||||||
-- or running: the process stopped before the job, or before its handler
|
|
||||||
-- recorded a result. Run at boot, after the job runner fails the jobs a
|
|
||||||
-- previous process left running.
|
|
||||||
UPDATE dinners
|
|
||||||
SET grounding_status = 'failed',
|
|
||||||
grounding_error = sqlc.arg(grounding_error),
|
|
||||||
updated_at = sqlc.arg(updated_at)
|
|
||||||
WHERE grounding_status = 'pending'
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM jobs
|
|
||||||
WHERE jobs.kind = sqlc.arg(kind)
|
|
||||||
AND jobs.status IN ('queued', 'running')
|
|
||||||
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -69,15 +69,3 @@ ORDER BY position, id;
|
|||||||
-- name: DeleteRecipeSteps :exec
|
-- name: DeleteRecipeSteps :exec
|
||||||
DELETE FROM recipe_steps
|
DELETE FROM recipe_steps
|
||||||
WHERE recipe_id = ?;
|
WHERE recipe_id = ?;
|
||||||
|
|
||||||
-- name: CreateManualRecipe :one
|
|
||||||
-- A recipe the household typed in. Its source_url is a mealquest:manual:
|
|
||||||
-- URN rather than a page, so it never matches a fetched page.
|
|
||||||
INSERT INTO recipes (
|
|
||||||
id, source_url, title, total_minutes, raw_jsonld, method,
|
|
||||||
minutes_total, fetched_at, created_at, updated_at
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?,
|
|
||||||
?, ?, ?, ?
|
|
||||||
)
|
|
||||||
RETURNING *;
|
|
||||||
|
|||||||
@@ -304,10 +304,3 @@
|
|||||||
.fill .textarea {
|
.fill .textarea {
|
||||||
resize: none;
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A form locked while its save or prompt reset is pending, so no edit is
|
|
||||||
made that the response would then replace. It lays out as if it were not
|
|
||||||
there. */
|
|
||||||
.lock {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -407,63 +407,6 @@ describe('AISettingsPage', () => {
|
|||||||
expect(unload()).toBe(true)
|
expect(unload()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('locks the form while a save is pending, and guards leaving until the save lands', async () => {
|
|
||||||
let answer: (response: Response) => void = () => {}
|
|
||||||
stubAPI({ 'PATCH /api/v1/settings/ai': () => new Promise<Response>((resolve) => (answer = resolve)) })
|
|
||||||
await renderPage()
|
|
||||||
const unload = () => {
|
|
||||||
const event = new Event('beforeunload', { cancelable: true })
|
|
||||||
act(() => void window.dispatchEvent(event))
|
|
||||||
return event.defaultPrevented
|
|
||||||
}
|
|
||||||
fireEvent.change(prompt(), { target: { value: 'Keep it cozy.' } })
|
|
||||||
fireEvent.click(save())
|
|
||||||
|
|
||||||
expect(await screen.findByRole('button', { name: 'Saving…' })).toBeDisabled()
|
|
||||||
for (const field of [provider(), model(), slider(), prompt()]) expect(field).toBeDisabled()
|
|
||||||
expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled()
|
|
||||||
expect(unload()).toBe(true)
|
|
||||||
|
|
||||||
answer(jsonResponse(200, { ...AI_SETTINGS, system_prompt: 'Keep it cozy.' }))
|
|
||||||
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
|
|
||||||
expect(prompt()).toBeEnabled()
|
|
||||||
expect(prompt()).toHaveValue('Keep it cozy.')
|
|
||||||
await waitFor(() => expect(unload()).toBe(false))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('locks the form while a prompt reset is pending, even with the sheet closed', async () => {
|
|
||||||
let answer: (response: Response) => void = () => {}
|
|
||||||
stubAPI({
|
|
||||||
'POST /api/v1/settings/ai/reset-prompt': () => new Promise<Response>((resolve) => (answer = resolve)),
|
|
||||||
})
|
|
||||||
await renderPage()
|
|
||||||
const unload = () => {
|
|
||||||
const event = new Event('beforeunload', { cancelable: true })
|
|
||||||
act(() => void window.dispatchEvent(event))
|
|
||||||
return event.defaultPrevented
|
|
||||||
}
|
|
||||||
fireEvent.change(slider(), { target: { value: '0.2' } })
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Reset' }))
|
|
||||||
const confirm = screen.getByRole('dialog', { name: 'Reset the system prompt?' })
|
|
||||||
fireEvent.click(within(confirm).getByRole('button', { name: 'Reset prompt' }))
|
|
||||||
expect(await within(confirm).findByRole('button', { name: 'Resetting…' })).toBeDisabled()
|
|
||||||
fireEvent.click(within(confirm).getByRole('button', { name: 'Cancel' }))
|
|
||||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
|
||||||
|
|
||||||
for (const field of [provider(), model(), slider(), prompt()]) expect(field).toBeDisabled()
|
|
||||||
expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled()
|
|
||||||
expect(save()).toBeDisabled()
|
|
||||||
expect(unload()).toBe(true)
|
|
||||||
|
|
||||||
answer(jsonResponse(200, { ...AI_SETTINGS, system_prompt: DEFAULT_PROMPT }))
|
|
||||||
await waitFor(() => expect(prompt()).toBeEnabled())
|
|
||||||
expect(prompt()).toHaveValue(DEFAULT_PROMPT)
|
|
||||||
expect(slider()).toHaveValue('0.2')
|
|
||||||
expect(unload()).toBe(true)
|
|
||||||
fireEvent.change(prompt(), { target: { value: 'After the reset.' } })
|
|
||||||
expect(prompt()).toHaveValue('After the reset.')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps 44 px tap targets', async () => {
|
it('keeps 44 px tap targets', async () => {
|
||||||
stubAPI()
|
stubAPI()
|
||||||
await renderPage()
|
await renderPage()
|
||||||
@@ -737,29 +680,6 @@ describe('Recipe search card', () => {
|
|||||||
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
|
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('locks the card while a save is pending, and guards leaving until the save lands', async () => {
|
|
||||||
let answer: (response: Response) => void = () => {}
|
|
||||||
stubAPI({ 'PATCH /api/v1/settings/recipe-search': () => new Promise<Response>((resolve) => (answer = resolve)) })
|
|
||||||
await renderCard()
|
|
||||||
const unload = () => {
|
|
||||||
const event = new Event('beforeunload', { cancelable: true })
|
|
||||||
act(() => void window.dispatchEvent(event))
|
|
||||||
return event.defaultPrevented
|
|
||||||
}
|
|
||||||
fireEvent.change(url(), { target: { value: 'https://search.example' } })
|
|
||||||
fireEvent.click(saveSearch())
|
|
||||||
|
|
||||||
expect(await within(card()).findByRole('button', { name: 'Saving…' })).toBeDisabled()
|
|
||||||
for (const field of [url(), searxng(), brave()]) expect(field).toBeDisabled()
|
|
||||||
expect(unload()).toBe(true)
|
|
||||||
|
|
||||||
answer(jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, base_url: 'https://search.example' }))
|
|
||||||
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
|
|
||||||
expect(url()).toBeEnabled()
|
|
||||||
expect(url()).toHaveValue('https://search.example')
|
|
||||||
await waitFor(() => expect(unload()).toBe(false))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps 44 px tap targets', async () => {
|
it('keeps 44 px tap targets', async () => {
|
||||||
stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) })
|
stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) })
|
||||||
await renderCard()
|
await renderCard()
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset className={styles.lock} disabled={save.isPending || resetPrompt.isPending}>
|
<>
|
||||||
<div className={styles.columns}>
|
<div className={styles.columns}>
|
||||||
<Select
|
<Select
|
||||||
label="Provider"
|
label="Provider"
|
||||||
@@ -325,7 +325,7 @@ function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) {
|
|||||||
>
|
>
|
||||||
This restores MealQuest's default system prompt for everyone in the household.
|
This restores MealQuest's default system prompt for everyone in the household.
|
||||||
</Confirm>
|
</Confirm>
|
||||||
</fieldset>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -229,9 +229,3 @@
|
|||||||
.check {
|
.check {
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A form locked while its save is pending, so no edit is made that the
|
|
||||||
saved settings would then replace. It lays out as if it were not there. */
|
|
||||||
.lock {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||||
import { Route, Routes, useLocation } from 'react-router'
|
import { Route, Routes, useLocation } from 'react-router'
|
||||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||||
import { ApiProvider, ApiError, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
import { ApiProvider, ApiError, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
@@ -293,34 +293,6 @@ describe('HouseholdSettingsPage', () => {
|
|||||||
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
|
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('locks the form while a save is pending, and guards leaving until the save lands', async () => {
|
|
||||||
let answer: (response: Response) => void = () => {}
|
|
||||||
stubAPI({
|
|
||||||
'PATCH /api/v1/settings/household': () => new Promise<Response>((resolve) => (answer = resolve)),
|
|
||||||
})
|
|
||||||
await renderPage()
|
|
||||||
const unload = () => {
|
|
||||||
const event = new Event('beforeunload', { cancelable: true })
|
|
||||||
act(() => void window.dispatchEvent(event))
|
|
||||||
return event.defaultPrevented
|
|
||||||
}
|
|
||||||
fireEvent.change(input('Dinner time'), { target: { value: '18:15' } })
|
|
||||||
fireEvent.click(save())
|
|
||||||
|
|
||||||
expect(await screen.findByRole('button', { name: 'Saving…' })).toBeDisabled()
|
|
||||||
for (const field of ['Dinner time', 'Day-of reminder', 'Plan draft day', 'Google Chat Supply run time']) {
|
|
||||||
expect(input(field)).toBeDisabled()
|
|
||||||
}
|
|
||||||
expect(screen.getByRole('button', { name: 'Change' })).toBeDisabled()
|
|
||||||
expect(unload()).toBe(true)
|
|
||||||
|
|
||||||
answer(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, dinner_time: '18:15' }))
|
|
||||||
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
|
|
||||||
expect(input('Dinner time')).toBeEnabled()
|
|
||||||
expect(input('Dinner time')).toHaveValue('18:15')
|
|
||||||
await waitFor(() => expect(unload()).toBe(false))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('leaves without asking when nothing changed', async () => {
|
it('leaves without asking when nothing changed', async () => {
|
||||||
stubAPI()
|
stubAPI()
|
||||||
await renderPage()
|
await renderPage()
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset className={styles.lock} disabled={save.isPending}>
|
<>
|
||||||
<Section title="Time zone">
|
<Section title="Time zone">
|
||||||
<TimeZoneField
|
<TimeZoneField
|
||||||
value={draft.timezone}
|
value={draft.timezone}
|
||||||
@@ -202,7 +202,7 @@ function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormPr
|
|||||||
{save.isPending ? 'Saving…' : 'Save changes'}
|
{save.isPending ? 'Saving…' : 'Save changes'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSetting
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset className={styles.lock} disabled={save.isPending}>
|
<>
|
||||||
<Segmented
|
<Segmented
|
||||||
label="Recipe search provider"
|
label="Recipe search provider"
|
||||||
options={providerOptions}
|
options={providerOptions}
|
||||||
@@ -173,7 +173,7 @@ function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSetting
|
|||||||
{save.isPending ? 'Saving…' : 'Save recipe search'}
|
{save.isPending ? 'Saving…' : 'Save recipe search'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||||
import { Route, Routes, useLocation } from 'react-router'
|
import { MemoryRouter, Route, Routes, useLocation } from 'react-router'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
|
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
|
||||||
import { declaredStyle } from '../test/cssRules.ts'
|
import { declaredStyle } from '../test/cssRules.ts'
|
||||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
|
||||||
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
||||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
import { mockViewport } from '../test/matchMedia.ts'
|
import { mockViewport } from '../test/matchMedia.ts'
|
||||||
@@ -40,8 +39,7 @@ function CurrentPath() {
|
|||||||
return <p data-testid="path">{useLocation().pathname}</p>
|
return <p data-testid="path">{useLocation().pathname}</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderSettings renders the settings home on a 390 px phone (mock 1p) in a
|
// renderSettings renders the settings home on a 390 px phone (mock 1p);
|
||||||
// data router (the hidden sub-tabs' leave guard needs one);
|
|
||||||
// SettingsTabs.test.tsx covers 768 px and up.
|
// SettingsTabs.test.tsx covers 768 px and up.
|
||||||
function renderSettings() {
|
function renderSettings() {
|
||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
@@ -49,12 +47,12 @@ function renderSettings() {
|
|||||||
return render(
|
return render(
|
||||||
<ApiProvider client={api}>
|
<ApiProvider client={api}>
|
||||||
<QueryClientProvider client={createQueryClient()}>
|
<QueryClientProvider client={createQueryClient()}>
|
||||||
<InDataRouter path="/settings">
|
<MemoryRouter initialEntries={['/settings']}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="*" element={<CurrentPath />} />
|
<Route path="*" element={<CurrentPath />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</InDataRouter>
|
</MemoryRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ApiProvider>,
|
</ApiProvider>,
|
||||||
)
|
)
|
||||||
@@ -97,11 +95,9 @@ describe('SettingsPage', () => {
|
|||||||
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
||||||
REGULARS_PATH,
|
REGULARS_PATH,
|
||||||
'/api/v1/settings/ai',
|
'/api/v1/settings/ai',
|
||||||
'/api/v1/settings/ai/models',
|
|
||||||
CALENDAR_PATH,
|
CALENDAR_PATH,
|
||||||
'/api/v1/settings/household',
|
'/api/v1/settings/household',
|
||||||
NOTIFICATIONS_PATH,
|
NOTIFICATIONS_PATH,
|
||||||
'/api/v1/settings/recipe-search',
|
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -25,17 +25,9 @@ import styles from './SettingsPage.module.css'
|
|||||||
import { SettingsTabs } from './SettingsTabs.tsx'
|
import { SettingsTabs } from './SettingsTabs.tsx'
|
||||||
|
|
||||||
// Settings home: the list of settings pages on mobile (mock 1p), one page
|
// Settings home: the list of settings pages on mobile (mock 1p), one page
|
||||||
// with sub-tabs from 768 px (mock 2e). The sub-tabs stay mounted, hidden, on
|
// with sub-tabs from 768 px (mock 2e).
|
||||||
// mobile, so an unsaved draft survives the viewport crossing 768 px and its
|
|
||||||
// leave guard keeps asking before the list navigates away.
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const mobile = useBreakpoint() === 'mobile'
|
return useBreakpoint() === 'mobile' ? <SettingsList /> : <SettingsTabs />
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{mobile && <SettingsList />}
|
|
||||||
<SettingsTabs hidden={mobile} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The mobile settings list (handoff mock 1p). Each row opens its settings
|
// The mobile settings list (handoff mock 1p). Each row opens its settings
|
||||||
|
|||||||
@@ -59,8 +59,7 @@
|
|||||||
color: var(--color-bg);
|
color: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A page's or panel's display would override the hidden attribute. */
|
/* A panel's display would override the hidden attribute. */
|
||||||
.page[hidden],
|
|
||||||
.single[hidden],
|
.single[hidden],
|
||||||
.columns[hidden] {
|
.columns[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
import { AI_MODELS, AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
|
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
|
||||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
import { mockViewport } from '../test/matchMedia.ts'
|
import { mockViewport } from '../test/matchMedia.ts'
|
||||||
import { SettingsPage } from './SettingsPage.tsx'
|
import { SettingsPage } from './SettingsPage.tsx'
|
||||||
|
|
||||||
|
const MODELS = { models: [{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }] }
|
||||||
|
|
||||||
type Responder = (body: unknown) => Response
|
type Responder = (body: unknown) => Response
|
||||||
|
|
||||||
// stubAPI answers "METHOD path" from responders, each called with the
|
// stubAPI answers "METHOD path" from responders, each called with the
|
||||||
@@ -20,7 +22,7 @@ function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch>
|
|||||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||||
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||||
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||||
'GET /api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
|
'GET /api/v1/settings/ai/models': () => jsonResponse(200, MODELS),
|
||||||
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||||
...responders,
|
...responders,
|
||||||
}
|
}
|
||||||
@@ -165,29 +167,4 @@ describe('SettingsPage across the 768 px breakpoint', () => {
|
|||||||
expect(screen.getByRole('tablist', { name: 'Settings sections' })).toBeInTheDocument()
|
expect(screen.getByRole('tablist', { name: 'Settings sections' })).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps an unsaved draft and its leave guard across 1180 px, 390 px and back', async () => {
|
|
||||||
stubAPI({
|
|
||||||
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
|
||||||
'GET /api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
|
|
||||||
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
|
||||||
})
|
|
||||||
const resize = await renderAt(1180)
|
|
||||||
fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), {
|
|
||||||
target: { value: 'A draft' },
|
|
||||||
})
|
|
||||||
|
|
||||||
resize(390)
|
|
||||||
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
|
|
||||||
fireEvent.click(screen.getByRole('link', { name: /^AI assistant/ }))
|
|
||||||
const confirm = await screen.findByRole('dialog', { name: 'Leave without saving?' })
|
|
||||||
fireEvent.click(within(confirm).getByRole('button', { name: 'Keep editing' }))
|
|
||||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
|
||||||
|
|
||||||
resize(1180)
|
|
||||||
expect(screen.getByRole('textbox', { name: 'System prompt' })).toHaveValue('A draft')
|
|
||||||
const unload = new Event('beforeunload', { cancelable: true })
|
|
||||||
window.dispatchEvent(unload)
|
|
||||||
expect(unload.defaultPrevented).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ const step: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1 }
|
|||||||
// Settings at tablet and desktop widths (handoff mock 2e): one page with
|
// Settings at tablet and desktop widths (handoff mock 2e): one page with
|
||||||
// sub-tabs instead of the mobile list. Every tab stays mounted while hidden,
|
// sub-tabs instead of the mobile list. Every tab stays mounted while hidden,
|
||||||
// so a draft survives switching tabs, and one leave guard covers them all.
|
// so a draft survives switching tabs, and one leave guard covers them all.
|
||||||
// hidden hides the page but keeps it mounted, drafts and leave guard included.
|
export function SettingsTabs() {
|
||||||
export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
|
||||||
const [tab, setTab] = useState<Tab>('notifications-ai')
|
const [tab, setTab] = useState<Tab>('notifications-ai')
|
||||||
const [householdDirty, setHouseholdDirty] = useState(false)
|
const [householdDirty, setHouseholdDirty] = useState(false)
|
||||||
const [aiDirty, setAIDirty] = useState(false)
|
const [aiDirty, setAIDirty] = useState(false)
|
||||||
@@ -62,7 +61,7 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page} hidden={hidden}>
|
<div className={styles.page}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<div className={styles.heading}>
|
<div className={styles.heading}>
|
||||||
<h1 className={styles.title}>Settings</h1>
|
<h1 className={styles.title}>Settings</h1>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||||
import { AppRoutes } from '../App.tsx'
|
import { AppRoutes } from '../App.tsx'
|
||||||
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
|
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
|
||||||
import { declaredStyle } from '../test/cssRules.ts'
|
import { declaredStyle } from '../test/cssRules.ts'
|
||||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
|
||||||
import { stubFetchByPath } from '../test/fetchStub.ts'
|
import { stubFetchByPath } from '../test/fetchStub.ts'
|
||||||
import { mockViewport } from '../test/matchMedia.ts'
|
import { mockViewport } from '../test/matchMedia.ts'
|
||||||
import styles from './BottomBar.module.css'
|
import styles from './BottomBar.module.css'
|
||||||
@@ -74,11 +74,11 @@ function renderAt(path: string, reducedMotion = false) {
|
|||||||
mockViewport(MOBILE, { reducedMotion })
|
mockViewport(MOBILE, { reducedMotion })
|
||||||
stubFetchByPath(SETTINGS_API)
|
stubFetchByPath(SETTINGS_API)
|
||||||
render(
|
render(
|
||||||
<InDataRouter path={path}>
|
<MemoryRouter initialEntries={[path]}>
|
||||||
<ApiProviders>
|
<ApiProviders>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</ApiProviders>
|
</ApiProviders>
|
||||||
</InDataRouter>,
|
</MemoryRouter>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ export const AI_SETTINGS = {
|
|||||||
updated_at: '2026-10-01T15:00:00.000Z',
|
updated_at: '2026-10-01T15:00:00.000Z',
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI_MODELS is a GET /api/v1/settings/ai/models body: the model AI_SETTINGS
|
|
||||||
// chooses.
|
|
||||||
export const AI_MODELS = { models: [{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }] }
|
|
||||||
|
|
||||||
// HOUSEHOLD_SETTINGS is a GET /api/v1/settings/household body: the seed
|
// HOUSEHOLD_SETTINGS is a GET /api/v1/settings/household body: the seed
|
||||||
// times in New York.
|
// times in New York.
|
||||||
export const HOUSEHOLD_SETTINGS = {
|
export const HOUSEHOLD_SETTINGS = {
|
||||||
@@ -53,15 +49,13 @@ export const RECIPE_SEARCH_SETTINGS = {
|
|||||||
|
|
||||||
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
|
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
|
||||||
// today's server does: the dinners, Google Calendar and notifications status
|
// today's server does: the dinners, Google Calendar and notifications status
|
||||||
// endpoints do not exist yet. The home's sub-tabs stay mounted on mobile, so
|
// endpoints do not exist yet.
|
||||||
// it also reads the AI models and recipe search settings.
|
|
||||||
export const SETTINGS_API = {
|
export const SETTINGS_API = {
|
||||||
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||||
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
|
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
|
||||||
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
||||||
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||||
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||||
'/api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
|
|
||||||
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user