Make recipe repairs conditional and keep pasted links out of logs
CI / ci (push) Successful in 2m34s
CI / ci (pull_request) Successful in 2m30s

F1: A candidate or link repair now writes the dinner only if it is still
unarchived and as observed before grounding (normalized_name,
grounding_status, grounding_revision and recipe_id). A dinner renamed or
grounded some other way meanwhile is ErrGroundConflict (409
ground_conflict); one archived, before or during the repair, is
ErrArchived (409 dinner_archived). Manual repairs reject archived dinners
too.

F2: Any failure grounding a candidate or link by hand wraps
ErrGroundFailed, which the API logs as reason=ground_failed with the
dinner id only, never the pasted link or dependency error text.
GroundURL no longer formats the link into its 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:03:05 -04:00
co-authored by Claude Opus 5.5
parent cc7875c76c
commit 5c4c507ee6
9 changed files with 291 additions and 55 deletions
+8 -1
View File
@@ -250,7 +250,14 @@ automatic** (D-021).
`search_failed` / `ai_failed` (only the reason code is logged, never the `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 provider's text), and grounding a candidate or link that runs past 90
seconds (fetch, photo and `normalize_recipe`, all in the request) 504 seconds (fetch, photo and `normalize_recipe`, all in the request) 504
`ground_timeout`, leaving the dinner unchanged. `ground_timeout`, leaving the dinner unchanged. An archived dinner is 409
`dinner_archived`. A candidate or link is written only if the dinner is
still as it was before grounding began: one renamed or grounded some
other way meanwhile is 409 `ground_conflict`, and one archived meanwhile
409 `dinner_archived`, either leaving the dinner unchanged. Any other
failure grounding a candidate or link is 500, logged with a fixed reason
and the dinner id only — never the link, which can carry a personal
token, or dependency error text.
- `/health` — liveness. - `/health` — liveness.
- Everything else serves the embedded PWA with SPA fallback. - Everything else serves the embedded PWA with SPA fallback.
+27 -10
View File
@@ -248,17 +248,22 @@ func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) {
const groundDinner = `-- name: GroundDinner :one const groundDinner = `-- name: GroundDinner :one
UPDATE dinners UPDATE dinners
SET recipe_id = ?, SET recipe_id = ?1,
recipe_url = NULL, recipe_url = NULL,
photo_id = ?, photo_id = ?2,
method = ?, method = ?3,
minutes_total = ?, minutes_total = ?4,
difficulty = ?, difficulty = ?5,
grounding_status = ?, grounding_status = ?6,
grounding_error = NULL, grounding_error = NULL,
updated_by = ?, updated_by = ?7,
updated_at = ? updated_at = ?8
WHERE id = ? WHERE id = ?9
AND status <> 'archived'
AND normalized_name = ?10
AND grounding_status = ?11
AND grounding_revision = ?12
AND recipe_id IS ?13
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, grounding_revision 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, grounding_revision
` `
@@ -272,11 +277,19 @@ type GroundDinnerParams struct {
UpdatedBy sql.NullString UpdatedBy sql.NullString
UpdatedAt string UpdatedAt string
ID string ID string
ObservedNormalizedName string
ObservedGroundingStatus string
ObservedGroundingRevision int64
ObservedRecipeID sql.NullString
} }
// Records a dinner's grounding by hand (grounded or manual) and clears any // Records a dinner's grounding by hand (grounded or manual) and clears any
// grounding_error and pasted recipe_url: the recipe chosen replaces the // grounding_error and pasted recipe_url: the recipe chosen replaces the
// link. An unknown id returns sql.ErrNoRows. // link. It writes only an unarchived dinner still as it was observed
// before grounding (same normalized_name, grounding_status,
// grounding_revision and recipe_id), so a dinner renamed, archived or
// grounded some other way meanwhile is left as it is. That dinner, or an
// unknown id, returns sql.ErrNoRows.
func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error) { func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error) {
row := q.db.QueryRowContext(ctx, groundDinner, row := q.db.QueryRowContext(ctx, groundDinner,
arg.RecipeID, arg.RecipeID,
@@ -288,6 +301,10 @@ func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Din
arg.UpdatedBy, arg.UpdatedBy,
arg.UpdatedAt, arg.UpdatedAt,
arg.ID, arg.ID,
arg.ObservedNormalizedName,
arg.ObservedGroundingStatus,
arg.ObservedGroundingRevision,
arg.ObservedRecipeID,
) )
var i Dinner var i Dinner
err := row.Scan( err := row.Scan(
+5 -1
View File
@@ -69,7 +69,11 @@ type Querier interface {
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
// Records a dinner's grounding by hand (grounded or manual) and clears any // Records a dinner's grounding by hand (grounded or manual) and clears any
// grounding_error and pasted recipe_url: the recipe chosen replaces the // grounding_error and pasted recipe_url: the recipe chosen replaces the
// link. An unknown id returns sql.ErrNoRows. // link. It writes only an unarchived dinner still as it was observed
// before grounding (same normalized_name, grounding_status,
// grounding_revision and recipe_id), so a dinner renamed, archived or
// grounded some other way meanwhile is left as it is. That dinner, or an
// unknown id, returns sql.ErrNoRows.
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
// Records the background grounding of an unarchived dinner still pending at // Records the background grounding of an unarchived dinner still pending at
// the grounding_revision it was grounded for; a dinner grounded some other // the grounding_revision it was grounded for; a dinner grounded some other
+49 -6
View File
@@ -76,6 +76,18 @@ var groundTimeout = GroundTimeout
// runs past GroundTimeout; the dinner is left as it was. // runs past GroundTimeout; the dinner is left as it was.
var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long") var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long")
// ErrGroundFailed wraps any failure grounding a candidate or link by hand.
// Its text may quote the link and dependency errors, so it is never
// logged.
var ErrGroundFailed = errors.New("dinners: grounding the recipe failed")
// ErrArchived is grounding an archived dinner by hand.
var ErrArchived = errors.New("dinner archived")
// ErrGroundConflict is a candidate or link grounded for a dinner that was
// renamed or grounded some other way while it was grounding.
var ErrGroundConflict = errors.New("dinner changed while its recipe was grounding")
// GroundJob is the KindGroundDinner payload: the dinner to ground and its // GroundJob is the KindGroundDinner payload: the dinner to ground and its
// grounding_revision when the job was enqueued. // grounding_revision when the job was enqueued.
type GroundJob struct { type GroundJob struct {
@@ -326,8 +338,11 @@ type ManualRecipe struct {
// difficulty. A manual recipe is stored as typed — no model runs — 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 // sets grounding_status manual with its method and minutes; it has no
// photo, and the dinner keeps its difficulty. An invalid f is a // photo, and the dinner keeps its difficulty. An invalid f is a
// *ValidationError; a page that cannot ground the dinner is a // *ValidationError and an archived dinner ErrArchived. A failure grounding
// *recipes.ErrUngroundable and changes nothing. // 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.
func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string) (Dinner, error) { func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string) (Dinner, error) {
if updatedBy == "" { if updatedBy == "" {
return Dinner{}, errors.New("dinners: ground: no member to record as updated_by") return Dinner{}, errors.New("dinners: ground: no member to record as updated_by")
@@ -339,9 +354,13 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
if manual != nil { if manual != nil {
return s.groundManual(ctx, id, *manual, updatedBy) return s.groundManual(ctx, id, *manual, updatedBy)
} }
if _, err := s.get(ctx, id); err != nil { cur, err := s.get(ctx, id)
if err != nil {
return Dinner{}, err return Dinner{}, err
} }
if cur.Status == StatusArchived {
return Dinner{}, ErrArchived
}
groundCtx, cancel := context.WithTimeout(ctx, groundTimeout) groundCtx, cancel := context.WithTimeout(ctx, groundTimeout)
defer cancel() defer cancel()
gr, err := s.grounder.GroundURL(groundCtx, link) gr, err := s.grounder.GroundURL(groundCtx, link)
@@ -349,7 +368,7 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
if ctx.Err() == nil && errors.Is(groundCtx.Err(), context.DeadlineExceeded) { if ctx.Err() == nil && errors.Is(groundCtx.Err(), context.DeadlineExceeded) {
return Dinner{}, ErrGroundTimeout return Dinner{}, ErrGroundTimeout
} }
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err) return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
} }
row, err := db.New(s.st.Writer).GroundDinner(ctx, db.GroundDinnerParams{ row, err := db.New(s.st.Writer).GroundDinner(ctx, db.GroundDinnerParams{
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true}, RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
@@ -360,10 +379,14 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
GroundingStatus: GroundingGrounded, GroundingStatus: GroundingGrounded,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(), UpdatedAt: store.Now().String(),
ID: id, ID: cur.ID,
ObservedNormalizedName: cur.NormalizedName,
ObservedGroundingStatus: cur.GroundingStatus,
ObservedGroundingRevision: cur.GroundingRevision,
ObservedRecipeID: cur.RecipeID,
}) })
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return Dinner{}, ErrNotFound return Dinner{}, s.groundRejected(ctx, id)
} }
if err != nil { if err != nil {
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err) return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err)
@@ -371,6 +394,19 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
return fromRow(row), nil return fromRow(row), nil
} }
// groundRejected is the error for dinner id when it was not as observed
// before grounding: ErrNotFound, ErrArchived or ErrGroundConflict.
func (s *Service) groundRejected(ctx context.Context, id string) error {
cur, err := s.get(ctx, id)
if err != nil {
return err
}
if cur.Status == StatusArchived {
return ErrArchived
}
return ErrGroundConflict
}
// groundManual stores m as dinner id's recipe, with its ingredients and // groundManual stores m as dinner id's recipe, with its ingredients and
// steps, and records it on the dinner, all in one transaction. // 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) { func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, updatedBy string) (Dinner, error) {
@@ -384,6 +420,9 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
if err != nil { if err != nil {
return fmt.Errorf("dinners: read %s: %w", id, err) return fmt.Errorf("dinners: read %s: %w", id, err)
} }
if cur.Status == StatusArchived {
return ErrArchived
}
ld, err := json.Marshal(manualJSONLD{ ld, err := json.Marshal(manualJSONLD{
Context: "https://schema.org", Context: "https://schema.org",
Type: "Recipe", Type: "Recipe",
@@ -447,6 +486,10 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: now, UpdatedAt: now,
ID: cur.ID, ID: cur.ID,
ObservedNormalizedName: cur.NormalizedName,
ObservedGroundingStatus: cur.GroundingStatus,
ObservedGroundingRevision: cur.GroundingRevision,
ObservedRecipeID: cur.RecipeID,
}) })
if err != nil { if err != nil {
return fmt.Errorf("dinners: ground %s: %w", id, err) return fmt.Errorf("dinners: ground %s: %w", id, err)
+100
View File
@@ -1106,3 +1106,103 @@ func TestGroundTimesOut(t *testing.T) {
t.Errorf("grounding_status = %s, want failed as before", row.GroundingStatus) t.Errorf("grounding_status = %s, want failed as before", row.GroundingStatus)
} }
} }
// TestFixInFlightWhenDinnerChanges: a link still grounding when its dinner
// is renamed, archived or grounded some other way writes nothing; the
// change made meanwhile is kept.
func TestFixInFlightWhenDinnerChanges(t *testing.T) {
t.Parallel()
tests := []struct {
name string
change func(t *testing.T, e *env, id string)
want error
// kept checks the dinner shows the change, not the link's recipe.
kept func(row db.Dinner) bool
}{
{"renamed", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Chicken curry")}, member); err != nil {
t.Fatal(err)
}
}, dinners.ErrGroundConflict, func(row db.Dinner) bool {
return row.Name == "Chicken curry" && 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)
}
}, dinners.ErrArchived, func(row db.Dinner) bool {
return row.Status == dinners.StatusArchived && row.GroundingStatus == dinners.GroundingFailed && !row.RecipeID.Valid
}},
{"grounded by hand", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Ground(t.Context(), id, dinners.Fix{Manual: &dinners.ManualRecipe{
Ingredients: []string{"1 lb beef"}, Minutes: ptr(240), Method: ptr("crockpot"),
}}, member); err != nil {
t.Fatal(err)
}
}, dinners.ErrGroundConflict, func(row db.Dinner) bool {
return row.GroundingStatus == dinners.GroundingManual && row.Method.String == "crockpot"
}},
}
for _, tt := range tests {
t.Run(tt.name, func(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)
tt.change(t, e, id)
close(e.pages.Gate)
if err := <-done; !errors.Is(err, tt.want) {
t.Fatalf("err = %v, want %v", err, tt.want)
}
if row := e.dinner(t, id); !tt.kept(row) {
t.Errorf("dinner = %+v, want the %s change kept and the link's recipe not written", row, tt.name)
}
})
}
}
// TestFixArchivedDinner: an archived dinner cannot be grounded by hand.
func TestFixArchivedDinner(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Grandma's stew")
if _, err := e.svc.Archive(t.Context(), id, member); err != nil {
t.Fatal(err)
}
before := e.dinner(t, id)
for _, f := range []dinners.Fix{
{RecipeURL: ptr(stew)},
{Manual: &dinners.ManualRecipe{Ingredients: []string{"1 lb beef"}, Minutes: ptr(240), Method: ptr("crockpot")}},
} {
if _, err := e.svc.Ground(t.Context(), id, f, member); !errors.Is(err, dinners.ErrArchived) {
t.Errorf("fix %+v: err = %v, want ErrArchived", f, err)
}
}
if got := e.pages.Fetched(); len(got) != 0 {
t.Errorf("fetched %q for an archived dinner", got)
}
if after := e.dinner(t, id); after != before {
t.Errorf("archived dinner changed: %+v, was %+v", after, before)
}
}
// waitFetched waits until rawURL has been fetched.
func (e *env) waitFetched(t *testing.T, rawURL string) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for !slices.Contains(e.pages.Fetched(), rawURL) {
if time.Now().After(deadline) {
t.Fatalf("%s never fetched", rawURL)
}
time.Sleep(5 * time.Millisecond)
}
}
+1 -1
View File
@@ -266,7 +266,7 @@ func (g *Grounder) GroundURL(ctx context.Context, rawURL string) (Grounded, erro
} }
gr, reason, err := g.ground(ctx, rawURL, r) gr, reason, err := g.ground(ctx, rawURL, r)
if err != nil { if err != nil {
return Grounded{}, fmt.Errorf("recipes: ground %s: %w", rawURL, err) return Grounded{}, fmt.Errorf("recipes: ground pasted link: %w", err)
} }
if reason != nil { if reason != nil {
return ungroundable(reason) return ungroundable(reason)
+14 -1
View File
@@ -200,7 +200,10 @@ func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (a
// writeDinnerError maps a dinners.Service error to its API error. A page // writeDinnerError maps a dinners.Service error to its API error. A page
// that cannot ground the dinner is 422 ungroundable; recipe search not set // 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 // up is 409 not_configured; a search or model failure is 502; grounding by
// hand that runs past dinners.GroundTimeout is 504 ground_timeout. // hand that runs past dinners.GroundTimeout is 504 ground_timeout; grounding
// an archived dinner is 409 dinner_archived and one changed while grounding
// 409 ground_conflict. Any other grounding failure is 500, logged by reason
// and dinner id only.
func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) { func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var invalid *dinners.ValidationError var invalid *dinners.ValidationError
var dup *dinners.DuplicateError var dup *dinners.DuplicateError
@@ -220,6 +223,16 @@ func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logge
case errors.Is(err, recipes.ErrNormalizeFailed): case errors.Is(err, recipes.ErrNormalizeFailed):
logger.WarnContext(r.Context(), action, "reason", "normalize_recipe failed") 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") writeError(w, r, logger, http.StatusBadGateway, "ai_failed", "the AI provider failed to read the recipe")
case errors.Is(err, dinners.ErrGroundFailed):
// The error may quote the pasted link, which can carry a personal
// token, and dependency errors, so only the reason and dinner are
// logged.
logger.ErrorContext(r.Context(), action, "reason", "ground_failed", "dinner_id", chi.URLParam(r, "id"))
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
case errors.Is(err, dinners.ErrArchived):
writeError(w, r, logger, http.StatusConflict, "dinner_archived", "the dinner is archived")
case errors.Is(err, dinners.ErrGroundConflict):
writeError(w, r, logger, http.StatusConflict, "ground_conflict", "the dinner changed while its recipe was grounding; reload and try again")
case errors.Is(err, dinners.ErrGroundTimeout): case errors.Is(err, dinners.ErrGroundTimeout):
logger.WarnContext(r.Context(), action, "reason", "ground_timeout") logger.WarnContext(r.Context(), action, "reason", "ground_timeout")
writeError(w, r, logger, http.StatusGatewayTimeout, "ground_timeout", "grounding the recipe took too long; try again") writeError(w, r, logger, http.StatusGatewayTimeout, "ground_timeout", "grounding the recipe took too long; try again")
+43
View File
@@ -224,6 +224,49 @@ func TestPostGroundViaRecipeURL(t *testing.T) {
} }
} }
// TestPostGroundViaRecipeURLStorageFailure: a pasted link that fails for a
// reason other than its recipe is a 500 logged by reason and dinner id,
// never with the link, which can carry a personal token, or the dependency
// error.
func TestPostGroundViaRecipeURLStorageFailure(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
const token = "personal-token-abc123"
link := stewPage + "?access_token=" + token
e.pages.Bodies[link] = e.pages.Bodies[stewPage]
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
if _, err := e.st.Writer.ExecContext(t.Context(),
`CREATE TRIGGER refuse_recipes BEFORE INSERT ON recipes BEGIN SELECT RAISE(ABORT, 'disk full'); END`); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+link+`"}`)
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" || strings.Contains(rec.Body.String(), token) {
t.Errorf("POST = %d %s, want 500 internal without the link", rec.Code, rec.Body)
}
logs := e.logs.String()
if strings.Contains(logs, token) || strings.Contains(logs, "c.example") || strings.Contains(logs, "disk full") ||
!strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the link or dependency error", logs)
}
}
// TestPostGroundArchived: grounding an archived dinner by hand is a 409.
func TestPostGroundArchived(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Family chili"}`)
if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
`{"manual":{"ingredients":["1 lb beef"],"minutes":240,"method":"crockpot"}}`)
if rec.Code != http.StatusConflict || errorCode(body) != "dinner_archived" {
t.Errorf("POST = %d %v, want 409 dinner_archived", rec.Code, body)
}
}
func TestPostGroundViaManual(t *testing.T) { func TestPostGroundViaManual(t *testing.T) {
t.Parallel() t.Parallel()
e := newDinnersEnv(t) e := newDinnersEnv(t)
+19 -10
View File
@@ -84,19 +84,28 @@ RETURNING *;
-- name: GroundDinner :one -- name: GroundDinner :one
-- Records a dinner's grounding by hand (grounded or manual) and clears any -- Records a dinner's grounding by hand (grounded or manual) and clears any
-- grounding_error and pasted recipe_url: the recipe chosen replaces the -- grounding_error and pasted recipe_url: the recipe chosen replaces the
-- link. An unknown id returns sql.ErrNoRows. -- link. It writes only an unarchived dinner still as it was observed
-- before grounding (same normalized_name, grounding_status,
-- grounding_revision and recipe_id), so a dinner renamed, archived or
-- grounded some other way meanwhile is left as it is. That dinner, or an
-- unknown id, returns sql.ErrNoRows.
UPDATE dinners UPDATE dinners
SET recipe_id = ?, SET recipe_id = sqlc.arg(recipe_id),
recipe_url = NULL, recipe_url = NULL,
photo_id = ?, photo_id = sqlc.arg(photo_id),
method = ?, method = sqlc.arg(method),
minutes_total = ?, minutes_total = sqlc.arg(minutes_total),
difficulty = ?, difficulty = sqlc.arg(difficulty),
grounding_status = ?, grounding_status = sqlc.arg(grounding_status),
grounding_error = NULL, grounding_error = NULL,
updated_by = ?, updated_by = sqlc.arg(updated_by),
updated_at = ? updated_at = sqlc.arg(updated_at)
WHERE id = ? WHERE id = sqlc.arg(id)
AND status <> 'archived'
AND normalized_name = sqlc.arg(observed_normalized_name)
AND grounding_status = sqlc.arg(observed_grounding_status)
AND grounding_revision = sqlc.arg(observed_grounding_revision)
AND recipe_id IS sqlc.narg(observed_recipe_id)
RETURNING *; RETURNING *;
-- name: GroundPendingDinner :execrows -- name: GroundPendingDinner :execrows