Guard recipe repairs against same-normalized renames and member edits
F1-1: GroundDinner also compares the observed raw name, so renaming "Grandma's stew" to "Easy Grandma's stew" (same normalized_name) while a link is grounding is ErrGroundConflict, not attached to the renamed dinner. F1-2: A method, minutes_total or difficulty no longer as observed before grounding is kept over the recipe's value, as GroundPendingDinner keeps values a member set while a dinner was pending. F2-1: Failing to record a grounded candidate or link on the dinner (or to read it back after a rejected write) wraps ErrGroundFailed, so the API logs reason=ground_failed with the dinner id, never the database error. Maestro task 8741cedb-1849-4e3f-8d05-f5648c77c7ee. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -335,14 +335,16 @@ type ManualRecipe struct {
|
||||
// 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 and an archived dinner ErrArchived. A failure grounding
|
||||
// a candidate or link wraps ErrGroundFailed; a page that cannot ground the
|
||||
// dinner is also a *recipes.ErrUngroundable. A dinner renamed, archived or
|
||||
// grounded some other way while a candidate or link was grounding is
|
||||
// ErrGroundConflict or ErrArchived. None of these changes the dinner.
|
||||
// difficulty; one a member changed while it was grounding is kept. 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
|
||||
// and an archived dinner ErrArchived. A failure grounding a candidate or
|
||||
// link, or recording it on the dinner, wraps ErrGroundFailed; a page that
|
||||
// cannot ground the dinner is also a *recipes.ErrUngroundable. A dinner
|
||||
// renamed, archived or grounded some other way while a candidate or link
|
||||
// was grounding is ErrGroundConflict or ErrArchived. None of these changes
|
||||
// the dinner.
|
||||
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")
|
||||
@@ -380,27 +382,35 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: cur.ID,
|
||||
ObservedName: cur.Name,
|
||||
ObservedNormalizedName: cur.NormalizedName,
|
||||
ObservedGroundingStatus: cur.GroundingStatus,
|
||||
ObservedGroundingRevision: cur.GroundingRevision,
|
||||
ObservedRecipeID: cur.RecipeID,
|
||||
ObservedMethod: cur.Method,
|
||||
ObservedMinutesTotal: cur.MinutesTotal,
|
||||
ObservedDifficulty: cur.Difficulty,
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Dinner{}, s.groundRejected(ctx, id)
|
||||
}
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err)
|
||||
return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
|
||||
}
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
// groundRejected is the error for dinner id when it was not as observed
|
||||
// before grounding: ErrNotFound, ErrArchived or ErrGroundConflict.
|
||||
// before grounding: ErrNotFound, ErrArchived or ErrGroundConflict. Failing
|
||||
// to read it wraps ErrGroundFailed.
|
||||
func (s *Service) groundRejected(ctx context.Context, id string) error {
|
||||
cur, err := s.get(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
|
||||
}
|
||||
if cur.Status == StatusArchived {
|
||||
return ErrArchived
|
||||
}
|
||||
@@ -486,10 +496,14 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: now,
|
||||
ID: cur.ID,
|
||||
ObservedName: cur.Name,
|
||||
ObservedNormalizedName: cur.NormalizedName,
|
||||
ObservedGroundingStatus: cur.GroundingStatus,
|
||||
ObservedGroundingRevision: cur.GroundingRevision,
|
||||
ObservedRecipeID: cur.RecipeID,
|
||||
ObservedMethod: cur.Method,
|
||||
ObservedMinutesTotal: cur.MinutesTotal,
|
||||
ObservedDifficulty: cur.Difficulty,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: ground %s: %w", id, err)
|
||||
|
||||
@@ -1126,6 +1126,13 @@ func TestFixInFlightWhenDinnerChanges(t *testing.T) {
|
||||
}, dinners.ErrGroundConflict, func(row db.Dinner) bool {
|
||||
return row.Name == "Chicken curry" && row.GroundingStatus == dinners.GroundingFailed && !row.RecipeID.Valid
|
||||
}},
|
||||
{"renamed to the same normalized name", func(t *testing.T, e *env, id string) {
|
||||
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Easy Grandma's stew")}, member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, dinners.ErrGroundConflict, func(row db.Dinner) bool {
|
||||
return row.Name == "Easy Grandma's stew" && row.GroundingStatus == dinners.GroundingFailed && !row.RecipeID.Valid
|
||||
}},
|
||||
{"archived", func(t *testing.T, e *env, id string) {
|
||||
if _, err := e.svc.Archive(t.Context(), id, member); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1170,6 +1177,40 @@ func TestFixInFlightWhenDinnerChanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFixInFlightKeepsMemberEdits: a method or difficulty a member sets
|
||||
// while a link is grounding is kept; what they left alone takes the link's
|
||||
// recipe.
|
||||
func TestFixInFlightKeepsMemberEdits(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
id := failedDinner(t, e, "Grandma's stew")
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
||||
e.pages.Gate = make(chan struct{})
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := e.svc.Ground(context.Background(), id, dinners.Fix{RecipeURL: ptr(stew)}, member)
|
||||
done <- err
|
||||
}()
|
||||
e.waitFetched(t, stew)
|
||||
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Method: ptr("grill"), Difficulty: ptr("easy")}, member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(e.pages.Gate)
|
||||
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := e.dinner(t, id)
|
||||
if row.GroundingStatus != dinners.GroundingGrounded || !row.RecipeID.Valid {
|
||||
t.Errorf("dinner = %+v, want grounded in the link's recipe", row)
|
||||
}
|
||||
if row.Method.String != "grill" || row.Difficulty.String != "easy" || row.MinutesTotal.Int64 != 150 {
|
||||
t.Errorf("method, difficulty, minutes = %q, %q, %d; want grill and easy kept, 150 from the recipe",
|
||||
row.Method.String, row.Difficulty.String, row.MinutesTotal.Int64)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFixArchivedDinner: an archived dinner cannot be grounded by hand.
|
||||
func TestFixArchivedDinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
Reference in New Issue
Block a user