Background grounding and recipe fix-up API (#90)
CI / ci (push) Successful in 2m24s

Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #90.
This commit is contained in:
2026-10-06 16:39:10 -04:00
committed by approver_agent
parent 729c5bd0fe
commit 423b898f54
23 changed files with 2424 additions and 83 deletions
+79 -11
View File
@@ -1,6 +1,6 @@
// Package dinners keeps the household's dinners: adding Regulars by name or
// recipe link, listing Regulars and the Quest log, editing, archiving and
// favoriting. Names are deduplicated with the harness's dinner-name rules,
// 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
@@ -10,6 +10,7 @@ import (
"database/sql"
"errors"
"fmt"
"log/slog"
"net/url"
"slices"
"strings"
@@ -18,6 +19,8 @@ import (
"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"
)
@@ -65,9 +68,12 @@ type Dinner struct {
RecipeURL *string `json:"recipe_url"`
PhotoURL *string `json:"photo_url"`
GroundingStatus string `json:"grounding_status"`
UpdatedBy *string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
// 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.
@@ -120,19 +126,80 @@ func (e *DuplicateError) Error() string {
// Service reads and changes dinners.
type Service struct {
st *store.Store
st *store.Store
grounder *recipes.Grounder
logger *slog.Logger
groundJob *jobs.Kind[GroundJob]
}
// NewService returns a service over st.
func NewService(st *store.Store) *Service {
return &Service{st: st}
// 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. A name
// repeating an unarchived dinner is a *DuplicateError.
func (s *Service) CreateRegular(ctx context.Context, r NewRegular, updatedBy string) (Dinner, error) {
// 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")
}
@@ -445,6 +512,7 @@ func fromRow(r db.Dinner) Dinner {
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,
+14
View File
@@ -0,0 +1,14 @@
package dinners
import (
"testing"
"time"
)
// SetGroundTimeout sets the deadline of grounding by hand until t ends. A
// test calling it must not run in parallel.
func SetGroundTimeout(t *testing.T, d time.Duration) {
old := groundTimeout
groundTimeout = d
t.Cleanup(func() { groundTimeout = old })
}
+497
View File
@@ -0,0 +1,497 @@
package dinners
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// KindGroundDinner is the job that grounds a new Regular in a recipe.
const KindGroundDinner = "ground_dinner"
// Grounding statuses (the dinners.grounding_status column).
const (
GroundingPending = "pending"
GroundingGrounded = "grounded"
GroundingFailed = "failed"
GroundingManual = "manual"
)
// Reasons a dinner's background grounding failed, stored in
// dinners.grounding_error and as the KindGroundDinner job's failure code.
const (
// ReasonNoRecipe: no page the search found holds a usable recipe.
ReasonNoRecipe = "no_recipe_found"
// ReasonRecipeUnusable: the dinner's recipe_url holds no usable recipe.
ReasonRecipeUnusable = "recipe_unusable"
// ReasonSearchNotConfigured: recipe search is not set up.
ReasonSearchNotConfigured = "search_not_configured"
// ReasonSearchFailed: the search provider failed.
ReasonSearchFailed = "search_failed"
// ReasonAIFailed: normalize_recipe's model provider could not be built
// or called.
ReasonAIFailed = "ai_failed"
// ReasonInterrupted: the server stopped while grounding.
ReasonInterrupted = "interrupted"
// ReasonGroundingError: any other failure, such as the database's.
ReasonGroundingError = "grounding_error"
)
// Reasons are every grounding failure reason.
var Reasons = []string{
ReasonNoRecipe, ReasonRecipeUnusable, ReasonSearchNotConfigured, ReasonSearchFailed,
ReasonAIFailed, ReasonInterrupted, ReasonGroundingError,
}
// Limits on candidates and on a recipe entered by hand.
const (
MaxCandidates = 3
MaxManualLines = 100
MaxManualLineChar = 500
// manualSourcePrefix starts the source_url of a recipe entered by hand,
// followed by the recipe's id; it is never a fetchable page.
manualSourcePrefix = "mealquest:manual:"
)
// GroundTimeout bounds grounding a candidate or link by hand, which runs in
// the request: the page fetch (recipes.Timeout), its photo download and
// normalize_recipe (harness.DefaultTimeout), with some slack.
const GroundTimeout = 90 * time.Second
// groundTimeout is GroundTimeout; tests shorten it.
var groundTimeout = GroundTimeout
// ErrGroundTimeout is returned by Ground when grounding a candidate or link
// runs past GroundTimeout; the dinner is left as it was.
var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long")
// GroundJob is the KindGroundDinner payload.
type GroundJob struct {
DinnerID string `json:"dinner_id"`
}
// groundResult is the KindGroundDinner result: the dinner's grounding
// status once the job ends. It is not grounded when the dinner was
// grounded by hand before the job finished.
type groundResult struct {
DinnerID string `json:"dinner_id"`
GroundingStatus string `json:"grounding_status"`
}
func (r groundResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// runGroundJob grounds a pending dinner as its creation asked: its
// recipe_url with GroundURL, else its name with Ground. Success records
// the recipe, photo, method, minutes and difficulty with grounding_status
// grounded; failure records grounding_status failed with its Reason, which
// is also the job's failure code. A dinner no longer pending (grounded by
// hand meanwhile) is left as it is.
func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundResult) error) (groundResult, error) {
row, err := db.New(s.st.Reader).GetDinner(ctx, p.DinnerID)
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", p.DinnerID, err)
}
if row.GroundingStatus != GroundingPending {
return groundResult{DinnerID: row.ID, GroundingStatus: row.GroundingStatus}, nil
}
var gr recipes.Grounded
if row.RecipeUrl.Valid {
gr, err = s.grounder.GroundURL(ctx, row.RecipeUrl.String)
} else {
gr, err = s.grounder.Ground(ctx, row.Name, row.Name)
}
if err != nil {
reason := reasonFor(err, row.RecipeUrl.Valid)
if ctx.Err() != nil {
reason = ReasonInterrupted
}
// Only the reason is logged: the error may quote a dependency's
// response, which jobs never store, return or log.
s.logger.InfoContext(ctx, "dinner grounding failed", "dinner_id", row.ID, "reason", reason)
write := context.WithoutCancel(ctx)
if _, failErr := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{
GroundingError: sql.NullString{String: reason, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
}); failErr != nil {
return groundResult{}, fmt.Errorf("dinners: ground: record failure of %s: %w", row.ID, failErr)
}
if ctx.Err() != nil {
return groundResult{}, ctx.Err()
}
return groundResult{}, &jobs.Failure{Code: reason}
}
n, err := db.New(s.st.Writer).GroundPendingDinner(ctx, db.GroundPendingDinnerParams{
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
Method: sql.NullString{String: gr.Method, Valid: true},
MinutesTotal: nullInt(gr.MinutesTotal),
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
})
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: record %s: %w", row.ID, err)
}
if n == 0 {
cur, err := db.New(s.st.Reader).GetDinner(ctx, row.ID)
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", row.ID, err)
}
return groundResult{DinnerID: cur.ID, GroundingStatus: cur.GroundingStatus}, nil
}
return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil
}
// FailInterruptedGroundings fails, with ReasonInterrupted, every dinner
// still pending whose KindGroundDinner job is no longer queued or running:
// a previous process stopped before enqueuing the job or before its
// handler recorded a result. Call it once at boot, after the job runner's
// Start has failed the jobs that process left running, so no dinner stays
// pending with nothing left to ground it. It returns how many it failed.
func (s *Service) FailInterruptedGroundings(ctx context.Context) (int64, error) {
n, err := db.New(s.st.Writer).FailOrphanedPendingDinnerGroundings(ctx, db.FailOrphanedPendingDinnerGroundingsParams{
GroundingError: sql.NullString{String: ReasonInterrupted, Valid: true},
UpdatedAt: store.Now().String(),
Kind: KindGroundDinner,
})
if err != nil {
return 0, fmt.Errorf("dinners: fail interrupted groundings: %w", err)
}
if n > 0 {
s.logger.WarnContext(ctx, "failed dinner groundings interrupted by restart", "count", n)
}
return n, nil
}
// reasonFor is the Reason for a grounding error; fromURL says whether the
// dinner's recipe_url was grounded rather than its name.
func reasonFor(err error, fromURL bool) string {
var ungroundable *recipes.ErrUngroundable
switch {
case errors.As(err, &ungroundable) && fromURL:
return ReasonRecipeUnusable
case errors.As(err, &ungroundable):
return ReasonNoRecipe
case errors.Is(err, recipes.ErrSearchNotConfigured):
return ReasonSearchNotConfigured
case errors.Is(err, recipes.ErrSearchFailed):
return ReasonSearchFailed
case errors.Is(err, recipes.ErrNormalizeFailed):
return ReasonAIFailed
}
return ReasonGroundingError
}
// Candidate is a recipe page offered to ground a dinner in.
type Candidate struct {
// CandidateID is the page's URL; Fix.CandidateID grounds it.
CandidateID string `json:"candidate_id"`
Title string `json:"title"`
SiteName string `json:"site_name"`
// PhotoURL is the cached photo of a page already grounded, else null.
PhotoURL *string `json:"photo_url"`
Minutes *int `json:"minutes"`
}
// Candidates searches for dinner id's name and returns up to MaxCandidates
// recipe pages to ground it in, best first; the list may be empty. A
// search that fails wraps recipes.ErrSearchFailed.
func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error) {
row, err := s.get(ctx, id)
if err != nil {
return nil, err
}
offers, err := s.grounder.Candidates(ctx, row.Name, row.Name, MaxCandidates)
if err != nil {
return nil, err
}
out := make([]Candidate, len(offers))
for i, o := range offers {
out[i] = Candidate{CandidateID: o.URL, Title: o.Title, SiteName: o.SiteName, Minutes: o.Minutes}
if o.PhotoID != "" {
u := PhotoURL(o.PhotoID)
out[i].PhotoURL = &u
}
}
return out, nil
}
// Fix grounds a dinner by hand: exactly one of a candidate from
// Candidates, a pasted recipe link or a recipe typed in.
type Fix struct {
CandidateID *string
RecipeURL *string
Manual *ManualRecipe
}
// ManualRecipe is a recipe typed in by the household.
type ManualRecipe struct {
// Ingredients are the ingredient lines, at least one.
Ingredients []string
// Steps are the instructions, in order; there may be none.
Steps []string
Minutes *int
Method *string
}
// Ground grounds dinner id as f says, recording updatedBy. A candidate or
// link is grounded with recipes.Grounder.GroundURL and sets
// grounding_status grounded, with the recipe's photo, method, minutes and
// difficulty. A manual recipe is stored as typed — no model runs — and
// sets grounding_status manual with its method and minutes; it has no
// photo, and the dinner keeps its difficulty. An invalid f is a
// *ValidationError; a page that cannot ground the dinner is a
// *recipes.ErrUngroundable and changes nothing.
func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: ground: no member to record as updated_by")
}
link, manual, err := f.validate()
if err != nil {
return Dinner{}, err
}
if manual != nil {
return s.groundManual(ctx, id, *manual, updatedBy)
}
if _, err := s.get(ctx, id); err != nil {
return Dinner{}, err
}
groundCtx, cancel := context.WithTimeout(ctx, groundTimeout)
defer cancel()
gr, err := s.grounder.GroundURL(groundCtx, link)
if err != nil {
if ctx.Err() == nil && errors.Is(groundCtx.Err(), context.DeadlineExceeded) {
return Dinner{}, ErrGroundTimeout
}
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err)
}
row, err := db.New(s.st.Writer).GroundDinner(ctx, db.GroundDinnerParams{
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
Method: sql.NullString{String: gr.Method, Valid: true},
MinutesTotal: nullInt(gr.MinutesTotal),
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
GroundingStatus: GroundingGrounded,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
ID: id,
})
if errors.Is(err, sql.ErrNoRows) {
return Dinner{}, ErrNotFound
}
if err != nil {
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err)
}
return fromRow(row), nil
}
// groundManual stores m as dinner id's recipe, with its ingredients and
// steps, and records it on the dinner, all in one transaction.
func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, updatedBy string) (Dinner, error) {
var row db.Dinner
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetDinner(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("dinners: read %s: %w", id, err)
}
ld, err := json.Marshal(manualJSONLD{
Context: "https://schema.org",
Type: "Recipe",
Name: cur.Name,
Ingredients: m.Ingredients,
Instructions: m.Steps,
TotalTime: fmt.Sprintf("PT%dM", *m.Minutes),
})
if err != nil {
return fmt.Errorf("dinners: encode manual recipe: %w", err)
}
now := store.Now().String()
recipeID := store.NewID()
minutes := sql.NullInt64{Int64: int64(*m.Minutes), Valid: true}
method := sql.NullString{String: *m.Method, Valid: true}
rec, err := q.CreateManualRecipe(ctx, db.CreateManualRecipeParams{
ID: recipeID,
SourceUrl: manualSourcePrefix + recipeID,
Title: cur.Name,
TotalMinutes: minutes,
RawJsonld: string(ld),
Method: method,
MinutesTotal: minutes,
FetchedAt: now,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return fmt.Errorf("dinners: store manual recipe: %w", err)
}
for i, line := range m.Ingredients {
if err := q.CreateRecipeIngredient(ctx, db.CreateRecipeIngredientParams{
ID: store.NewID(),
RecipeID: rec.ID,
Position: int64(i),
RawText: line,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("dinners: store manual ingredient %d: %w", i+1, err)
}
}
for i, text := range m.Steps {
if err := q.CreateRecipeStep(ctx, db.CreateRecipeStepParams{
ID: store.NewID(),
RecipeID: rec.ID,
Position: int64(i),
Text: text,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("dinners: store manual step %d: %w", i+1, err)
}
}
row, err = q.GroundDinner(ctx, db.GroundDinnerParams{
RecipeID: sql.NullString{String: rec.ID, Valid: true},
Method: method,
MinutesTotal: minutes,
Difficulty: cur.Difficulty,
GroundingStatus: GroundingManual,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: now,
ID: cur.ID,
})
if err != nil {
return fmt.Errorf("dinners: ground %s: %w", id, err)
}
return nil
})
if err != nil {
return Dinner{}, err
}
return fromRow(row), nil
}
// manualJSONLD is the schema.org Recipe stored as a manual recipe's
// raw_jsonld, so it reads like a fetched page's.
type manualJSONLD struct {
Context string `json:"@context"`
Type string `json:"@type"`
Name string `json:"name"`
Ingredients []string `json:"recipeIngredient"`
Instructions []string `json:"recipeInstructions"`
TotalTime string `json:"totalTime"`
}
// validate checks f and returns the link to ground, or the manual recipe
// with its lines trimmed.
func (f Fix) validate() (link string, manual *ManualRecipe, err error) {
given := 0
for _, set := range []bool{f.CandidateID != nil, f.RecipeURL != nil, f.Manual != nil} {
if set {
given++
}
}
if given != 1 {
return "", nil, invalid("ground", "give exactly one of candidate_id, recipe_url and manual")
}
switch {
case f.CandidateID != nil:
u, err := checkRecipeURL(*f.CandidateID)
if err != nil {
return "", nil, invalid("candidate_id", "is not a candidate from recipe-candidates")
}
return u.String(), nil, nil
case f.RecipeURL != nil:
u, err := checkRecipeURL(*f.RecipeURL)
if err != nil {
return "", nil, err
}
return u.String(), nil, nil
}
m, err := f.Manual.validate()
if err != nil {
return "", nil, err
}
return "", &m, nil
}
// validate checks every field of m and returns it with its lines trimmed.
func (m ManualRecipe) validate() (ManualRecipe, error) {
var problems []Problem
ings, ingProblem := checkLines(m.Ingredients, 1)
if ingProblem != "" {
problems = append(problems, Problem{"manual.ingredients", ingProblem})
}
steps, stepProblem := checkLines(m.Steps, 0)
if stepProblem != "" {
problems = append(problems, Problem{"manual.steps", stepProblem})
}
if m.Minutes == nil || *m.Minutes < 1 || *m.Minutes > MaxMinutes {
problems = append(problems, Problem{"manual.minutes", fmt.Sprintf("must be 1 to %d", MaxMinutes)})
}
if m.Method == nil || !slices.Contains(Methods, *m.Method) {
problems = append(problems, Problem{"manual.method", "must be one of " + strings.Join(Methods, ", ")})
}
if problems != nil {
return ManualRecipe{}, &ValidationError{Problems: problems}
}
return ManualRecipe{Ingredients: ings, Steps: steps, Minutes: m.Minutes, Method: m.Method}, nil
}
// checkLines returns lines trimmed, or why they are refused: fewer than
// least, more than MaxManualLines, or one blank or over MaxManualLineChar.
func checkLines(lines []string, least int) ([]string, string) {
if len(lines) < least {
return nil, fmt.Sprintf("must have at least %d line", least)
}
if len(lines) > MaxManualLines {
return nil, fmt.Sprintf("must have at most %d lines", MaxManualLines)
}
out := make([]string, len(lines))
for i, line := range lines {
line = strings.TrimSpace(line)
switch {
case line == "":
return nil, fmt.Sprintf("line %d is blank", i+1)
case utf8.RuneCountInString(line) > MaxManualLineChar:
return nil, fmt.Sprintf("line %d is over %d characters", i+1, MaxManualLineChar)
}
out[i] = line
}
return out, ""
}
// get reads dinner id, or ErrNotFound.
func (s *Service) get(ctx context.Context, id string) (db.Dinner, error) {
row, err := db.New(s.st.Reader).GetDinner(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return db.Dinner{}, ErrNotFound
}
if err != nil {
return db.Dinner{}, fmt.Errorf("dinners: read %s: %w", id, err)
}
return row, nil
}
func nullInt(v *int) sql.NullInt64 {
if v == nil {
return sql.NullInt64{}
}
return sql.NullInt64{Int64: int64(*v), Valid: true}
}
+595
View File
@@ -0,0 +1,595 @@
package dinners_test
import (
"context"
"errors"
"io"
"log/slog"
"slices"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const member = "member-1"
// Fixture pages: two curry recipes from search and a stew at a pasted link.
const (
curryA = "https://a.example/chicken-curry"
curryB = "https://b.example/curry"
stew = "https://c.example/recipes/beef-stew"
)
type env struct {
svc *dinners.Service
st *store.Store
runner *jobs.Runner
search *recipestest.Search
pages *recipestest.Pages
model *llm.Fake
}
// newEnv returns a dinners service over a started job runner, grounding
// with fake search, pages, images and model. Searching "Chicken curry"
// finds curryA, curryB and a missing page.
func newEnv(t *testing.T) *env {
t.Helper()
e := newUnstartedEnv(t)
e.start(t)
return e
}
// newUnstartedEnv is newEnv with its job runner not yet started, so jobs
// enqueued stay queued until start.
func newUnstartedEnv(t *testing.T) *env {
t.Helper()
dataDir := t.TempDir()
st, err := store.Open(t.Context(), dataDir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
search := &recipestest.Search{Results: map[string][]recipes.Candidate{
"Chicken curry": {{URL: curryA}, {URL: "https://missing.example/x"}, {URL: curryB}},
}}
pages := &recipestest.Pages{Bodies: map[string][]byte{
curryA: recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40),
curryB: recipestest.RecipePage("Easy Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25),
stew: recipestest.RecipePage("Beef Stew", "Stew Co", "https://c.example/stew.jpg", 150),
}}
images := &recipestest.Images{JPEGs: map[string][]byte{
"https://a.example/curry.jpg": recipestest.JPEG(t, 400, 320, 1),
"https://b.example/curry.jpg": recipestest.JPEG(t, 400, 320, 2),
"https://c.example/stew.jpg": recipestest.JPEG(t, 400, 320, 3),
}}
model := &llm.Fake{}
g := recipestest.NewGrounder(t, st, dataDir, search, pages, images, model)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
svc, err := dinners.NewService(st, g, runner, logger)
if err != nil {
t.Fatal(err)
}
return &env{svc: svc, st: st, runner: runner, search: search, pages: pages, model: model}
}
// start starts the env's job runner, stopping it when the test ends.
func (e *env) start(t *testing.T) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
if err := e.runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
e.runner.Wait()
})
}
func ptr[T any](v T) *T { return &v }
// waitJob waits for job id to finish and returns it.
func (e *env) waitJob(t *testing.T, id string) jobs.Job {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
job, err := e.runner.Get(t.Context(), id)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
return job
}
if time.Now().After(deadline) {
t.Fatalf("job %s never finished; last %+v", id, job)
}
time.Sleep(5 * time.Millisecond)
}
}
func (e *env) dinner(t *testing.T, id string) db.Dinner {
t.Helper()
row, err := db.New(e.st.Reader).GetDinner(t.Context(), id)
if err != nil {
t.Fatal(err)
}
return row
}
func TestCreateGroundsInBackground(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
if c.GroundingStatus != dinners.GroundingPending || c.GroundingJobID == nil {
t.Fatalf("created = %+v, want pending with a job id", c)
}
job := e.waitJob(t, *c.GroundingJobID)
if job.Kind != dinners.KindGroundDinner || job.Status != jobs.StatusSucceeded ||
string(job.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"grounded"}` {
t.Fatalf("job = %+v (result %s)", job, job.Result)
}
row := e.dinner(t, c.ID)
if row.GroundingStatus != "grounded" || !row.RecipeID.Valid || !row.PhotoID.Valid ||
row.Method.String != "stovetop" || row.MinutesTotal.Int64 != 40 || row.Difficulty.String != "easy" || row.GroundingError.Valid {
t.Fatalf("dinner = %+v, want grounded with recipe, photo, stovetop, 40 minutes, easy", row)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != curryA || rec.PhotoID != row.PhotoID {
t.Errorf("recipe = %s photo %s, want %s photo %s", rec.SourceUrl, rec.PhotoID.String, curryA, row.PhotoID.String)
}
if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) {
t.Errorf("searched %q, want the dinner's name", got)
}
}
func TestCreateByRecipeURLGroundsThatURL(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(stew)}, member)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v", job)
}
row := e.dinner(t, c.ID)
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
if err != nil {
t.Fatal(err)
}
if row.GroundingStatus != "grounded" || rec.SourceUrl != stew || row.Method.String != "oven" || row.MinutesTotal.Int64 != 150 {
t.Fatalf("dinner = %+v recipe %s, want grounded in %s", row, rec.SourceUrl, stew)
}
if q := e.search.Queries(); len(q) != 0 {
t.Errorf("searched %q; a recipe_url is grounded directly", q)
}
}
func TestCreateGroundingFailureRecordsReason(t *testing.T) {
t.Parallel()
tests := []struct {
name string
create dinners.NewRegular
setup func(*env)
reason string
}{
{"no candidates", dinners.NewRegular{Name: ptr("Unknown dish")}, nil, dinners.ReasonNoRecipe},
{"unusable link", dinners.NewRegular{RecipeURL: ptr("https://gone.example/recipes/lost-soup")}, nil, dinners.ReasonRecipeUnusable},
{"search fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) { e.search.Err = recipes.ErrSearchRateLimited }, dinners.ReasonSearchFailed},
{"model fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) {
// One provider failure per candidate tried.
e.model.QueueGenerate(llm.GenerateResult{Err: llm.ErrAuth})
}, dinners.ReasonAIFailed},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
if tt.setup != nil {
tt.setup(e)
}
c, err := e.svc.CreateRegular(t.Context(), tt.create, member)
if err != nil {
t.Fatal(err)
}
job := e.waitJob(t, *c.GroundingJobID)
if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != tt.reason {
t.Errorf("job = %+v, want failed with %s", job, tt.reason)
}
row := e.dinner(t, c.ID)
if row.GroundingStatus != "failed" || row.GroundingError.String != tt.reason || row.RecipeID.Valid {
t.Errorf("dinner = %+v, want failed with %s", row, tt.reason)
}
})
}
}
func TestCreateReturnsBeforeGroundingCompletes(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.search.Gate = make(chan struct{})
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
// The search is held, so the job cannot have finished.
if row := e.dinner(t, c.ID); row.GroundingStatus != "pending" {
t.Fatalf("grounding_status = %s right after create, want pending", row.GroundingStatus)
}
if job, err := e.runner.Get(t.Context(), *c.GroundingJobID); err != nil || (job.Status != jobs.StatusQueued && job.Status != jobs.StatusRunning) {
t.Fatalf("job = %+v, %v; want queued or running", job, err)
}
close(e.search.Gate)
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v", job)
}
if row := e.dinner(t, c.ID); row.GroundingStatus != "grounded" {
t.Errorf("grounding_status = %s, want grounded", row.GroundingStatus)
}
}
func TestManualFixWhilePendingIsKept(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.search.Gate = make(chan struct{})
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
if _, err := e.svc.Ground(t.Context(), c.ID, dinners.Fix{Manual: &dinners.ManualRecipe{
Ingredients: []string{"1 chicken"}, Minutes: ptr(30), Method: ptr("oven"),
}}, member); err != nil {
t.Fatal(err)
}
close(e.search.Gate)
job := e.waitJob(t, *c.GroundingJobID)
if job.Status != jobs.StatusSucceeded || string(job.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"manual"}` {
t.Errorf("job = %+v (result %s), want succeeded reporting manual", job, job.Result)
}
if row := e.dinner(t, c.ID); row.GroundingStatus != "manual" || row.Method.String != "oven" {
t.Errorf("dinner = %+v, want the manual grounding kept", row)
}
}
// failedDinner creates a Regular whose background grounding fails.
func failedDinner(t *testing.T, e *env, name string) string {
t.Helper()
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr(name)}, member)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusFailed {
t.Fatalf("job = %+v, want failed", job)
}
return c.ID
}
func TestCandidates(t *testing.T) {
t.Parallel()
e := newEnv(t)
// The background grounding stores curryA with its cached photo.
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v", job)
}
got, err := e.svc.Candidates(t.Context(), c.ID)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("candidates = %+v, want the two usable pages", got)
}
first, second := got[0], got[1]
wantPhoto := dinners.PhotoURL(e.dinner(t, c.ID).PhotoID.String)
if first.CandidateID != curryA || first.Title != "Chicken Curry" || first.SiteName != "Curry House" ||
first.PhotoURL == nil || *first.PhotoURL != wantPhoto || first.Minutes == nil || *first.Minutes != 40 {
t.Errorf("first = %+v, want the stored curryA with photo %s", first, wantPhoto)
}
if second.CandidateID != curryB || second.Title != "Easy Weeknight Curry" || second.SiteName != "B Kitchen" ||
second.PhotoURL != nil || second.Minutes == nil || *second.Minutes != 25 {
t.Errorf("second = %+v, want curryB without a photo", second)
}
if _, err := e.svc.Candidates(t.Context(), "nope"); !errors.Is(err, dinners.ErrNotFound) {
t.Errorf("unknown dinner: err = %v, want ErrNotFound", err)
}
}
func TestCandidatesSearchFails(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.search.Err = recipes.ErrSearchRefused
id := failedDinner(t, e, "Chicken curry")
if _, err := e.svc.Candidates(t.Context(), id); !errors.Is(err, recipes.ErrSearchFailed) {
t.Errorf("err = %v, want ErrSearchFailed", err)
}
}
func TestCandidatesNone(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Fish tacos")
got, err := e.svc.Candidates(t.Context(), id)
if err != nil || len(got) != 0 {
t.Fatalf("no search results: candidates = %+v, %v; want none", got, err)
}
}
func TestFixViaCandidate(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(curryB)}, "member-2")
if err != nil {
t.Fatal(err)
}
if d.GroundingStatus != "grounded" || d.GroundingError != nil || d.RecipeID == nil || d.PhotoURL == nil ||
*d.Method != "stovetop" || *d.MinutesTotal != 25 || *d.Difficulty != "easy" || *d.UpdatedBy != "member-2" {
t.Fatalf("dinner = %+v", d)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), *d.RecipeID)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != curryB {
t.Errorf("recipe source = %s, want %s", rec.SourceUrl, curryB)
}
}
func TestFixViaRecipeURL(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Grandma's stew")
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr(" " + stew + " ")}, member)
if err != nil {
t.Fatal(err)
}
if d.GroundingStatus != "grounded" || d.PhotoURL == nil || *d.Method != "oven" || *d.MinutesTotal != 150 {
t.Fatalf("dinner = %+v", d)
}
// A link holding no recipe changes nothing.
before := e.dinner(t, id)
_, err = e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr("https://gone.example/x")}, member)
var ungroundable *recipes.ErrUngroundable
if !errors.As(err, &ungroundable) {
t.Fatalf("err = %v, want *recipes.ErrUngroundable", err)
}
if after := e.dinner(t, id); after != before {
t.Errorf("dinner changed by a failed fix: %+v, was %+v", after, before)
}
}
func TestFixViaManual(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Family chili")
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Difficulty: ptr("medium")}, member); err != nil {
t.Fatal(err)
}
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{Manual: &dinners.ManualRecipe{
Ingredients: []string{" 1 lb beef ", "1 can beans"},
Steps: []string{"Brown the beef.", "Simmer with the beans."},
Minutes: ptr(240),
Method: ptr("crockpot"),
}}, member)
if err != nil {
t.Fatal(err)
}
if d.GroundingStatus != "manual" || d.GroundingError != nil || d.RecipeID == nil || d.PhotoURL != nil ||
*d.Method != "crockpot" || *d.MinutesTotal != 240 || *d.Difficulty != "medium" {
t.Fatalf("dinner = %+v", d)
}
q := db.New(e.st.Reader)
rec, err := q.GetRecipe(t.Context(), *d.RecipeID)
if err != nil {
t.Fatal(err)
}
if rec.Title != "Family chili" || rec.SourceUrl != "mealquest:manual:"+rec.ID || rec.MinutesTotal.Int64 != 240 || rec.PhotoID.Valid {
t.Errorf("recipe = %+v", rec)
}
ings, err := q.ListRecipeIngredients(t.Context(), rec.ID)
if err != nil {
t.Fatal(err)
}
steps, err := q.ListRecipeSteps(t.Context(), rec.ID)
if err != nil {
t.Fatal(err)
}
if len(ings) != 2 || ings[0].RawText != "1 lb beef" || len(steps) != 2 || steps[1].Text != "Simmer with the beans." {
t.Errorf("ingredients = %+v, steps = %+v", ings, steps)
}
}
func TestFixValidation(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Pad thai")
good := func() *dinners.ManualRecipe {
return &dinners.ManualRecipe{Ingredients: []string{"noodles"}, Minutes: ptr(20), Method: ptr("stovetop")}
}
tests := map[string]dinners.Fix{
"nothing": {},
"two": {CandidateID: ptr(curryA), RecipeURL: ptr(stew)},
"candidate not URL": {CandidateID: ptr("cand-1")},
"http link": {RecipeURL: ptr("http://a.example/x")},
"no ingredients": {Manual: &dinners.ManualRecipe{Minutes: ptr(20), Method: ptr("oven")}},
"blank ingredient": {Manual: &dinners.ManualRecipe{Ingredients: []string{" "}, Minutes: ptr(20), Method: ptr("oven")}},
"blank step": {Manual: func() *dinners.ManualRecipe { m := good(); m.Steps = []string{""}; return m }()},
"no minutes": {Manual: func() *dinners.ManualRecipe { m := good(); m.Minutes = nil; return m }()},
"zero minutes": {Manual: func() *dinners.ManualRecipe { m := good(); m.Minutes = ptr(0); return m }()},
"unknown method": {Manual: func() *dinners.ManualRecipe { m := good(); m.Method = ptr("microwave"); return m }()},
"no method": {Manual: func() *dinners.ManualRecipe { m := good(); m.Method = nil; return m }()},
"too many lines": {Manual: func() *dinners.ManualRecipe { m := good(); m.Ingredients = make([]string, 101); return m }()},
"manual and a link": {RecipeURL: ptr(stew), Manual: good()},
"over-long ing line": {Manual: func() *dinners.ManualRecipe {
m := good()
m.Ingredients = []string{string(make([]byte, 501))}
return m
}()},
}
before := e.dinner(t, id)
for name, fix := range tests {
_, err := e.svc.Ground(t.Context(), id, fix, member)
var invalid *dinners.ValidationError
if !errors.As(err, &invalid) {
t.Errorf("%s: err = %v, want *ValidationError", name, err)
}
}
if after := e.dinner(t, id); after != before {
t.Errorf("dinner changed by invalid fixes: %+v", after)
}
if _, err := e.svc.Ground(t.Context(), "nope", dinners.Fix{Manual: good()}, member); !errors.Is(err, dinners.ErrNotFound) {
t.Errorf("unknown dinner, manual: err = %v, want ErrNotFound", err)
}
if _, err := e.svc.Ground(t.Context(), "nope", dinners.Fix{RecipeURL: ptr(stew)}, member); !errors.Is(err, dinners.ErrNotFound) {
t.Errorf("unknown dinner, link: err = %v, want ErrNotFound", err)
}
}
// TestRestartFailsInterruptedGrounding simulates a crash: one dinner's job
// was left running and another dinner was saved before its job was
// enqueued. At the next boot both end failed as interrupted, while a
// dinner whose job is still queued stays pending and is grounded.
func TestRestartFailsInterruptedGrounding(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.search.Gate = make(chan struct{})
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
running, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Fish pie")}, member)
if err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.ExecContext(t.Context(),
`UPDATE jobs SET status = 'running', started_at = updated_at WHERE id = ?`, *running.GroundingJobID); err != nil {
t.Fatal(err)
}
unqueued, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Lentil soup")}, member)
if err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM jobs WHERE id = ?`, *unqueued.GroundingJobID); err != nil {
t.Fatal(err)
}
queued, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
// Boot: the runner fails the running job, then the service fails the
// dinners nothing is left to ground. The search is held, so the queued
// dinner's job cannot finish meanwhile.
e.start(t)
n, err := e.svc.FailInterruptedGroundings(t.Context())
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Errorf("failed %d dinners, want 2", n)
}
if job, err := e.runner.Get(t.Context(), *running.GroundingJobID); err != nil || job.Status != jobs.StatusFailed {
t.Errorf("running job = %+v, %v; want failed", job, err)
}
for _, id := range []string{running.ID, unqueued.ID} {
if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed || row.GroundingError.String != dinners.ReasonInterrupted {
t.Errorf("dinner %s = %s (%s), want failed (interrupted)", id, row.GroundingStatus, row.GroundingError.String)
}
}
if row := e.dinner(t, queued.ID); row.GroundingStatus != dinners.GroundingPending {
t.Errorf("queued dinner = %s, want pending", row.GroundingStatus)
}
close(e.search.Gate)
if job := e.waitJob(t, *queued.GroundingJobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("queued job = %+v", job)
}
if row := e.dinner(t, queued.ID); row.GroundingStatus != dinners.GroundingGrounded {
t.Errorf("queued dinner = %s, want grounded", row.GroundingStatus)
}
}
// TestCreateEnqueueFailureReturnsFailedDinner: a dinner saved when its job
// cannot be enqueued is returned, with its id, failed and no job id.
func TestCreateEnqueueFailureReturnsFailedDinner(t *testing.T) {
t.Parallel()
e := newEnv(t)
if _, err := e.st.Writer.ExecContext(t.Context(),
`CREATE TRIGGER refuse_jobs BEFORE INSERT ON jobs BEGIN SELECT RAISE(ABORT, 'no jobs'); END`); err != nil {
t.Fatal(err)
}
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
if c.ID == "" || c.GroundingJobID != nil || c.GroundingStatus != dinners.GroundingFailed ||
c.GroundingError == nil || *c.GroundingError != dinners.ReasonGroundingError {
t.Fatalf("created = %+v, want its id, failed (grounding_error) and no job", c)
}
if row := e.dinner(t, c.ID); row.GroundingStatus != dinners.GroundingFailed {
t.Errorf("stored grounding_status = %s, want failed", row.GroundingStatus)
}
}
// TestGroundTimesOut: grounding a link by hand that runs past its deadline
// is ErrGroundTimeout and leaves the dinner as it was. It shortens the
// package deadline, so it does not run in parallel.
func TestGroundTimesOut(t *testing.T) {
dinners.SetGroundTimeout(t, 50*time.Millisecond)
e := newEnv(t)
id := failedDinner(t, e, "Beef stew")
e.pages.Gate = make(chan struct{})
defer close(e.pages.Gate)
_, err := e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr(stew)}, member)
if !errors.Is(err, dinners.ErrGroundTimeout) {
t.Fatalf("err = %v, want ErrGroundTimeout", err)
}
if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed {
t.Errorf("grounding_status = %s, want failed as before", row.GroundingStatus)
}
}