At boot, after the job runner fails the jobs a previous process left running, FailInterruptedGroundings sets every dinner still pending with no ground_dinner job queued or running to failed (interrupted). A crash mid-job, or between saving a dinner and enqueuing its job, no longer leaves the dinner pending forever. A search failure on recipe-candidates or ground now logs only the reason code search_failed, never the wrapped error, which may quote the provider; the response stays redacted. When the grounding job cannot be enqueued, POST /dinners answers 201 with the saved dinner failed (grounding_error) and grounding_job_id null, instead of a 500 that hid the dinner it had created. Grounding a candidate or link by hand runs under a 90-second deadline (fetch, photo, normalize_recipe); past it the request answers 504 ground_timeout and the dinner is unchanged. Maestro task: 0cbaa5bf-8be4-4cbd-aab2-891e7f1bc2b5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
596 lines
22 KiB
Go
596 lines
22 KiB
Go
package dinners_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
|
"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/jobs"
|
|
"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"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
|
)
|
|
|
|
const member = "member-1"
|
|
|
|
// Fixture pages: two curry recipes from search and a stew at a pasted link.
|
|
const (
|
|
curryA = "https://a.example/chicken-curry"
|
|
curryB = "https://b.example/curry"
|
|
stew = "https://c.example/recipes/beef-stew"
|
|
)
|
|
|
|
type env struct {
|
|
svc *dinners.Service
|
|
st *store.Store
|
|
runner *jobs.Runner
|
|
search *recipestest.Search
|
|
pages *recipestest.Pages
|
|
model *llm.Fake
|
|
}
|
|
|
|
// newEnv returns a dinners service over a started job runner, grounding
|
|
// with fake search, pages, images and model. Searching "Chicken curry"
|
|
// finds curryA, curryB and a missing page.
|
|
func newEnv(t *testing.T) *env {
|
|
t.Helper()
|
|
e := newUnstartedEnv(t)
|
|
e.start(t)
|
|
return e
|
|
}
|
|
|
|
// newUnstartedEnv is newEnv with its job runner not yet started, so jobs
|
|
// enqueued stay queued until start.
|
|
func newUnstartedEnv(t *testing.T) *env {
|
|
t.Helper()
|
|
dataDir := t.TempDir()
|
|
st, err := store.Open(t.Context(), dataDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := st.Close(); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
search := &recipestest.Search{Results: map[string][]recipes.Candidate{
|
|
"Chicken curry": {{URL: curryA}, {URL: "https://missing.example/x"}, {URL: curryB}},
|
|
}}
|
|
pages := &recipestest.Pages{Bodies: map[string][]byte{
|
|
curryA: recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40),
|
|
curryB: recipestest.RecipePage("Easy Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25),
|
|
stew: recipestest.RecipePage("Beef Stew", "Stew Co", "https://c.example/stew.jpg", 150),
|
|
}}
|
|
images := &recipestest.Images{JPEGs: map[string][]byte{
|
|
"https://a.example/curry.jpg": recipestest.JPEG(t, 400, 320, 1),
|
|
"https://b.example/curry.jpg": recipestest.JPEG(t, 400, 320, 2),
|
|
"https://c.example/stew.jpg": recipestest.JPEG(t, 400, 320, 3),
|
|
}}
|
|
model := &llm.Fake{}
|
|
g := recipestest.NewGrounder(t, st, dataDir, search, pages, images, model)
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
runner, err := jobs.NewRunner(st, logger, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
svc, err := dinners.NewService(st, g, runner, logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &env{svc: svc, st: st, runner: runner, search: search, pages: pages, model: model}
|
|
}
|
|
|
|
// start starts the env's job runner, stopping it when the test ends.
|
|
func (e *env) start(t *testing.T) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
if err := e.runner.Start(ctx); err != nil {
|
|
cancel()
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
e.runner.Wait()
|
|
})
|
|
}
|
|
|
|
func ptr[T any](v T) *T { return &v }
|
|
|
|
// waitJob waits for job id to finish and returns it.
|
|
func (e *env) waitJob(t *testing.T, id string) jobs.Job {
|
|
t.Helper()
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for {
|
|
job, err := e.runner.Get(t.Context(), id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
|
return job
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("job %s never finished; last %+v", id, job)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func (e *env) dinner(t *testing.T, id string) db.Dinner {
|
|
t.Helper()
|
|
row, err := db.New(e.st.Reader).GetDinner(t.Context(), id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return row
|
|
}
|
|
|
|
func TestCreateGroundsInBackground(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)
|
|
}
|
|
if c.GroundingStatus != dinners.GroundingPending || c.GroundingJobID == nil {
|
|
t.Fatalf("created = %+v, want pending with a job id", c)
|
|
}
|
|
job := e.waitJob(t, *c.GroundingJobID)
|
|
if job.Kind != dinners.KindGroundDinner || job.Status != jobs.StatusSucceeded ||
|
|
string(job.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"grounded"}` {
|
|
t.Fatalf("job = %+v (result %s)", job, job.Result)
|
|
}
|
|
|
|
row := e.dinner(t, c.ID)
|
|
if row.GroundingStatus != "grounded" || !row.RecipeID.Valid || !row.PhotoID.Valid ||
|
|
row.Method.String != "stovetop" || row.MinutesTotal.Int64 != 40 || row.Difficulty.String != "easy" || row.GroundingError.Valid {
|
|
t.Fatalf("dinner = %+v, want grounded with recipe, photo, stovetop, 40 minutes, easy", row)
|
|
}
|
|
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec.SourceUrl != curryA || rec.PhotoID != row.PhotoID {
|
|
t.Errorf("recipe = %s photo %s, want %s photo %s", rec.SourceUrl, rec.PhotoID.String, curryA, 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 TestCreateByRecipeURLGroundsThatURL(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
|
|
|
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(stew)}, member)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
|
|
t.Fatalf("job = %+v", job)
|
|
}
|
|
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 != "grounded" || rec.SourceUrl != stew || row.Method.String != "oven" || row.MinutesTotal.Int64 != 150 {
|
|
t.Fatalf("dinner = %+v recipe %s, want grounded in %s", row, rec.SourceUrl, stew)
|
|
}
|
|
if q := e.search.Queries(); len(q) != 0 {
|
|
t.Errorf("searched %q; a recipe_url is grounded directly", q)
|
|
}
|
|
}
|
|
|
|
func TestCreateGroundingFailureRecordsReason(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
create dinners.NewRegular
|
|
setup func(*env)
|
|
reason string
|
|
}{
|
|
{"no candidates", dinners.NewRegular{Name: ptr("Unknown dish")}, nil, dinners.ReasonNoRecipe},
|
|
{"unusable link", dinners.NewRegular{RecipeURL: ptr("https://gone.example/recipes/lost-soup")}, nil, dinners.ReasonRecipeUnusable},
|
|
{"search fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) { e.search.Err = recipes.ErrSearchRateLimited }, dinners.ReasonSearchFailed},
|
|
{"model fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) {
|
|
// One provider failure per candidate tried.
|
|
e.model.QueueGenerate(llm.GenerateResult{Err: llm.ErrAuth})
|
|
}, dinners.ReasonAIFailed},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
if tt.setup != nil {
|
|
tt.setup(e)
|
|
}
|
|
c, err := e.svc.CreateRegular(t.Context(), tt.create, member)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
job := e.waitJob(t, *c.GroundingJobID)
|
|
if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != tt.reason {
|
|
t.Errorf("job = %+v, want failed with %s", job, tt.reason)
|
|
}
|
|
row := e.dinner(t, c.ID)
|
|
if row.GroundingStatus != "failed" || row.GroundingError.String != tt.reason || row.RecipeID.Valid {
|
|
t.Errorf("dinner = %+v, want failed with %s", row, tt.reason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateReturnsBeforeGroundingCompletes(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)
|
|
}
|
|
// The search is held, so the job cannot have finished.
|
|
if row := e.dinner(t, c.ID); row.GroundingStatus != "pending" {
|
|
t.Fatalf("grounding_status = %s right after create, want pending", row.GroundingStatus)
|
|
}
|
|
if job, err := e.runner.Get(t.Context(), *c.GroundingJobID); err != nil || (job.Status != jobs.StatusQueued && job.Status != jobs.StatusRunning) {
|
|
t.Fatalf("job = %+v, %v; want queued or running", job, 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.GroundingStatus != "grounded" {
|
|
t.Errorf("grounding_status = %s, want grounded", row.GroundingStatus)
|
|
}
|
|
}
|
|
|
|
func TestManualFixWhilePendingIsKept(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)
|
|
}
|
|
if _, err := e.svc.Ground(t.Context(), c.ID, dinners.Fix{Manual: &dinners.ManualRecipe{
|
|
Ingredients: []string{"1 chicken"}, Minutes: ptr(30), Method: ptr("oven"),
|
|
}}, member); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
close(e.search.Gate)
|
|
job := e.waitJob(t, *c.GroundingJobID)
|
|
if job.Status != jobs.StatusSucceeded || string(job.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"manual"}` {
|
|
t.Errorf("job = %+v (result %s), want succeeded reporting manual", job, job.Result)
|
|
}
|
|
if row := e.dinner(t, c.ID); row.GroundingStatus != "manual" || row.Method.String != "oven" {
|
|
t.Errorf("dinner = %+v, want the manual grounding kept", row)
|
|
}
|
|
}
|
|
|
|
// failedDinner creates a Regular whose background grounding fails.
|
|
func failedDinner(t *testing.T, e *env, name string) string {
|
|
t.Helper()
|
|
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr(name)}, 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)
|
|
}
|
|
return c.ID
|
|
}
|
|
|
|
func TestCandidates(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
// The background grounding stores curryA with its cached photo.
|
|
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 job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
|
|
t.Fatalf("job = %+v", job)
|
|
}
|
|
|
|
got, err := e.svc.Candidates(t.Context(), c.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("candidates = %+v, want the two usable pages", got)
|
|
}
|
|
first, second := got[0], got[1]
|
|
wantPhoto := dinners.PhotoURL(e.dinner(t, c.ID).PhotoID.String)
|
|
if first.CandidateID != curryA || first.Title != "Chicken Curry" || first.SiteName != "Curry House" ||
|
|
first.PhotoURL == nil || *first.PhotoURL != wantPhoto || first.Minutes == nil || *first.Minutes != 40 {
|
|
t.Errorf("first = %+v, want the stored curryA with photo %s", first, wantPhoto)
|
|
}
|
|
if second.CandidateID != curryB || second.Title != "Easy Weeknight Curry" || second.SiteName != "B Kitchen" ||
|
|
second.PhotoURL != nil || second.Minutes == nil || *second.Minutes != 25 {
|
|
t.Errorf("second = %+v, want curryB without a photo", second)
|
|
}
|
|
|
|
if _, err := e.svc.Candidates(t.Context(), "nope"); !errors.Is(err, dinners.ErrNotFound) {
|
|
t.Errorf("unknown dinner: err = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestCandidatesSearchFails(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
e.search.Err = recipes.ErrSearchRefused
|
|
id := failedDinner(t, e, "Chicken curry")
|
|
if _, err := e.svc.Candidates(t.Context(), id); !errors.Is(err, recipes.ErrSearchFailed) {
|
|
t.Errorf("err = %v, want ErrSearchFailed", err)
|
|
}
|
|
}
|
|
|
|
func TestCandidatesNone(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
id := failedDinner(t, e, "Fish tacos")
|
|
got, err := e.svc.Candidates(t.Context(), id)
|
|
if err != nil || len(got) != 0 {
|
|
t.Fatalf("no search results: candidates = %+v, %v; want none", got, err)
|
|
}
|
|
}
|
|
|
|
func TestFixViaCandidate(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
id := failedDinner(t, e, "Mystery curry")
|
|
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
|
|
|
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(curryB)}, "member-2")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if d.GroundingStatus != "grounded" || d.GroundingError != nil || d.RecipeID == nil || d.PhotoURL == nil ||
|
|
*d.Method != "stovetop" || *d.MinutesTotal != 25 || *d.Difficulty != "easy" || *d.UpdatedBy != "member-2" {
|
|
t.Fatalf("dinner = %+v", d)
|
|
}
|
|
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), *d.RecipeID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec.SourceUrl != curryB {
|
|
t.Errorf("recipe source = %s, want %s", rec.SourceUrl, curryB)
|
|
}
|
|
}
|
|
|
|
func TestFixViaRecipeURL(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
id := failedDinner(t, e, "Grandma's stew")
|
|
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
|
|
|
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr(" " + stew + " ")}, member)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if d.GroundingStatus != "grounded" || d.PhotoURL == nil || *d.Method != "oven" || *d.MinutesTotal != 150 {
|
|
t.Fatalf("dinner = %+v", d)
|
|
}
|
|
|
|
// A link holding no recipe changes nothing.
|
|
before := e.dinner(t, id)
|
|
_, err = e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr("https://gone.example/x")}, member)
|
|
var ungroundable *recipes.ErrUngroundable
|
|
if !errors.As(err, &ungroundable) {
|
|
t.Fatalf("err = %v, want *recipes.ErrUngroundable", err)
|
|
}
|
|
if after := e.dinner(t, id); after != before {
|
|
t.Errorf("dinner changed by a failed fix: %+v, was %+v", after, before)
|
|
}
|
|
}
|
|
|
|
func TestFixViaManual(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
id := failedDinner(t, e, "Family chili")
|
|
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Difficulty: ptr("medium")}, member); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{Manual: &dinners.ManualRecipe{
|
|
Ingredients: []string{" 1 lb beef ", "1 can beans"},
|
|
Steps: []string{"Brown the beef.", "Simmer with the beans."},
|
|
Minutes: ptr(240),
|
|
Method: ptr("crockpot"),
|
|
}}, member)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if d.GroundingStatus != "manual" || d.GroundingError != nil || d.RecipeID == nil || d.PhotoURL != nil ||
|
|
*d.Method != "crockpot" || *d.MinutesTotal != 240 || *d.Difficulty != "medium" {
|
|
t.Fatalf("dinner = %+v", d)
|
|
}
|
|
q := db.New(e.st.Reader)
|
|
rec, err := q.GetRecipe(t.Context(), *d.RecipeID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec.Title != "Family chili" || rec.SourceUrl != "mealquest:manual:"+rec.ID || rec.MinutesTotal.Int64 != 240 || rec.PhotoID.Valid {
|
|
t.Errorf("recipe = %+v", rec)
|
|
}
|
|
ings, err := q.ListRecipeIngredients(t.Context(), rec.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
steps, err := q.ListRecipeSteps(t.Context(), rec.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(ings) != 2 || ings[0].RawText != "1 lb beef" || len(steps) != 2 || steps[1].Text != "Simmer with the beans." {
|
|
t.Errorf("ingredients = %+v, steps = %+v", ings, steps)
|
|
}
|
|
}
|
|
|
|
func TestFixValidation(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
id := failedDinner(t, e, "Pad thai")
|
|
good := func() *dinners.ManualRecipe {
|
|
return &dinners.ManualRecipe{Ingredients: []string{"noodles"}, Minutes: ptr(20), Method: ptr("stovetop")}
|
|
}
|
|
tests := map[string]dinners.Fix{
|
|
"nothing": {},
|
|
"two": {CandidateID: ptr(curryA), RecipeURL: ptr(stew)},
|
|
"candidate not URL": {CandidateID: ptr("cand-1")},
|
|
"http link": {RecipeURL: ptr("http://a.example/x")},
|
|
"no ingredients": {Manual: &dinners.ManualRecipe{Minutes: ptr(20), Method: ptr("oven")}},
|
|
"blank ingredient": {Manual: &dinners.ManualRecipe{Ingredients: []string{" "}, Minutes: ptr(20), Method: ptr("oven")}},
|
|
"blank step": {Manual: func() *dinners.ManualRecipe { m := good(); m.Steps = []string{""}; return m }()},
|
|
"no minutes": {Manual: func() *dinners.ManualRecipe { m := good(); m.Minutes = nil; return m }()},
|
|
"zero minutes": {Manual: func() *dinners.ManualRecipe { m := good(); m.Minutes = ptr(0); return m }()},
|
|
"unknown method": {Manual: func() *dinners.ManualRecipe { m := good(); m.Method = ptr("microwave"); return m }()},
|
|
"no method": {Manual: func() *dinners.ManualRecipe { m := good(); m.Method = nil; return m }()},
|
|
"too many lines": {Manual: func() *dinners.ManualRecipe { m := good(); m.Ingredients = make([]string, 101); return m }()},
|
|
"manual and a link": {RecipeURL: ptr(stew), Manual: good()},
|
|
"over-long ing line": {Manual: func() *dinners.ManualRecipe {
|
|
m := good()
|
|
m.Ingredients = []string{string(make([]byte, 501))}
|
|
return m
|
|
}()},
|
|
}
|
|
before := e.dinner(t, id)
|
|
for name, fix := range tests {
|
|
_, err := e.svc.Ground(t.Context(), id, fix, member)
|
|
var invalid *dinners.ValidationError
|
|
if !errors.As(err, &invalid) {
|
|
t.Errorf("%s: err = %v, want *ValidationError", name, err)
|
|
}
|
|
}
|
|
if after := e.dinner(t, id); after != before {
|
|
t.Errorf("dinner changed by invalid fixes: %+v", after)
|
|
}
|
|
if _, err := e.svc.Ground(t.Context(), "nope", dinners.Fix{Manual: good()}, member); !errors.Is(err, dinners.ErrNotFound) {
|
|
t.Errorf("unknown dinner, manual: err = %v, want ErrNotFound", err)
|
|
}
|
|
if _, err := e.svc.Ground(t.Context(), "nope", dinners.Fix{RecipeURL: ptr(stew)}, member); !errors.Is(err, dinners.ErrNotFound) {
|
|
t.Errorf("unknown dinner, link: err = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
// TestRestartFailsInterruptedGrounding simulates a crash: one dinner's job
|
|
// was left running and another dinner was saved before its job was
|
|
// enqueued. At the next boot both end failed as interrupted, while a
|
|
// dinner whose job is still queued stays pending and is grounded.
|
|
func TestRestartFailsInterruptedGrounding(t *testing.T) {
|
|
t.Parallel()
|
|
e := newUnstartedEnv(t)
|
|
e.search.Gate = make(chan struct{})
|
|
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
|
|
running, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Fish pie")}, member)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := e.st.Writer.ExecContext(t.Context(),
|
|
`UPDATE jobs SET status = 'running', started_at = updated_at WHERE id = ?`, *running.GroundingJobID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
unqueued, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Lentil soup")}, member)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM jobs WHERE id = ?`, *unqueued.GroundingJobID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
queued, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Boot: the runner fails the running job, then the service fails the
|
|
// dinners nothing is left to ground. The search is held, so the queued
|
|
// dinner's job cannot finish meanwhile.
|
|
e.start(t)
|
|
n, err := e.svc.FailInterruptedGroundings(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 2 {
|
|
t.Errorf("failed %d dinners, want 2", n)
|
|
}
|
|
if job, err := e.runner.Get(t.Context(), *running.GroundingJobID); err != nil || job.Status != jobs.StatusFailed {
|
|
t.Errorf("running job = %+v, %v; want failed", job, err)
|
|
}
|
|
for _, id := range []string{running.ID, unqueued.ID} {
|
|
if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed || row.GroundingError.String != dinners.ReasonInterrupted {
|
|
t.Errorf("dinner %s = %s (%s), want failed (interrupted)", id, row.GroundingStatus, row.GroundingError.String)
|
|
}
|
|
}
|
|
if row := e.dinner(t, queued.ID); row.GroundingStatus != dinners.GroundingPending {
|
|
t.Errorf("queued dinner = %s, want pending", row.GroundingStatus)
|
|
}
|
|
close(e.search.Gate)
|
|
if job := e.waitJob(t, *queued.GroundingJobID); job.Status != jobs.StatusSucceeded {
|
|
t.Fatalf("queued job = %+v", job)
|
|
}
|
|
if row := e.dinner(t, queued.ID); row.GroundingStatus != dinners.GroundingGrounded {
|
|
t.Errorf("queued dinner = %s, want grounded", row.GroundingStatus)
|
|
}
|
|
}
|
|
|
|
// TestCreateEnqueueFailureReturnsFailedDinner: a dinner saved when its job
|
|
// cannot be enqueued is returned, with its id, failed and no job id.
|
|
func TestCreateEnqueueFailureReturnsFailedDinner(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
if _, err := e.st.Writer.ExecContext(t.Context(),
|
|
`CREATE TRIGGER refuse_jobs BEFORE INSERT ON jobs BEGIN SELECT RAISE(ABORT, 'no jobs'); END`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c.ID == "" || c.GroundingJobID != nil || c.GroundingStatus != dinners.GroundingFailed ||
|
|
c.GroundingError == nil || *c.GroundingError != dinners.ReasonGroundingError {
|
|
t.Fatalf("created = %+v, want its id, failed (grounding_error) and no job", c)
|
|
}
|
|
if row := e.dinner(t, c.ID); row.GroundingStatus != dinners.GroundingFailed {
|
|
t.Errorf("stored grounding_status = %s, want failed", row.GroundingStatus)
|
|
}
|
|
}
|
|
|
|
// TestGroundTimesOut: grounding a link by hand that runs past its deadline
|
|
// is ErrGroundTimeout and leaves the dinner as it was. It shortens the
|
|
// package deadline, so it does not run in parallel.
|
|
func TestGroundTimesOut(t *testing.T) {
|
|
dinners.SetGroundTimeout(t, 50*time.Millisecond)
|
|
e := newEnv(t)
|
|
id := failedDinner(t, e, "Beef stew")
|
|
e.pages.Gate = make(chan struct{})
|
|
defer close(e.pages.Gate)
|
|
|
|
_, err := e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr(stew)}, member)
|
|
if !errors.Is(err, dinners.ErrGroundTimeout) {
|
|
t.Fatalf("err = %v, want ErrGroundTimeout", err)
|
|
}
|
|
if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed {
|
|
t.Errorf("grounding_status = %s, want failed as before", row.GroundingStatus)
|
|
}
|
|
}
|