|
|
|
@@ -0,0 +1,215 @@
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|