Task 36c40130-a69a-46ed-a047-2cdc5ee2d2e0 (Feature 23, task 1 of 2). A backfill_dinner_photos job grounds, by name with the existing recipes.Grounder.Ground, every unarchived dinner whose grounding failed and that has no photo and no pasted recipe_url. Ground already tries the ranked candidates best first and keeps the first whose photo caches and whose ingredients normalize, reusing stored recipes and photos. Success records the recipe and photo as grounded; a failure leaves the dinner failed with the new grounding_error for the fix-recipe flow, without stopping its siblings. Writes are guarded on status, photo and grounding_revision, so grounded, manual, pending, renamed and archived dinners are never touched. POST /api/v1/settings/photo-backfill starts a full pass and answers 202 with the job id; the kind is service-only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
599 lines
26 KiB
Go
599 lines
26 KiB
Go
package server
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
|
)
|
|
|
|
// Fixture pages: two curries found by searching "Chicken curry" and a stew
|
|
// at a pasted link.
|
|
const (
|
|
curryPage = "https://a.example/chicken-curry"
|
|
curry2Page = "https://b.example/curry"
|
|
stewPage = "https://c.example/recipes/beef-stew"
|
|
)
|
|
|
|
// withRecipes adds the fixture pages, their photos and the search for
|
|
// "Chicken curry" to e.
|
|
func (e *dinnersEnv) withRecipes(t *testing.T) *dinnersEnv {
|
|
t.Helper()
|
|
e.search.Results["Chicken curry"] = []recipes.Candidate{{URL: curryPage}, {URL: curry2Page}}
|
|
e.pages.Bodies[curryPage] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
|
|
e.pages.Bodies[curry2Page] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25)
|
|
e.pages.Bodies[stewPage] = recipestest.RecipePage("Beef Stew", "Stew Co", "https://c.example/stew.jpg", 150)
|
|
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
|
|
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
|
|
e.images.JPEGs["https://c.example/stew.jpg"] = recipestest.JPEG(t, 400, 320, 3)
|
|
return e
|
|
}
|
|
|
|
// waitJob polls GET /jobs/{id} until the job ends and returns its body.
|
|
func waitJob(t *testing.T, h http.Handler, id string) map[string]any {
|
|
t.Helper()
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for {
|
|
rec, body := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET job = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
if s := body["status"]; s == "succeeded" || s == "failed" {
|
|
return body
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("job %s never finished; last %v", id, body)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// listedDinner returns dinner id as GET /dinners?status=regular lists it.
|
|
func listedDinner(t *testing.T, h http.Handler, id string) map[string]any {
|
|
t.Helper()
|
|
rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"?status=regular", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET dinners = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
for _, d := range body["dinners"].([]any) {
|
|
if d := d.(map[string]any); d["id"] == id {
|
|
return d
|
|
}
|
|
}
|
|
t.Fatalf("dinner %s not listed", id)
|
|
return nil
|
|
}
|
|
|
|
// candidateID returns the candidate_id GET recipe-candidates offers dinner
|
|
// id for the page titled title.
|
|
func candidateID(t *testing.T, h http.Handler, id, title string) string {
|
|
t.Helper()
|
|
rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET candidates = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
for _, c := range body["candidates"].([]any) {
|
|
if c := c.(map[string]any); c["title"] == title {
|
|
cid := c["candidate_id"].(string)
|
|
if strings.Contains(cid, "://") {
|
|
t.Fatalf("candidate_id = %q, want an opaque id, not the page URL", cid)
|
|
}
|
|
return cid
|
|
}
|
|
}
|
|
t.Fatalf("candidates = %v, want one titled %q", body, title)
|
|
return ""
|
|
}
|
|
|
|
func TestPostDinnerReturnsBeforeGroundingCompletes(t *testing.T) {
|
|
t.Parallel()
|
|
e := newDinnersEnv(t).withRecipes(t)
|
|
e.search.Gate = make(chan struct{})
|
|
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
e.start(t)
|
|
|
|
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
jobID, _ := created["grounding_job_id"].(string)
|
|
if created["grounding_status"] != "pending" || created["grounding_error"] != nil || jobID == "" {
|
|
t.Fatalf("created = %v, want pending with a grounding_job_id", created)
|
|
}
|
|
// The search is held, so grounding cannot have completed.
|
|
_, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+jobID, "")
|
|
if s := job["status"]; s != "queued" && s != "running" {
|
|
t.Fatalf("job right after POST = %v, want queued or running", job)
|
|
}
|
|
if d := listedDinner(t, e.h, created["id"].(string)); d["grounding_status"] != "pending" {
|
|
t.Fatalf("dinner right after POST = %v, want pending", d)
|
|
}
|
|
|
|
close(e.search.Gate)
|
|
job = waitJob(t, e.h, jobID)
|
|
if job["status"] != "succeeded" || job["kind"] != "ground_dinner" {
|
|
t.Fatalf("job = %v, want ground_dinner succeeded", job)
|
|
}
|
|
d := listedDinner(t, e.h, created["id"].(string))
|
|
photo, _ := d["photo_url"].(string)
|
|
if d["grounding_status"] != "grounded" || d["recipe_id"] == nil || !strings.HasSuffix(photo, "-400.jpg") ||
|
|
d["method"] != "stovetop" || d["minutes_total"] != 40.0 || d["difficulty"] != "easy" || d["grounding_error"] != nil {
|
|
t.Errorf("dinner = %v, want grounded with recipe, photo, method, minutes and difficulty", d)
|
|
}
|
|
}
|
|
|
|
func TestPostDinnerGroundingFailure(t *testing.T) {
|
|
t.Parallel()
|
|
e := newDinnersEnv(t).withRecipes(t)
|
|
e.start(t)
|
|
|
|
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Nothing matches this"}`)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
job := waitJob(t, e.h, created["grounding_job_id"].(string))
|
|
if job["status"] != "failed" || job["error"] != "no_recipe_found" {
|
|
t.Errorf("job = %v, want failed with no_recipe_found", job)
|
|
}
|
|
d := listedDinner(t, e.h, created["id"].(string))
|
|
if d["grounding_status"] != "failed" || d["grounding_error"] != "no_recipe_found" || d["recipe_id"] != nil {
|
|
t.Errorf("dinner = %v, want failed with no_recipe_found", d)
|
|
}
|
|
}
|
|
|
|
func TestGetRecipeCandidates(t *testing.T) {
|
|
t.Parallel()
|
|
e := newDinnersEnv(t).withRecipes(t)
|
|
id := createDinner(t, e.h, `{"name":"Chicken curry"}`) // runner not started: stays pending
|
|
|
|
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
list, _ := body["candidates"].([]any)
|
|
if len(list) != 2 {
|
|
t.Fatalf("candidates = %v, want 2", body)
|
|
}
|
|
first := list[0].(map[string]any)
|
|
if cid, _ := first["candidate_id"].(string); cid == "" || strings.Contains(cid, "a.example") {
|
|
t.Errorf("candidate_id = %#v, want an opaque id", first["candidate_id"])
|
|
}
|
|
want := map[string]any{
|
|
"candidate_id": first["candidate_id"],
|
|
"title": "Chicken Curry",
|
|
"site_name": "Curry House",
|
|
"photo_url": nil,
|
|
"minutes": 40.0,
|
|
}
|
|
if len(first) != len(want) {
|
|
t.Errorf("candidate keys = %v, want exactly %v", first, want)
|
|
}
|
|
for k, v := range want {
|
|
if got, ok := first[k]; !ok || got != v {
|
|
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
|
|
}
|
|
}
|
|
|
|
rec, body = callDinners(t, e.h, http.MethodGet, dinnersPath+"/nope/recipe-candidates", "")
|
|
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
|
t.Errorf("unknown dinner = %d %v, want 404", rec.Code, body)
|
|
}
|
|
}
|
|
|
|
func TestGetRecipeCandidatesSearchFails(t *testing.T) {
|
|
t.Parallel()
|
|
e := newDinnersEnv(t)
|
|
// The provider's text must reach neither the response nor the logs.
|
|
const leak = "provider-said-token-abc123"
|
|
e.search.Err = fmt.Errorf("%w: %s", recipes.ErrSearchRefused, leak)
|
|
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
|
|
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
|
if rec.Code != http.StatusBadGateway || errorCode(body) != "search_failed" {
|
|
t.Errorf("GET = %d %v, want 502 search_failed", rec.Code, body)
|
|
}
|
|
if strings.Contains(rec.Body.String(), leak) {
|
|
t.Errorf("response quotes the provider: %s", rec.Body)
|
|
}
|
|
logs := e.logs.String()
|
|
if strings.Contains(logs, leak) || !strings.Contains(logs, "reason=search_failed") {
|
|
t.Errorf("logs = %q, want reason=search_failed and no provider text", logs)
|
|
}
|
|
}
|
|
|
|
// TestGetRecipeCandidatesStoredLookupFailure: a candidate whose stored
|
|
// recipe cannot be read, or is stored incomplete, is a 500 logged by reason
|
|
// and dinner id, never with the candidate's URL, which can carry a personal
|
|
// token, or the database error.
|
|
func TestGetRecipeCandidatesStoredLookupFailure(t *testing.T) {
|
|
t.Parallel()
|
|
const (
|
|
token = "personal-token-abc123"
|
|
host = "secret-candidate.example"
|
|
page = "https://" + host + "/curry?access_token=" + token
|
|
)
|
|
for _, tc := range []struct {
|
|
name, sabotage string
|
|
leaks []string
|
|
}{
|
|
// Renaming a column the lookup selects fails the read.
|
|
{"lookup", `ALTER TABLE recipes RENAME COLUMN yield TO yield_gone`, []string{"no such column", "yield"}},
|
|
// A recipe stored without its photo, method or difficulty is
|
|
// incomplete.
|
|
{"incomplete", `INSERT INTO recipes (id, source_url, title, raw_jsonld, fetched_at, created_at, updated_at)
|
|
VALUES ('r-incomplete', '` + page + `', 'Curry', '{}', 'now', 'now', 'now')`, []string{"r-incomplete", "no photo"}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
e := newDinnersEnv(t)
|
|
e.search.Results["Chicken curry"] = []recipes.Candidate{{URL: page}}
|
|
id := createDinner(t, e.h, `{"name":"Chicken curry"}`) // runner not started: stays pending
|
|
if _, err := e.st.Writer.ExecContext(t.Context(), tc.sabotage); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
|
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
|
|
t.Errorf("GET = %d %s, want 500 internal", rec.Code, rec.Body)
|
|
}
|
|
logs := e.logs.String()
|
|
for _, secret := range append([]string{page, host, token, "access_token"}, tc.leaks...) {
|
|
if strings.Contains(rec.Body.String(), secret) {
|
|
t.Errorf("response contains %q: %s", secret, rec.Body)
|
|
}
|
|
if strings.Contains(logs, secret) {
|
|
t.Errorf("logs contain %q: %s", secret, logs)
|
|
}
|
|
}
|
|
if !strings.Contains(logs, "reason=candidates_failed") || !strings.Contains(logs, "dinner_id="+id) {
|
|
t.Errorf("logs = %q, want reason=candidates_failed and the dinner id", logs)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPostGroundViaCandidate(t *testing.T) {
|
|
t.Parallel()
|
|
e := newDinnersEnv(t).withRecipes(t)
|
|
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
|
|
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
|
|
|
cid := candidateID(t, e.h, id, "Weeknight Curry")
|
|
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"candidate_id":"`+cid+`"}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
photo, _ := d["photo_url"].(string)
|
|
if d["grounding_status"] != "grounded" || d["recipe_id"] == nil || photo == "" ||
|
|
d["method"] != "stovetop" || d["minutes_total"] != 25.0 || d["difficulty"] != "easy" || d["updated_by"] != testSession.Subject {
|
|
t.Errorf("dinner = %v", d)
|
|
}
|
|
// The candidate is now stored, so it is offered with its photo.
|
|
_, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
|
for _, c := range body["candidates"].([]any) {
|
|
if c := c.(map[string]any); c["candidate_id"] == cid && c["photo_url"] != photo {
|
|
t.Errorf("grounded candidate photo_url = %v, want %s", c["photo_url"], photo)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRecipeCandidatesHideTokenURLs: a candidate page whose URL carries a
|
|
// token in its path or query is offered by an opaque id alone, which
|
|
// grounds the dinner in that page.
|
|
func TestRecipeCandidatesHideTokenURLs(t *testing.T) {
|
|
t.Parallel()
|
|
e := newDinnersEnv(t)
|
|
const (
|
|
pathToken = "path-token-abc123"
|
|
queryToken = "query-token-def456"
|
|
pathPage = "https://secret-path.example/u/" + pathToken + "/chicken-curry"
|
|
queryPage = "https://secret-query.example/curry?access_token=" + queryToken
|
|
)
|
|
e.search.Results["Chicken curry"] = []recipes.Candidate{{URL: pathPage}, {URL: queryPage}}
|
|
e.pages.Bodies[pathPage] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
|
|
e.pages.Bodies[queryPage] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25)
|
|
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
|
|
id := createDinner(t, e.h, `{"name":"Chicken curry"}`) // runner not started: stays pending
|
|
|
|
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
if list, _ := body["candidates"].([]any); len(list) != 2 {
|
|
t.Fatalf("candidates = %v, want 2", body)
|
|
}
|
|
for _, secret := range []string{pathPage, queryPage, "secret-path.example", "secret-query.example", pathToken, queryToken, "access_token"} {
|
|
if strings.Contains(rec.Body.String(), secret) {
|
|
t.Errorf("candidates response contains %q: %s", secret, rec.Body)
|
|
}
|
|
}
|
|
|
|
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
|
cid := candidateID(t, e.h, id, "Weeknight Curry")
|
|
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"candidate_id":"`+cid+`"}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
if strings.Contains(rec.Body.String(), queryToken) {
|
|
t.Errorf("ground response contains the token: %s", rec.Body)
|
|
}
|
|
var source string
|
|
if err := e.st.Reader.QueryRow(`SELECT source_url FROM recipes WHERE id = ?`, d["recipe_id"]).Scan(&source); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if d["grounding_status"] != "grounded" || source != queryPage {
|
|
t.Errorf("dinner = %v in recipe %s, want grounded in %s", d, source, queryPage)
|
|
}
|
|
}
|
|
|
|
// TestPostGroundRejectsCandidateNotOffered: a candidate_id is only one
|
|
// offered for that dinner; a page URL, or another dinner's candidate, is
|
|
// refused.
|
|
func TestPostGroundRejectsCandidateNotOffered(t *testing.T) {
|
|
t.Parallel()
|
|
e := newDinnersEnv(t).withRecipes(t)
|
|
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
|
|
other := createDinner(t, e.h, `{"name":"Pad thai"}`)
|
|
cid := candidateID(t, e.h, id, "Weeknight Curry")
|
|
for _, tc := range []struct{ name, dinner, cid string }{
|
|
{"page URL", id, curry2Page},
|
|
{"another dinner's", other, cid},
|
|
} {
|
|
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+tc.dinner+"/ground", `{"candidate_id":"`+tc.cid+`"}`)
|
|
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_dinner" {
|
|
t.Errorf("%s: %d %v, want 400 invalid_dinner", tc.name, rec.Code, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPostGroundViaRecipeURL(t *testing.T) {
|
|
t.Parallel()
|
|
e := newDinnersEnv(t).withRecipes(t)
|
|
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
|
|
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
|
|
|
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+stewPage+`"}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
if d["grounding_status"] != "grounded" || d["photo_url"] == nil || d["method"] != "oven" || d["minutes_total"] != 150.0 {
|
|
t.Errorf("dinner = %v", d)
|
|
}
|
|
|
|
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"https://gone.example/soup"}`)
|
|
if rec.Code != http.StatusUnprocessableEntity || errorCode(body) != "ungroundable" || strings.Contains(rec.Body.String(), "gone.example") {
|
|
t.Errorf("unusable link = %d %v, want 422 ungroundable", rec.Code, body)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// TestPostGroundViaRecipeURLDinnerWriteFailure: a pasted link whose recipe
|
|
// is stored but whose dinner cannot be written is a 500 logged by reason
|
|
// and dinner id, never with the dependency error.
|
|
func TestPostGroundViaRecipeURLDinnerWriteFailure(t *testing.T) {
|
|
t.Parallel()
|
|
e := newDinnersEnv(t).withRecipes(t)
|
|
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_grounding BEFORE UPDATE OF recipe_id ON dinners WHEN NEW.recipe_id IS NOT NULL 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":"`+stewPage+`"}`)
|
|
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
|
|
t.Errorf("POST = %d %s, want 500 internal", rec.Code, rec.Body)
|
|
}
|
|
var stored int
|
|
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM recipes WHERE source_url = ?`, stewPage).Scan(&stored); err != nil || stored != 1 {
|
|
t.Fatalf("recipes stored for the link = %d (%v), want 1: the failure must be the dinner write", stored, err)
|
|
}
|
|
logs := e.logs.String()
|
|
if 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 dependency error", logs)
|
|
}
|
|
}
|
|
|
|
// TestPostGroundUnexpectedFailures: grounding by hand that fails reading
|
|
// the dinner, or storing a manual recipe, is a 500 logged by reason and
|
|
// dinner id, never with the database error.
|
|
func TestPostGroundUnexpectedFailures(t *testing.T) {
|
|
t.Parallel()
|
|
const (
|
|
// Renaming a column every dinner read selects fails the read.
|
|
breakRead = `ALTER TABLE dinners RENAME COLUMN cuisine TO cuisine_gone`
|
|
// The trigger's message stands in for the database error.
|
|
breakStore = `CREATE TRIGGER refuse_recipes BEFORE INSERT ON recipes BEGIN SELECT RAISE(ABORT, 'disk full'); END`
|
|
manual = `{"manual":{"ingredients":["1 lb beef"],"steps":["Brown."],"minutes":240,"method":"crockpot"}}`
|
|
)
|
|
for _, tc := range []struct {
|
|
name, sabotage, fix, leak string
|
|
}{
|
|
{"link read", breakRead, `{"recipe_url":"` + stewPage + `"}`, "cuisine"},
|
|
{"manual read", breakRead, manual, "cuisine"},
|
|
{"manual store", breakStore, manual, "disk full"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
e := newDinnersEnv(t).withRecipes(t)
|
|
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
|
|
if _, err := e.st.Writer.ExecContext(t.Context(), tc.sabotage); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", tc.fix)
|
|
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
|
|
t.Errorf("POST = %d %s, want 500 internal", rec.Code, rec.Body)
|
|
}
|
|
logs := e.logs.String()
|
|
if strings.Contains(logs, tc.leak) || !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 database 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) {
|
|
t.Parallel()
|
|
e := newDinnersEnv(t)
|
|
id := createDinner(t, e.h, `{"name":"Family chili"}`)
|
|
|
|
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
|
|
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
if d["grounding_status"] != "manual" || d["recipe_id"] == nil || d["photo_url"] != nil ||
|
|
d["method"] != "crockpot" || d["minutes_total"] != 240.0 || d["grounding_error"] != nil {
|
|
t.Errorf("dinner = %v", d)
|
|
}
|
|
var lines int
|
|
if err := e.st.Reader.QueryRow(`SELECT count(*) FROM recipe_ingredients WHERE recipe_id = ?`, d["recipe_id"]).Scan(&lines); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if lines != 2 {
|
|
t.Errorf("stored %d ingredient lines, want 2", lines)
|
|
}
|
|
}
|
|
|
|
func TestPostGroundRejects(t *testing.T) {
|
|
t.Parallel()
|
|
e := newDinnersEnv(t)
|
|
id := createDinner(t, e.h, `{"name":"Pad thai"}`)
|
|
path := dinnersPath + "/" + id + "/ground"
|
|
|
|
for _, tc := range []struct{ name, body, code string }{
|
|
{"empty", `{}`, "invalid_dinner"},
|
|
{"two kinds", `{"candidate_id":"https://a.example/x","recipe_url":"https://a.example/x"}`, "invalid_dinner"},
|
|
{"candidate not offered", `{"candidate_id":"c1"}`, "invalid_dinner"},
|
|
{"candidate a page URL", `{"candidate_id":"https://a.example/x"}`, "invalid_dinner"},
|
|
{"http link", `{"recipe_url":"http://a.example/x"}`, "invalid_dinner"},
|
|
{"null", `{"recipe_url":null}`, "invalid_request"},
|
|
{"unknown field", `{"url":"https://a.example/x"}`, "invalid_request"},
|
|
{"unknown manual field", `{"manual":{"ingredients":["x"],"minutes":5,"method":"oven","serves":4}}`, "invalid_request"},
|
|
{"manual without method", `{"manual":{"ingredients":["x"],"minutes":5}}`, "invalid_dinner"},
|
|
{"manual without ingredients", `{"manual":{"ingredients":[],"minutes":5,"method":"oven"}}`, "invalid_dinner"},
|
|
{"manual minutes too many", `{"manual":{"ingredients":["x"],"minutes":5000,"method":"oven"}}`, "invalid_dinner"},
|
|
{"not an object", `[]`, "invalid_request"},
|
|
} {
|
|
rec, body := callDinners(t, e.h, http.MethodPost, path, tc.body)
|
|
if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code {
|
|
t.Errorf("%s: %d %v, want 400 %s", tc.name, rec.Code, body, tc.code)
|
|
}
|
|
}
|
|
if d := listedDinner(t, e.h, id); d["grounding_status"] != "pending" {
|
|
t.Errorf("dinner after rejected fixes = %v, want unchanged", d)
|
|
}
|
|
|
|
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/nope/ground",
|
|
`{"manual":{"ingredients":["x"],"minutes":5,"method":"oven"}}`)
|
|
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
|
t.Errorf("unknown dinner = %d %v, want 404", rec.Code, body)
|
|
}
|
|
}
|
|
|
|
func TestPostDinnerEnqueueFailureAnswersFailedDinner(t *testing.T) {
|
|
t.Parallel()
|
|
e := newDinnersEnv(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)
|
|
}
|
|
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
id, _ := created["id"].(string)
|
|
if id == "" || created["grounding_status"] != "failed" || created["grounding_error"] != "grounding_error" || created["grounding_job_id"] != nil {
|
|
t.Fatalf("created = %v, want its id, failed (grounding_error) and a null grounding_job_id", created)
|
|
}
|
|
if got := listedDinner(t, e.h, id); got["grounding_status"] != "failed" {
|
|
t.Errorf("listed = %v, want failed", got)
|
|
}
|
|
}
|
|
|
|
func TestPostPhotoBackfillGroundsFailedDinners(t *testing.T) {
|
|
t.Parallel()
|
|
e := newDinnersEnv(t).withRecipes(t)
|
|
e.start(t)
|
|
|
|
// No model reply is scripted, so grounding on creation fails.
|
|
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
id := created["id"].(string)
|
|
waitJob(t, e.h, created["grounding_job_id"].(string))
|
|
if d := listedDinner(t, e.h, id); d["grounding_status"] != "failed" || d["photo_url"] != nil {
|
|
t.Fatalf("dinner = %v, want failed without a photo", d)
|
|
}
|
|
|
|
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
rec, started := callDinners(t, e.h, http.MethodPost, "/api/v1/settings/photo-backfill", "")
|
|
jobID, _ := started["id"].(string)
|
|
if rec.Code != http.StatusAccepted || jobID == "" {
|
|
t.Fatalf("POST photo-backfill = %d: %s, want 202 with a job id", rec.Code, rec.Body)
|
|
}
|
|
job := waitJob(t, e.h, jobID)
|
|
want := fmt.Sprintf(`map[dinners:[map[dinner_id:%s outcome:grounded reason:<nil>]]]`, id)
|
|
if job["status"] != "succeeded" || job["kind"] != "backfill_dinner_photos" || fmt.Sprint(job["result"]) != want {
|
|
t.Fatalf("job = %v, want backfill_dinner_photos succeeded with %s", job, want)
|
|
}
|
|
d := listedDinner(t, e.h, id)
|
|
photo, _ := d["photo_url"].(string)
|
|
if d["grounding_status"] != "grounded" || d["grounding_error"] != nil || !strings.HasSuffix(photo, "-400.jpg") {
|
|
t.Errorf("dinner = %v, want grounded with a photo", d)
|
|
}
|
|
|
|
// The backfill is started only by its own endpoint.
|
|
rec, _ = callDinners(t, e.h, http.MethodPost, "/api/v1/jobs", `{"kind":"backfill_dinner_photos","payload":{}}`)
|
|
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "service_only_kind") {
|
|
t.Errorf("POST /jobs backfill = %d: %s, want 400 service_only_kind", rec.Code, rec.Body)
|
|
}
|
|
}
|