Guard recipe repairs against same-normalized renames and member edits
CI / ci (push) Successful in 2m41s
CI / ci (pull_request) Successful in 2m31s

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:
2026-10-06 20:11:32 -04:00
co-authored by Claude Opus 5.5
parent 5c4c507ee6
commit 1dd53767f7
7 changed files with 138 additions and 35 deletions
+41
View File
@@ -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()