Fix Feature 12 review findings F2–F8 on Regular grounding and onboarding
CI / ci (push) Successful in 2m21s
CI / ci (pull_request) Successful in 2m29s

Grounding (F2–F5):
- Each ground_dinner job carries the dinner's new grounding_revision
  (migration 0010). Renaming a pending Regular raises the revision, and a
  job only grounds and writes a dinner still at its revision, so a queued
  original job no longer grounds the new name or decides the outcome.
- Background grounding keeps a method, minutes or difficulty a member saved
  while the Regular was pending, and fills only those left empty.
- Grounding by hand clears the pasted recipe_url, and link deduplication
  uses the source_url of the attached recipe, else the pending link.
- Archived dinners are not grounded: the job checks before any search,
  fetch or model call, and its final update requires an unarchived row.

Onboarding (F6, F7):
- Step 3 shows "Add Regulars" back to step 2 when the server refuses to
  finish with fewer than three Regulars.
- Common picks are hidden by a copy of harness Normalize/Similar, checked
  against the same shared cases as the Go code.

Docs (F8): ARCHITECTURE.md covers the three-Regular minimum, step-3
recovery, the grounding revision and the dedupe rules.

F1 was fixed in 1304487; its test now also asserts the old link is never
grounded and recipe_url stays cleared.

