Returns a dinner with its recipe for the recipe detail screen and Kitchen reading mode: photo URLs at both cached sizes (400/1200) or null, ingredients and steps in recipe position order, the source page for a fetched recipe, grounding status and error, and why_picked, the reason of the suggestion that created the dinner (null when none). Unknown and archived dinners are 404. Read in one transaction. Adds the GetDinnerSuggestionReason query (sqlc regenerated). The dinners test env now serves photos from the grounder's own store and data directory so photo URLs can be fetched in tests. Maestro task 099e9952-7f98-4700-8369-241c589a1e9d. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
216 lines
8.3 KiB
Go
216 lines
8.3 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"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/recipestest"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
)
|
|
|
|
// getDetail answers GET /dinners/{id} for id, failing unless it is 200, and
|
|
// returns the body both raw (to tell null from absent) and decoded.
|
|
func getDetail(t *testing.T, h http.Handler, id string) (map[string]any, dinners.Detail) {
|
|
t.Helper()
|
|
rec, raw := callDinners(t, h, http.MethodGet, dinnersPath+"/"+id, "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET dinner = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
var d dinners.Detail
|
|
dec := json.NewDecoder(rec.Body)
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(&d); err != nil {
|
|
t.Fatalf("decode detail: %v", err)
|
|
}
|
|
return raw, d
|
|
}
|
|
|
|
// wantNull fails unless each key is present in raw and null.
|
|
func wantNull(t *testing.T, raw map[string]any, keys ...string) {
|
|
t.Helper()
|
|
for _, k := range keys {
|
|
if v, ok := raw[k]; !ok || v != nil {
|
|
t.Errorf("%s = %v (present %v), want null", k, v, ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGetDinnerGroundedSideQuest: a Side quest grounded in a fetched page
|
|
// shows its photo at both stored sizes, its recipe's ingredients and steps
|
|
// in recipe order (not row order), its source page and the reason it was
|
|
// suggested.
|
|
func TestGetDinnerGroundedSideQuest(t *testing.T) {
|
|
t.Parallel()
|
|
e := newDinnersEnv(t).withRecipes(t)
|
|
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)
|
|
}
|
|
if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "succeeded" {
|
|
t.Fatalf("grounding job = %v", job)
|
|
}
|
|
id := created["id"].(string)
|
|
|
|
// Make it an accepted Side quest with the suggestion that created it,
|
|
// and reverse its recipe's positions so row order disagrees with
|
|
// recipe order.
|
|
var recipeID, photoID string
|
|
if err := e.st.Reader.QueryRow(`SELECT recipe_id, photo_id FROM dinners WHERE id = ?`, id).Scan(&recipeID, &photoID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, stmt := range []string{
|
|
`UPDATE dinners SET status = 'quest_log', origin = 'side_quest', is_favorite = 1 WHERE id = ?`,
|
|
`UPDATE recipe_ingredients SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
|
|
`UPDATE recipe_steps SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
|
|
} {
|
|
if _, err := e.st.Writer.Exec(stmt, id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
q := db.New(e.st.Writer)
|
|
now := store.Now().String()
|
|
batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const reason = "Like your Tikka masala, but quicker."
|
|
if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{
|
|
ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: "Chicken curry",
|
|
Reason: reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, got := getDetail(t, e.h, id)
|
|
str := func(s string) *string { return &s }
|
|
minutes := int64(40)
|
|
want := dinners.Detail{
|
|
ID: id,
|
|
Name: "Chicken curry",
|
|
Status: "quest_log",
|
|
Origin: "side_quest",
|
|
Method: str("stovetop"),
|
|
MinutesTotal: &minutes,
|
|
Difficulty: str("easy"),
|
|
IsFavorite: true,
|
|
PhotoURLs: map[string]string{
|
|
"400": "/media/photos/" + photoID + "-400.jpg",
|
|
"1200": "/media/photos/" + photoID + "-1200.jpg",
|
|
},
|
|
Ingredients: []dinners.Ingredient{
|
|
{Name: str("garlic"), Qty: str("2"), Unit: str("cloves"), Note: str("minced"), RawText: "2 cloves garlic, minced"},
|
|
{Name: str("onion"), Qty: str("1"), RawText: "1 onion"},
|
|
},
|
|
Steps: []string{"Fry with the garlic.", "Chop the onion."},
|
|
Source: &dinners.Source{URL: curryPage, SiteName: str("Curry House")},
|
|
GroundingStatus: "grounded",
|
|
WhyPicked: str(reason),
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
gj, _ := json.Marshal(got)
|
|
wj, _ := json.Marshal(want)
|
|
t.Errorf("detail =\n %s\nwant\n %s", gj, wj)
|
|
}
|
|
// Each photo URL is served.
|
|
for _, u := range got.PhotoURLs {
|
|
rec := httptest.NewRecorder()
|
|
e.h.ServeHTTP(rec, signedIn(http.MethodGet, u, nil))
|
|
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/jpeg" {
|
|
t.Errorf("GET %s = %d %s", u, rec.Code, rec.Header().Get("Content-Type"))
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGetDinnerManualRegular: a Regular added by hand and given a recipe
|
|
// typed in has no photo, no source and no reason, and its ingredients are
|
|
// the typed lines with nothing normalized.
|
|
func TestGetDinnerManualRegular(t *testing.T) {
|
|
t.Parallel()
|
|
e := newDinnersEnv(t)
|
|
id := createDinner(t, e.h, `{"name":"Family chili"}`)
|
|
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
|
|
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK {
|
|
t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
|
|
raw, got := getDetail(t, e.h, id)
|
|
wantNull(t, raw, "photo_urls", "source", "why_picked", "difficulty", "grounding_error")
|
|
str := func(s string) *string { return &s }
|
|
minutes := int64(240)
|
|
want := dinners.Detail{
|
|
ID: id,
|
|
Name: "Family chili",
|
|
Status: "regular",
|
|
Origin: "regular",
|
|
Method: str("crockpot"),
|
|
MinutesTotal: &minutes,
|
|
Ingredients: []dinners.Ingredient{{RawText: "1 lb beef"}, {RawText: "1 can beans"}},
|
|
Steps: []string{"Brown.", "Simmer."},
|
|
GroundingStatus: "manual",
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
gj, _ := json.Marshal(got)
|
|
wj, _ := json.Marshal(want)
|
|
t.Errorf("detail =\n %s\nwant\n %s", gj, wj)
|
|
}
|
|
}
|
|
|
|
// TestGetDinnerFailedGrounding: a dinner whose grounding failed shows its
|
|
// real state — failed with its reason, no photo, no source and empty
|
|
// ingredients and steps — never a pretend recipe.
|
|
func TestGetDinnerFailedGrounding(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)
|
|
}
|
|
if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "failed" {
|
|
t.Fatalf("grounding job = %v, want failed", job)
|
|
}
|
|
|
|
raw, got := getDetail(t, e.h, created["id"].(string))
|
|
wantNull(t, raw, "photo_urls", "source", "why_picked", "method", "minutes_total", "difficulty")
|
|
if ings, ok := raw["ingredients"].([]any); !ok || len(ings) != 0 {
|
|
t.Errorf("ingredients = %v, want []", raw["ingredients"])
|
|
}
|
|
if steps, ok := raw["steps"].([]any); !ok || len(steps) != 0 {
|
|
t.Errorf("steps = %v, want []", raw["steps"])
|
|
}
|
|
if got.GroundingStatus != "failed" || got.GroundingError == nil || *got.GroundingError != dinners.ReasonNoRecipe ||
|
|
got.Name != "Nothing matches this" || got.Status != "regular" || got.Origin != "regular" {
|
|
t.Errorf("detail = %+v, want the failed Regular with no_recipe_found", got)
|
|
}
|
|
}
|
|
|
|
// TestGetDinnerNotFound: an unknown dinner and an archived one are both
|
|
// 404, and the detail needs a session like every /api/v1 route.
|
|
func TestGetDinnerNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
e := newDinnersEnv(t)
|
|
id := createDinner(t, e.h, `{"name":"Old favourite"}`)
|
|
if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK {
|
|
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
for _, target := range []string{id, store.NewID()} {
|
|
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+target, "")
|
|
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
|
t.Errorf("GET %s = %d %v, want 404 not_found", target, rec.Code, body)
|
|
}
|
|
}
|
|
|
|
live := createDinner(t, e.h, `{"name":"Taco night"}`)
|
|
req := httptest.NewRequest(http.MethodGet, dinnersPath+"/"+live, nil)
|
|
if rec, body := serve(t, e.h, req); rec.Code != http.StatusUnauthorized || errorCode(body) != "unauthenticated" {
|
|
t.Errorf("GET without a session = %d %v, want 401", rec.Code, body)
|
|
}
|
|
}
|