package dinners_test import ( "database/sql" "encoding/json" "errors" "slices" "testing" "time" "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/dinners" "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" ) // failedDinner stores a Regular named name whose grounding failed with // no_recipe_found, without a photo or recipe_url, and returns its id. func (e *env) failedDinner(t *testing.T, name string) string { t.Helper() q := db.New(e.st.Writer) now := store.Now().String() row, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{ ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: dinners.StatusRegular, Origin: "regular", GroundingStatus: dinners.GroundingPending, UpdatedBy: sql.NullString{String: member, Valid: true}, CreatedAt: now, UpdatedAt: now, }) if err != nil { t.Fatal(err) } if _, err := q.FailPendingDinnerGrounding(t.Context(), db.FailPendingDinnerGroundingParams{ GroundingError: sql.NullString{String: dinners.ReasonNoRecipe, Valid: true}, UpdatedAt: now, ID: row.ID, GroundingRevision: row.GroundingRevision, }); err != nil { t.Fatal(err) } return row.ID } func outcome(id, out string, reason *string) dinners.BackfillOutcome { return dinners.BackfillOutcome{DinnerID: id, Outcome: out, Reason: reason} } func checkOutcomes(t *testing.T, got, want []dinners.BackfillOutcome) { t.Helper() gj, _ := json.Marshal(got) wj, _ := json.Marshal(want) if string(gj) != string(wj) { t.Fatalf("outcomes = %s, want %s", gj, wj) } } func TestBackfillGroundsFirstCandidateWithAPhoto(t *testing.T) { t.Parallel() e := newUnstartedEnv(t) // curryA ranks first but its photo is gone; curryB's photo caches. delete(e.images.JPEGs, "https://a.example/curry.jpg") e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25)) id := e.failedDinner(t, "Chicken curry") got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{}) if err != nil { t.Fatal(err) } checkOutcomes(t, got, []dinners.BackfillOutcome{outcome(id, dinners.BackfillGrounded, nil)}) row := e.dinner(t, id) if row.GroundingStatus != dinners.GroundingGrounded || !row.PhotoID.Valid || !row.RecipeID.Valid || row.GroundingError.Valid { t.Fatalf("dinner = %+v, want grounded with a photo and no error", row) } rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String) if err != nil { t.Fatal(err) } if rec.SourceUrl != curryB || rec.SiteName.String != "B Kitchen" || rec.PhotoID != row.PhotoID { t.Errorf("recipe = %s from %q photo %s, want %s from B Kitchen photo %s", rec.SourceUrl, rec.SiteName.String, rec.PhotoID.String, curryB, 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 TestBackfillReusesCachedRecipeAndPhoto(t *testing.T) { t.Parallel() e := newUnstartedEnv(t) e.search.Results["Curry chicken"] = []recipes.Candidate{{URL: curryA}} // One reply: the second dinner must reuse the recipe the first stored. e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40)) first := e.failedDinner(t, "Chicken curry") second := e.failedDinner(t, "Curry chicken") got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{}) if err != nil { t.Fatal(err) } checkOutcomes(t, got, []dinners.BackfillOutcome{ outcome(first, dinners.BackfillGrounded, nil), outcome(second, dinners.BackfillGrounded, nil), }) a, b := e.dinner(t, first), e.dinner(t, second) if a.RecipeID != b.RecipeID || a.PhotoID != b.PhotoID || !a.PhotoID.Valid { t.Errorf("dinners grounded in recipe %s photo %s and recipe %s photo %s, want the same cached ones", a.RecipeID.String, a.PhotoID.String, b.RecipeID.String, b.PhotoID.String) } if n := len(e.model.Requests()); n != 1 { t.Errorf("normalize_recipe ran %d times, want 1", n) } if n := slices.Index(e.pages.Fetched(), curryA); n < 0 || slices.Contains(e.pages.Fetched()[n+1:], curryA) { t.Errorf("fetched %q, want %s fetched once", e.pages.Fetched(), curryA) } } func TestBackfillIsolatesEachDinnersFailure(t *testing.T) { t.Parallel() e := newUnstartedEnv(t) e.search.Fail = map[string]error{"Broken search": errors.New("search provider down")} e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40)) // Listed by normalized name: both failures come before the curry. broken := e.failedDinner(t, "Broken search") curry := e.failedDinner(t, "Chicken curry") absent := e.failedDinner(t, "Abalone surprise") got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{}) if err != nil { t.Fatal(err) } checkOutcomes(t, got, []dinners.BackfillOutcome{ outcome(absent, dinners.BackfillFailed, ptr(dinners.ReasonNoRecipe)), outcome(broken, dinners.BackfillFailed, ptr(dinners.ReasonSearchFailed)), outcome(curry, dinners.BackfillGrounded, nil), }) for id, reason := range map[string]string{absent: dinners.ReasonNoRecipe, broken: dinners.ReasonSearchFailed} { row := e.dinner(t, id) if row.GroundingStatus != dinners.GroundingFailed || row.GroundingError.String != reason || row.PhotoID.Valid { t.Errorf("dinner %s = %+v, want failed with %s and no photo", row.Name, row, reason) } } if row := e.dinner(t, curry); row.GroundingStatus != dinners.GroundingGrounded || !row.PhotoID.Valid { t.Errorf("curry = %+v, want grounded with a photo", row) } } func TestBackfillSkipsDinnersNotMissingAPhoto(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) } e.waitJob(t, *c.GroundingJobID) if row := e.dinner(t, c.ID); row.GroundingStatus != dinners.GroundingGrounded { t.Fatalf("dinner = %+v, want grounded", row) } // A failed dinner with a pasted link keeps that link for a fix by hand. linked, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Mystery pie"), RecipeURL: ptr("https://d.example/pie")}, member) if err != nil { t.Fatal(err) } e.waitJob(t, *linked.GroundingJobID) if row := e.dinner(t, linked.ID); row.GroundingStatus != dinners.GroundingFailed { t.Fatalf("linked dinner = %+v, want failed", row) } archived := e.failedDinner(t, "Old stew") if _, err := e.svc.Archive(t.Context(), archived, member); err != nil { t.Fatal(err) } searched := len(e.search.Queries()) id, err := e.svc.StartPhotoBackfill(t.Context()) if err != nil { t.Fatal(err) } job := e.waitJob(t, id) if job.Kind != dinners.KindBackfillPhotos || string(job.Result) != `{"dinners":[]}` { t.Fatalf("job = %+v (result %s), want a backfill with no dinners", job, job.Result) } if got := e.search.Queries()[searched:]; len(got) != 0 { t.Errorf("backfill searched %q, want nothing", got) } } func TestBackfillOfOneDinnerGroundsOnlyIt(t *testing.T) { t.Parallel() e := newUnstartedEnv(t) e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40)) curry := e.failedDinner(t, "Chicken curry") other := e.failedDinner(t, "Abalone surprise") got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{DinnerID: curry}) if err != nil { t.Fatal(err) } checkOutcomes(t, got, []dinners.BackfillOutcome{outcome(curry, dinners.BackfillGrounded, nil)}) if row := e.dinner(t, other); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid { t.Errorf("other dinner = %+v, want left failed", row) } if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) { t.Errorf("searched %q, want only the dinner's name", got) } } func TestBackfillLeavesDinnerChangedWhileGrounding(t *testing.T) { t.Parallel() e := newUnstartedEnv(t) id := e.failedDinner(t, "Chicken curry") gate := make(chan struct{}) e.search.Gate = gate e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40)) done := make(chan []dinners.BackfillOutcome) go func() { got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{DinnerID: id}) if err != nil { t.Error(err) } done <- got }() // The backfill listed the dinner and waits at its search. for len(e.search.Queries()) == 0 { time.Sleep(time.Millisecond) } if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Chicken korma")}, member); err != nil { t.Fatal(err) } close(gate) checkOutcomes(t, <-done, []dinners.BackfillOutcome{outcome(id, dinners.BackfillSkipped, nil)}) if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid { t.Errorf("dinner = %+v, want left failed without a photo", row) } }