Maestro task 5037ff86-181d-448e-927b-c2fd2258a25d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 19:24:47 -04:00
co-authored by Claude Opus 5.5
parent 13044879f6
commit 837c9924d1
22 changed files with 875 additions and 232 deletions
+54 -46
View File
@@ -176,7 +176,7 @@ func (s *Service) CreateRegular(ctx context.Context, r NewRegular, updatedBy str
if err != nil {
return Created{}, err
}
jobID, err := s.enqueueGrounding(ctx, d.ID, harness.Normalize(d.Name))
jobID, err := s.enqueueGrounding(ctx, d.ID, d.GroundingRevision)
if err != nil {
return Created{}, err
}
@@ -187,60 +187,59 @@ func (s *Service) CreateRegular(ctx context.Context, r NewRegular, updatedBy str
}
return Created{Dinner: fromRow(row)}, nil
}
return Created{Dinner: d, GroundingJobID: jobID}, nil
return Created{Dinner: fromRow(d), GroundingJobID: jobID}, nil
}
// enqueueGrounding enqueues the KindGroundDinner job for pending dinner id,
// whose normalized name is normalizedName, and returns its id. When the job
// cannot be enqueued, the dinner's grounding is failed with
// ReasonGroundingError, so it is not left pending with no job, and the id
// is nil.
func (s *Service) enqueueGrounding(ctx context.Context, id, normalizedName string) (*string, error) {
jobID, err := s.groundJob.Enqueue(ctx, GroundJob{DinnerID: id})
// enqueueGrounding enqueues the KindGroundDinner job for pending dinner id
// at grounding_revision revision and returns its id. When the job cannot be
// enqueued, the dinner's grounding is failed with ReasonGroundingError, so
// it is not left pending with no job, and the id is nil.
func (s *Service) enqueueGrounding(ctx context.Context, id string, revision int64) (*string, error) {
jobID, err := s.groundJob.Enqueue(ctx, GroundJob{DinnerID: id, Revision: revision})
if err == nil {
return &jobID, nil
}
// The error is a database error, never a dependency's text.
s.logger.ErrorContext(ctx, "dinner grounding not enqueued", "dinner_id", id, "error", err)
if _, err := db.New(s.st.Writer).FailPendingDinnerGrounding(context.WithoutCancel(ctx), db.FailPendingDinnerGroundingParams{
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
UpdatedAt: store.Now().String(),
ID: id,
NormalizedName: normalizedName,
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
UpdatedAt: store.Now().String(),
ID: id,
GroundingRevision: revision,
}); err != nil {
return nil, fmt.Errorf("dinners: record failed grounding of %s: %w", id, err)
}
return nil, nil
}
func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy string) (Dinner, error) {
func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy string) (db.Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: create: no member to record as updated_by")
return db.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")
return db.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
return db.Dinner{}, err
}
name = n
case r.RecipeURL != nil:
u, err := checkRecipeURL(*r.RecipeURL)
if err != nil {
return Dinner{}, err
return db.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")
return db.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")
return db.Dinner{}, invalid("name", "give a name or a recipe_url")
}
var row db.Dinner
@@ -269,9 +268,9 @@ func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy str
return nil
})
if err != nil {
return Dinner{}, err
return db.Dinner{}, err
}
return fromRow(row), nil
return row, nil
}
// List returns the dinners with status, which must be regular or
@@ -294,9 +293,10 @@ func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
// 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. Renaming a dinner still pending
// clears its recipe_url and enqueues a fresh KindGroundDinner job that
// grounds the new name: the job already running was for the old name or
// link, and its result no longer applies.
// clears its recipe_url, raises its grounding_revision and enqueues a fresh
// KindGroundDinner job that grounds the new name: a job queued or running
// for the old name or link carries the old revision, so it can no longer
// write the dinner.
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")
@@ -318,21 +318,22 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
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,
RecipeUrl: cur.RecipeUrl,
PhotoID: cur.PhotoID,
GroundingStatus: cur.GroundingStatus,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
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,
RecipeUrl: cur.RecipeUrl,
PhotoID: cur.PhotoID,
GroundingStatus: cur.GroundingStatus,
GroundingRevision: cur.GroundingRevision,
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 {
@@ -344,6 +345,7 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
// The link was the old dinner's recipe; the new name is
// grounded by name, and a link can be given by hand.
params.RecipeUrl = sql.NullString{}
params.GroundingRevision++
}
}
if p.Method != nil {
@@ -359,14 +361,14 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
if err != nil {
return fmt.Errorf("dinners: update %s: %w", id, err)
}
regrounding = row.GroundingStatus == GroundingPending && row.NormalizedName != cur.NormalizedName
regrounding = row.GroundingRevision != cur.GroundingRevision
return nil
})
if err != nil {
return Dinner{}, err
}
if regrounding {
jobID, err := s.enqueueGrounding(ctx, row.ID, row.NormalizedName)
jobID, err := s.enqueueGrounding(ctx, row.ID, row.GroundingRevision)
if err != nil {
return Dinner{}, err
}
@@ -514,18 +516,24 @@ func nameFromURL(u *url.URL) (name string, ok bool) {
}
// checkUnique returns a *DuplicateError when name is harness.Similar to an
// unarchived dinner other than self, or recipeURL is one's recipe link.
// unarchived dinner other than self, or recipeURL is one's recipe link: the
// source_url of the recipe it is grounded in, else the recipe_url still
// awaiting grounding.
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 {
if row.Dinner.ID == self {
continue
}
if harness.Similar(name, row.Name) || (recipeURL.Valid && row.RecipeUrl == recipeURL) {
return &DuplicateError{Existing: fromRow(row)}
link := row.Dinner.RecipeUrl
if row.RecipeSourceUrl.Valid {
link = row.RecipeSourceUrl
}
if harness.Similar(name, row.Dinner.Name) || (recipeURL.Valid && link == recipeURL) {
return &DuplicateError{Existing: fromRow(row.Dinner)}
}
}
return nil
+9
View File
@@ -1,6 +1,7 @@
package dinners
import (
"context"
"testing"
"time"
)
@@ -12,3 +13,11 @@ func SetGroundTimeout(t *testing.T, d time.Duration) {
groundTimeout = d
t.Cleanup(func() { groundTimeout = old })
}
// RunGroundJob runs the KindGroundDinner handler for p in the caller's
// goroutine, as the job runner would, and returns the grounding status it
// reports.
func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error) {
r, err := s.runGroundJob(ctx, p, nil)
return r.GroundingStatus, err
}
+28 -22
View File
@@ -76,14 +76,17 @@ var groundTimeout = GroundTimeout
// 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.
// GroundJob is the KindGroundDinner payload: the dinner to ground and its
// grounding_revision when the job was enqueued.
type GroundJob struct {
DinnerID string `json:"dinner_id"`
Revision int64 `json:"revision"`
}
// 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.
// status once the job ends. It is not grounded by this job when the dinner
// was grounded by hand before the job finished, renamed (a later job
// grounds it) or archived; the job then reports the status it found.
type groundResult struct {
DinnerID string `json:"dinner_id"`
GroundingStatus string `json:"grounding_status"`
@@ -93,18 +96,21 @@ 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. Either is recorded only while the dinner
// is still pending under the normalized name read here: a dinner grounded
// by hand meanwhile is left as it is, and one renamed meanwhile is left
// pending for the job its rename enqueued.
// the recipe and photo with grounding_status grounded, and the recipe's
// method, minutes and difficulty where a member has not set them; failure
// records grounding_status failed with its Reason, which is also the job's
// failure code. A dinner archived, no longer pending, or at a
// grounding_revision other than p.Revision is not grounded at all, and
// either result is recorded only while the dinner is still unarchived,
// pending and at p.Revision: a dinner grounded by hand meanwhile is left as
// it is, and one renamed or archived meanwhile is left pending — for the
// job its rename enqueued, or for none.
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 {
if row.Status == StatusArchived || row.GroundingStatus != GroundingPending || row.GroundingRevision != p.Revision {
return groundResult{DinnerID: row.ID, GroundingStatus: row.GroundingStatus}, nil
}
@@ -124,10 +130,10 @@ func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundRe
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,
NormalizedName: row.NormalizedName,
GroundingError: sql.NullString{String: reason, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
GroundingRevision: p.Revision,
}); failErr != nil {
return groundResult{}, fmt.Errorf("dinners: ground: record failure of %s: %w", row.ID, failErr)
}
@@ -138,14 +144,14 @@ func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundRe
}
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,
NormalizedName: row.NormalizedName,
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,
GroundingRevision: p.Revision,
})
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: record %s: %w", row.ID, err)
+217
View File
@@ -2,6 +2,8 @@ package dinners_test
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"log/slog"
@@ -385,6 +387,168 @@ func TestRenameLinkWhilePendingGroundsNewName(t *testing.T) {
if got := e.search.Queries(); !slices.Equal(got, []string{"Beef stew"}) {
t.Errorf("searched %q, want only the new name", got)
}
if row.RecipeUrl.Valid {
t.Errorf("recipe_url = %q, want the old link dropped", row.RecipeUrl.String)
}
if _, err := db.New(e.st.Reader).GetRecipeBySourceURL(t.Context(), curryA); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("old link's recipe lookup err = %v, want it never grounded (sql.ErrNoRows)", err)
}
}
// queuedJob reads the payload of queued KindGroundDinner job id.
func (e *env) queuedJob(t *testing.T, id string) dinners.GroundJob {
t.Helper()
var payload string
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT payload FROM jobs WHERE id = ?`, id).Scan(&payload); err != nil {
t.Fatal(err)
}
var p dinners.GroundJob
if err := json.Unmarshal([]byte(payload), &p); err != nil {
t.Fatal(err)
}
return p
}
// TestRenameBeforeJobStartsSkipsStaleJob: a Regular renamed before its
// first job starts is grounded only by the job its rename enqueued. The
// first job, though it would read the new name, does no work and writes
// nothing, so its failure cannot leave the dinner "Needs a recipe" while
// the rename's job finds a recipe.
func TestRenameBeforeJobStartsSkipsStaleJob(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
first := e.queuedJob(t, *c.GroundingJobID)
if first.DinnerID != c.ID || first.Revision != e.dinner(t, c.ID).GroundingRevision {
t.Fatalf("first job = %+v, want dinner %s at its revision", first, c.ID)
}
if _, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
t.Fatal(err)
}
renamed := e.dinner(t, c.ID)
if renamed.GroundingRevision != first.Revision+1 {
t.Fatalf("revision after rename = %d, want %d", renamed.GroundingRevision, first.Revision+1)
}
// The first job would fail if it searched.
e.search.Err = recipes.ErrSearchRateLimited
status, err := e.svc.RunGroundJob(t.Context(), first)
if err != nil || status != dinners.GroundingPending {
t.Fatalf("first job = %q, %v; want pending, no error", status, err)
}
if q := e.search.Queries(); len(q) != 0 {
t.Errorf("first job searched %q, want no work for a stale revision", q)
}
if row := e.dinner(t, c.ID); row != renamed {
t.Fatalf("first job wrote the dinner: %+v, was %+v", row, renamed)
}
e.search.Err = nil
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
status, err = e.svc.RunGroundJob(t.Context(), dinners.GroundJob{DinnerID: c.ID, Revision: renamed.GroundingRevision})
if err != nil || status != dinners.GroundingGrounded {
t.Fatalf("rename's job = %q, %v; want grounded", status, err)
}
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 != dinners.GroundingGrounded || rec.SourceUrl != stew {
t.Errorf("dinner = %+v recipe %s, want grounded in %s", row, rec.SourceUrl, stew)
}
// A stale job finishing last changes nothing either.
if status, err := e.svc.RunGroundJob(t.Context(), first); err != nil || status != dinners.GroundingGrounded {
t.Errorf("first job run last = %q, %v; want grounded reported, no error", status, err)
}
if after := e.dinner(t, c.ID); after != row {
t.Errorf("stale job changed the grounded dinner: %+v, was %+v", after, row)
}
}
// TestEditWhilePendingIsKept: a method or minutes a member saves while a
// Regular is grounding is kept when grounding succeeds; what the member
// did not set comes from the recipe.
func TestEditWhilePendingIsKept(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)
}
e.waitQuery(t, "Chicken curry")
if _, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Method: ptr("grill"), MinutesTotal: ptr(15)}, member); err != nil {
t.Fatal(err)
}
close(e.search.Gate)
row := e.waitGrounding(t, c.ID)
if row.GroundingStatus != dinners.GroundingGrounded || !row.RecipeID.Valid || !row.PhotoID.Valid {
t.Fatalf("dinner = %+v, want grounded", row)
}
if row.Method.String != "grill" || row.MinutesTotal.Int64 != 15 || row.Difficulty.String != "easy" {
t.Errorf("dinner = %s, %d minutes, %s; want the member's grill and 15 minutes, the recipe's easy",
row.Method.String, row.MinutesTotal.Int64, row.Difficulty.String)
}
}
// TestArchiveWhilePendingStopsGrounding: a pending Regular archived before
// its job starts is not searched for, and one archived while its job runs
// is not written; neither ends up grounded.
func TestArchiveWhilePendingStopsGrounding(t *testing.T) {
t.Parallel()
t.Run("before the job starts", func(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(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 _, err := e.svc.Archive(t.Context(), c.ID, member); err != nil {
t.Fatal(err)
}
e.start(t)
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v, want succeeded with nothing to do", job)
}
if q := e.search.Queries(); len(q) != 0 {
t.Errorf("searched %q for an archived dinner", q)
}
if row := e.dinner(t, c.ID); row.Status != dinners.StatusArchived || row.GroundingStatus == dinners.GroundingGrounded || row.RecipeID.Valid {
t.Errorf("dinner = %+v, want archived and not grounded", row)
}
})
t.Run("while the job runs", func(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)
}
e.waitQuery(t, "Chicken curry")
if _, err := e.svc.Archive(t.Context(), c.ID, member); err != nil {
t.Fatal(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.Status != dinners.StatusArchived || row.GroundingStatus == dinners.GroundingGrounded || row.RecipeID.Valid {
t.Errorf("dinner = %+v, want archived and not grounded", row)
}
})
}
// TestRenameWhilePendingEnqueueFailure: a pending Regular renamed when the
@@ -530,6 +694,59 @@ func TestFixViaCandidate(t *testing.T) {
}
}
// TestFixReplacesPendingLink: fixing a Regular added from a link that held
// no recipe drops that link, so it no longer blocks adding the link again,
// while the link of the recipe chosen does.
func TestFixReplacesPendingLink(t *testing.T) {
t.Parallel()
const lost = "https://gone.example/recipes/lamb-tagine"
tests := []struct {
name string
fix dinners.Fix
// linkTaken is the recipe link the fixed dinner now has, if any.
linkTaken string
}{
{"candidate", dinners.Fix{CandidateID: ptr(curryB)}, curryB},
{"manual", dinners.Fix{Manual: &dinners.ManualRecipe{Ingredients: []string{"1 lamb"}, Minutes: ptr(90), Method: ptr("oven")}}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(lost)}, 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)
}
// Renamed, so only the link could repeat it.
if _, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Weeknight dinner")}, member); err != nil {
t.Fatal(err)
}
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
d, err := e.svc.Ground(t.Context(), c.ID, tt.fix, member)
if err != nil {
t.Fatal(err)
}
if d.RecipeURL != nil {
t.Errorf("recipe_url = %q after the fix, want the unusable link dropped", *d.RecipeURL)
}
if _, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(lost)}, member); err != nil {
t.Errorf("adding the dropped link again: %v, want added", err)
}
if tt.linkTaken != "" {
_, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(tt.linkTaken)}, member)
var dup *dinners.DuplicateError
if !errors.As(err, &dup) || dup.Existing.ID != c.ID {
t.Errorf("adding the fixed dinner's recipe link: err = %v, want a duplicate of %s", err, c.ID)
}
}
})
}
}
func TestFixViaRecipeURL(t *testing.T) {
t.Parallel()
e := newEnv(t)