686 lines
26 KiB
Go
686 lines
26 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
|
"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"
|
|
)
|
|
|
|
const dinnersPath = "/api/v1/dinners"
|
|
|
|
// newDinnersRouter returns a router whose dinners service runs over the
|
|
// returned store. Its job runner is not started, so grounding jobs stay
|
|
// queued.
|
|
func newDinnersRouter(t *testing.T) (http.Handler, *store.Store) {
|
|
t.Helper()
|
|
e := newDinnersEnv(t)
|
|
return e.h, e.st
|
|
}
|
|
|
|
// dinnersEnv is a router whose dinners service, job runner and grounder
|
|
// share one store, grounding with fake search, pages, images and model.
|
|
type dinnersEnv struct {
|
|
h http.Handler
|
|
st *store.Store
|
|
runner *jobs.Runner
|
|
search *recipestest.Search
|
|
pages *recipestest.Pages
|
|
images *recipestest.Images
|
|
model *llm.Fake
|
|
// logs holds everything the router and services log.
|
|
logs *logBuffer
|
|
}
|
|
|
|
// logBuffer is a bytes.Buffer safe for concurrent log writes.
|
|
type logBuffer struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
func (b *logBuffer) Write(p []byte) (int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.Write(p)
|
|
}
|
|
|
|
func (b *logBuffer) String() string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.String()
|
|
}
|
|
|
|
func newDinnersEnv(t *testing.T) *dinnersEnv {
|
|
t.Helper()
|
|
st := newTestStore(t)
|
|
// The Quest log is read against the household's current week.
|
|
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e := &dinnersEnv{
|
|
st: st,
|
|
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
|
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
|
|
images: &recipestest.Images{JPEGs: map[string][]byte{}},
|
|
model: &llm.Fake{},
|
|
logs: &logBuffer{},
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(e.logs, nil))
|
|
runner, err := jobs.NewRunner(st, logger, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.runner = runner
|
|
// The router serves the photos the grounder caches.
|
|
dataDir := t.TempDir()
|
|
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.model)
|
|
photos, err := recipes.NewPhotoCache(e.images, st, dataDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
svc, err := dinners.NewService(st, g, runner, func() {}, logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
|
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return e
|
|
}
|
|
|
|
// start starts the env's job runner, stopping it when the test ends.
|
|
func (e *dinnersEnv) 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()
|
|
})
|
|
}
|
|
|
|
// callDinners sends a signed-in request with body (none when empty).
|
|
func callDinners(t *testing.T, h http.Handler, method, target, body string) (*httptest.ResponseRecorder, map[string]any) {
|
|
t.Helper()
|
|
var r io.Reader
|
|
if body != "" {
|
|
r = strings.NewReader(body)
|
|
}
|
|
return serve(t, h, signedIn(method, target, r))
|
|
}
|
|
|
|
// createDinner adds a Regular and returns its id.
|
|
func createDinner(t *testing.T, h http.Handler, body string) string {
|
|
t.Helper()
|
|
rec, got := callDinners(t, h, http.MethodPost, dinnersPath, body)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("POST %s = %d: %s", body, rec.Code, rec.Body)
|
|
}
|
|
return got["id"].(string)
|
|
}
|
|
|
|
// listNames returns the names GET /dinners?status=status lists, in order.
|
|
func listNames(t *testing.T, h http.Handler, status string) []string {
|
|
t.Helper()
|
|
rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"?status="+status, "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET status=%s = %d: %s", status, rec.Code, rec.Body)
|
|
}
|
|
list, ok := body["dinners"].([]any)
|
|
if !ok {
|
|
t.Fatalf("GET status=%s has no dinners list: %v", status, body)
|
|
}
|
|
names := make([]string, len(list))
|
|
for i, d := range list {
|
|
names[i] = d.(map[string]any)["name"].(string)
|
|
}
|
|
return names
|
|
}
|
|
|
|
func TestCreateDinnerByName(t *testing.T) {
|
|
t.Parallel()
|
|
router, st := newDinnersRouter(t)
|
|
|
|
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":" Chicken Tikka Masala "}`)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
|
}
|
|
for k, v := range map[string]any{
|
|
"name": "Chicken Tikka Masala",
|
|
"status": "regular",
|
|
"origin": "regular",
|
|
"grounding_status": "pending",
|
|
"is_favorite": false,
|
|
"photo_url": nil,
|
|
"method": nil,
|
|
"minutes_total": nil,
|
|
"difficulty": nil,
|
|
"updated_by": testSession.Subject,
|
|
} {
|
|
if got, ok := body[k]; !ok || got != v {
|
|
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
|
|
}
|
|
}
|
|
if got, ok := body["recipe_url"]; ok {
|
|
t.Errorf("recipe_url = %#v, want no recipe_url in a dinner response", got)
|
|
}
|
|
var normalized, updatedBy string
|
|
if err := st.Reader.QueryRow(`SELECT normalized_name, updated_by FROM dinners WHERE id = ?`, body["id"]).Scan(&normalized, &updatedBy); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if normalized != "chicken tikka masala" || updatedBy != testSession.Subject {
|
|
t.Errorf("stored normalized_name, updated_by = %q, %q", normalized, updatedBy)
|
|
}
|
|
}
|
|
|
|
func TestCreateDinnerByURL(t *testing.T) {
|
|
t.Parallel()
|
|
router, _ := newDinnersRouter(t)
|
|
|
|
link := "https://www.example.com/recipes/12345/beef-and-broccoli-stir-fry/"
|
|
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Stir fry night","recipe_url":"`+link+`"}`)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
|
}
|
|
for k, v := range map[string]any{
|
|
"name": "Stir fry night",
|
|
"status": "regular",
|
|
"origin": "regular",
|
|
"grounding_status": "pending",
|
|
"updated_by": testSession.Subject,
|
|
} {
|
|
if body[k] != v {
|
|
t.Errorf("%s = %#v, want %#v", k, body[k], v)
|
|
}
|
|
}
|
|
// The link stays on the server for grounding; responses never carry it.
|
|
if got, ok := body["recipe_url"]; ok {
|
|
t.Errorf("recipe_url = %#v, want no recipe_url in a dinner response", got)
|
|
}
|
|
|
|
// The same link again is a duplicate even though its name would differ.
|
|
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Something else entirely"}`)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("unrelated name = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Beef and broccoli","recipe_url":"`+link+`"}`)
|
|
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
|
|
t.Errorf("same link again = %d %v, want 409 duplicate_dinner", rec.Code, body)
|
|
}
|
|
|
|
for _, tc := range []struct{ name, body string }{
|
|
{"plain http", `{"name":"Chili","recipe_url":"http://example.com/recipes/chili"}`},
|
|
{"not a URL", `{"name":"Chili","recipe_url":"chili"}`},
|
|
{"credentials", `{"name":"Chili","recipe_url":"https://me:pw@example.com/recipes/chili"}`},
|
|
{"link without a name", `{"recipe_url":"https://example.com/recipes/chili"}`},
|
|
{"filler name with a link", `{"name":"The Best Easy Recipe","recipe_url":"https://example.com/recipes/chili"}`},
|
|
{"neither field", `{}`},
|
|
} {
|
|
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, tc.body)
|
|
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_dinner" {
|
|
t.Errorf("%s: %d %v, want 400 invalid_dinner", tc.name, rec.Code, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDinnerResponsesHideRecipeURL: a pasted link may carry a personal
|
|
// token, so neither the create response nor a later list read shows any
|
|
// part of it, while the stored link still awaits grounding.
|
|
func TestDinnerResponsesHideRecipeURL(t *testing.T) {
|
|
t.Parallel()
|
|
router, st := newDinnersRouter(t)
|
|
|
|
const link = "https://c.example/recipes/stew?access_token=personal-token-abc123"
|
|
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Stew","recipe_url":"`+link+`"}`)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
|
}
|
|
created := rec.Body.String()
|
|
rec, _ = callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET status=regular = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
for _, resp := range []struct{ name, body string }{{"create", created}, {"list", rec.Body.String()}} {
|
|
for _, secret := range []string{link, "c.example", "access_token", "personal-token-abc123", "recipe_url"} {
|
|
if strings.Contains(resp.body, secret) {
|
|
t.Errorf("%s response contains %q: %s", resp.name, secret, resp.body)
|
|
}
|
|
}
|
|
}
|
|
|
|
var stored string
|
|
if err := st.Reader.QueryRow(`SELECT recipe_url FROM dinners WHERE id = ?`, body["id"]).Scan(&stored); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored != link {
|
|
t.Errorf("stored recipe_url = %q, want %q kept for grounding", stored, link)
|
|
}
|
|
}
|
|
|
|
// TestDinnerResponsesHideTokenInLinkPath: a personal token can sit
|
|
// anywhere in a link's path, even as plain letters, so a dinner added from
|
|
// a link is named only by the household and neither the create response, a
|
|
// later list read nor the duplicate error for the same link shows any of
|
|
// the link.
|
|
func TestDinnerResponsesHideTokenInLinkPath(t *testing.T) {
|
|
t.Parallel()
|
|
router, _ := newDinnersRouter(t)
|
|
|
|
const link = "https://c.example/recipes/stew-key-abcdefghijklmno"
|
|
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Sunday stew","recipe_url":"`+link+`"}`)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
|
}
|
|
if body["name"] != "Sunday stew" {
|
|
t.Errorf("name = %v, want Sunday stew", body["name"])
|
|
}
|
|
created := rec.Body.String()
|
|
rec, _ = callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET status=regular = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
listed := rec.Body.String()
|
|
rec, _ = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Beef stew","recipe_url":"`+link+`"}`)
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("same link again = %d, want 409: %s", rec.Code, rec.Body)
|
|
}
|
|
// Each secret holds a character no ULID has (Crockford base32 has no I,
|
|
// L, O, U, "-", "." or "/"), so the dinner's id never matches one.
|
|
for _, resp := range []struct{ name, body string }{{"create", created}, {"list", listed}, {"duplicate", rec.Body.String()}} {
|
|
lower := strings.ToLower(resp.body)
|
|
for _, secret := range []string{link, "c.example", "recipes/stew", "stew-key", "abcdefghijklmno"} {
|
|
if strings.Contains(lower, secret) {
|
|
t.Errorf("%s response contains %q: %s", resp.name, secret, resp.body)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreateDinnerDuplicate(t *testing.T) {
|
|
t.Parallel()
|
|
router, st := newDinnersRouter(t)
|
|
createDinner(t, router, `{"name":"Chicken Tikka Masala"}`)
|
|
|
|
for _, name := range []string{
|
|
"chicken tikka masala",
|
|
"Easy Chicken Tikka-Masala!",
|
|
"The Best Homemade Chicken Tikka Masala Recipe",
|
|
} {
|
|
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"`+name+`"}`)
|
|
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
|
|
t.Errorf("%q: %d %v, want 409 duplicate_dinner", name, rec.Code, body)
|
|
}
|
|
}
|
|
// A name repeating the dish is a duplicate with a link too.
|
|
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Chicken tikka masala","recipe_url":"https://example.com/other-curry.html"}`)
|
|
if rec.Code != http.StatusConflict {
|
|
t.Errorf("named duplicate with a link = %d %v, want 409", rec.Code, body)
|
|
}
|
|
|
|
var n int
|
|
if err := st.Reader.QueryRow(`SELECT count(*) FROM dinners`).Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("dinners rows = %d, want 1", n)
|
|
}
|
|
|
|
// A different dish sharing words is not a duplicate.
|
|
createDinner(t, router, `{"name":"Chicken Tikka Wraps"}`)
|
|
}
|
|
|
|
func TestCreateDinnerRejected(t *testing.T) {
|
|
t.Parallel()
|
|
router, _ := newDinnersRouter(t)
|
|
for _, tc := range []struct{ name, body, code string }{
|
|
{"empty name", `{"name":" "}`, "invalid_dinner"},
|
|
{"filler only", `{"name":"The Best Easy Recipe"}`, "invalid_dinner"},
|
|
{"too long", `{"name":"` + strings.Repeat("x", dinners.MaxNameChars+1) + `"}`, "invalid_dinner"},
|
|
{"null name", `{"name":null}`, "invalid_request"},
|
|
{"unknown field", `{"name":"Chili","status":"quest_log"}`, "invalid_request"},
|
|
{"not an object", `"Chili"`, "invalid_request"},
|
|
} {
|
|
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListDinnersFiltersByStatus(t *testing.T) {
|
|
t.Parallel()
|
|
router, st := newDinnersRouter(t)
|
|
tacos := createDinner(t, router, `{"name":"Tacos"}`)
|
|
createDinner(t, router, `{"name":"Lasagna"}`)
|
|
pho := createDinner(t, router, `{"name":"Pho"}`)
|
|
gone := createDinner(t, router, `{"name":"Meatloaf"}`)
|
|
|
|
ctx := context.Background()
|
|
if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, pho); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.Writer.ExecContext(ctx,
|
|
`INSERT INTO photos (id, source_url, local_path, width, height, sha256, fetched_at, created_at, updated_at)
|
|
VALUES ('photo1', 'https://example.com/tacos.jpg', 'media/photos/photo1', 1200, 800, 'abc', 't', 't', 't')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET photo_id = 'photo1', grounding_status = 'grounded' WHERE id = ?`, tacos); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec, _ := callDinners(t, router, http.MethodDelete, dinnersPath+"/"+gone, ""); rec.Code != http.StatusOK {
|
|
t.Fatalf("DELETE = %d", rec.Code)
|
|
}
|
|
|
|
if got := strings.Join(listNames(t, router, "regular"), ","); got != "Lasagna,Tacos" {
|
|
t.Errorf("regular = %s, want Lasagna,Tacos", got)
|
|
}
|
|
if got := strings.Join(listNames(t, router, "quest_log"), ","); got != "Pho" {
|
|
t.Errorf("quest_log = %s, want Pho", got)
|
|
}
|
|
|
|
_, body := callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
|
|
for _, d := range body["dinners"].([]any) {
|
|
d := d.(map[string]any)
|
|
switch d["name"] {
|
|
case "Tacos":
|
|
if d["photo_url"] != "/media/photos/photo1-400.jpg" || d["grounding_status"] != "grounded" {
|
|
t.Errorf("Tacos photo_url, grounding_status = %v, %v", d["photo_url"], d["grounding_status"])
|
|
}
|
|
case "Lasagna":
|
|
if d["photo_url"] != nil || d["grounding_status"] != "pending" {
|
|
t.Errorf("Lasagna photo_url, grounding_status = %v, %v", d["photo_url"], d["grounding_status"])
|
|
}
|
|
}
|
|
}
|
|
|
|
// An empty list is [] rather than null.
|
|
empty, _ := newDinnersRouter(t)
|
|
rec, _ := callDinners(t, empty, http.MethodGet, dinnersPath+"?status=regular", "")
|
|
if got := rec.Body.String(); got != `{"dinners":[]}` {
|
|
t.Errorf("empty list = %s", got)
|
|
}
|
|
|
|
for _, q := range []string{"", "?status=archived", "?status=suggested", "?status=bogus"} {
|
|
rec, body := callDinners(t, router, http.MethodGet, dinnersPath+q, "")
|
|
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_dinner" {
|
|
t.Errorf("GET %q = %d %v, want 400 invalid_dinner", q, rec.Code, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The Quest log leaves out a dinner on the plan for the household's current
|
|
// week or a later one: it is scheduled already. A dinner on an earlier
|
|
// week's plan only, or on none, is listed.
|
|
func TestQuestLogLeavesOutPlannedDinners(t *testing.T) {
|
|
t.Parallel()
|
|
router, st := newDinnersRouter(t)
|
|
ctx := context.Background()
|
|
names := []string{"Bibimbap", "Gumbo", "Pho", "Ramen", "Tagine"}
|
|
ids := map[string]string{}
|
|
for _, name := range names {
|
|
id := createDinner(t, router, `{"name":"`+name+`"}`)
|
|
if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ids[name] = id
|
|
}
|
|
|
|
loc, err := time.LoadLocation("America/New_York")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
thisWeek := household.WeekStart(household.Today(time.Now(), loc))
|
|
// day is the date days after the Monday weeks after this week's.
|
|
day := func(weeks, days int) string {
|
|
y, m, d := thisWeek.Date()
|
|
return time.Date(y, m, d+7*weeks+days, 0, 0, 0, 0, time.UTC).Format(time.DateOnly)
|
|
}
|
|
// Gumbo is on last week's plan only; Pho on this week's; Ramen on the
|
|
// week after next; Tagine on last week's and this week's.
|
|
plan := func(weeks int, dinners ...string) {
|
|
t.Helper()
|
|
weekStart := day(weeks, 0)
|
|
planID := "plan-" + weekStart
|
|
if _, err := st.Writer.ExecContext(ctx,
|
|
`INSERT INTO week_plans (id, week_start, status, created_at, updated_at) VALUES (?, ?, 'draft', 't', 't')`,
|
|
planID, weekStart); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i, name := range dinners {
|
|
if _, err := st.Writer.ExecContext(ctx,
|
|
`INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at) VALUES (?, ?, ?, ?, 1, 't', 't')`,
|
|
planID+"-"+name, planID, day(weeks, i), ids[name]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
plan(-1, "Gumbo", "Tagine")
|
|
plan(0, "Pho", "Tagine")
|
|
plan(2, "Ramen")
|
|
|
|
if got := strings.Join(listNames(t, router, "quest_log"), ","); got != "Bibimbap,Gumbo" {
|
|
t.Errorf("quest_log = %s, want Bibimbap,Gumbo", got)
|
|
}
|
|
}
|
|
|
|
func TestPatchDinner(t *testing.T) {
|
|
t.Parallel()
|
|
router, st := newDinnersRouter(t)
|
|
id := createDinner(t, router, `{"name":"Chili"}`)
|
|
createDinner(t, router, `{"name":"Pot Roast"}`)
|
|
if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id,
|
|
`{"name":"White Chicken Chili","method":"crockpot","minutes_total":480,"difficulty":"easy"}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
for k, v := range map[string]any{
|
|
"name": "White Chicken Chili",
|
|
"method": "crockpot",
|
|
"minutes_total": float64(480),
|
|
"difficulty": "easy",
|
|
"status": "regular",
|
|
"updated_by": testSession.Subject,
|
|
} {
|
|
if body[k] != v {
|
|
t.Errorf("%s = %#v, want %#v", k, body[k], v)
|
|
}
|
|
}
|
|
var normalized string
|
|
if err := st.Reader.QueryRow(`SELECT normalized_name FROM dinners WHERE id = ?`, id).Scan(&normalized); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if normalized != "white chicken chili" {
|
|
t.Errorf("normalized_name = %q", normalized)
|
|
}
|
|
|
|
// A partial patch leaves the other fields.
|
|
rec, body = callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"difficulty":"medium"}`)
|
|
if rec.Code != http.StatusOK || body["method"] != "crockpot" || body["difficulty"] != "medium" || body["name"] != "White Chicken Chili" {
|
|
t.Errorf("partial patch = %d %v", rec.Code, body)
|
|
}
|
|
// Renaming to its own name, respelled, is not a duplicate of itself.
|
|
if rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"name":"Easy white chicken chili"}`); rec.Code != http.StatusOK {
|
|
t.Errorf("self rename = %d %v", rec.Code, body)
|
|
}
|
|
// Renaming onto another dinner is.
|
|
rec, body = callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"name":"The Best Pot Roast"}`)
|
|
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
|
|
t.Errorf("rename onto Pot Roast = %d %v, want 409", rec.Code, body)
|
|
}
|
|
}
|
|
|
|
func TestPatchDinnerValidation(t *testing.T) {
|
|
t.Parallel()
|
|
router, _ := newDinnersRouter(t)
|
|
id := createDinner(t, router, `{"name":"Chili"}`)
|
|
|
|
for _, tc := range []struct {
|
|
name, body, code, in string
|
|
}{
|
|
{"bad method", `{"method":"microwave"}`, "invalid_dinner", "method"},
|
|
{"crock pot spelling", `{"method":"crock pot"}`, "invalid_dinner", "method"},
|
|
{"bad difficulty", `{"difficulty":"expert"}`, "invalid_dinner", "difficulty"},
|
|
{"zero minutes", `{"minutes_total":0}`, "invalid_dinner", "minutes_total"},
|
|
{"negative minutes", `{"minutes_total":-5}`, "invalid_dinner", "minutes_total"},
|
|
{"too many minutes", `{"minutes_total":1441}`, "invalid_dinner", "minutes_total"},
|
|
{"empty name", `{"name":""}`, "invalid_dinner", "name"},
|
|
{"every problem", `{"method":"x","difficulty":"y","minutes_total":0}`, "invalid_dinner", "method: must be one of stovetop, oven, crockpot, grill, other; minutes_total"},
|
|
{"empty patch", `{}`, "invalid_dinner", "no fields"},
|
|
{"fractional minutes", `{"minutes_total":30.5}`, "invalid_request", "minutes_total"},
|
|
{"string minutes", `{"minutes_total":"30"}`, "invalid_request", "minutes_total"},
|
|
{"null method", `{"method":null}`, "invalid_request", "method"},
|
|
{"read-only field", `{"status":"archived"}`, "invalid_request", "status"},
|
|
{"favorite field", `{"is_favorite":true}`, "invalid_request", "is_favorite"},
|
|
} {
|
|
rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, tc.body)
|
|
msg, _ := body["error"].(map[string]any)["message"].(string)
|
|
if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code || !strings.Contains(msg, tc.in) {
|
|
t.Errorf("%s: %d %v, want 400 %s containing %q", tc.name, rec.Code, body, tc.code, tc.in)
|
|
}
|
|
}
|
|
|
|
_, body := callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
|
|
d := body["dinners"].([]any)[0].(map[string]any)
|
|
if d["method"] != nil || d["difficulty"] != nil || d["minutes_total"] != nil || d["name"] != "Chili" {
|
|
t.Errorf("a rejected patch changed the dinner: %v", d)
|
|
}
|
|
|
|
rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/nope", `{"method":"oven"}`)
|
|
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
|
t.Errorf("unknown id = %d %v, want 404", rec.Code, body)
|
|
}
|
|
}
|
|
|
|
func TestDeleteDinnerArchives(t *testing.T) {
|
|
t.Parallel()
|
|
router, st := newDinnersRouter(t)
|
|
id := createDinner(t, router, `{"name":"Shepherd's Pie"}`)
|
|
if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rec, body := callDinners(t, router, http.MethodDelete, dinnersPath+"/"+id, "")
|
|
if rec.Code != http.StatusOK || body["status"] != "archived" || body["updated_by"] != testSession.Subject {
|
|
t.Fatalf("DELETE = %d %v", rec.Code, body)
|
|
}
|
|
var status, name, updatedBy string
|
|
if err := st.Reader.QueryRow(`SELECT status, name, updated_by FROM dinners WHERE id = ?`, id).Scan(&status, &name, &updatedBy); err != nil {
|
|
t.Fatalf("archived row is gone: %v", err)
|
|
}
|
|
if status != "archived" || name != "Shepherd's Pie" || updatedBy != testSession.Subject {
|
|
t.Errorf("row = %s %q %s", status, name, updatedBy)
|
|
}
|
|
if got := listNames(t, router, "regular"); len(got) != 0 {
|
|
t.Errorf("regular after archive = %v", got)
|
|
}
|
|
// An archived name may be added again.
|
|
createDinner(t, router, `{"name":"Shepherds Pie"}`)
|
|
|
|
rec, body = callDinners(t, router, http.MethodDelete, dinnersPath+"/nope", "")
|
|
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
|
t.Errorf("unknown id = %d %v, want 404", rec.Code, body)
|
|
}
|
|
}
|
|
|
|
func TestFavoriteDinnerToggles(t *testing.T) {
|
|
t.Parallel()
|
|
router, st := newDinnersRouter(t)
|
|
id := createDinner(t, router, `{"name":"Fish Tacos"}`)
|
|
if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, want := range []bool{true, false, true} {
|
|
rec, body := callDinners(t, router, http.MethodPost, dinnersPath+"/"+id+"/favorite", "")
|
|
if rec.Code != http.StatusOK || body["is_favorite"] != want || body["updated_by"] != testSession.Subject {
|
|
t.Fatalf("favorite = %d %v, want is_favorite %v", rec.Code, body, want)
|
|
}
|
|
}
|
|
var fav int
|
|
if err := st.Reader.QueryRow(`SELECT is_favorite FROM dinners WHERE id = ?`, id).Scan(&fav); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fav != 1 {
|
|
t.Errorf("stored is_favorite = %d, want 1", fav)
|
|
}
|
|
|
|
rec, body := callDinners(t, router, http.MethodPost, dinnersPath+"/nope/favorite", "")
|
|
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
|
t.Errorf("unknown id = %d %v, want 404", rec.Code, body)
|
|
}
|
|
}
|
|
|
|
// TestDinnerWritesRequireCSRF sends every dinners write without the CSRF
|
|
// header and with a wrong one; each is refused and changes nothing.
|
|
func TestDinnerWritesRequireCSRF(t *testing.T) {
|
|
t.Parallel()
|
|
router, st := newDinnersRouter(t)
|
|
id := createDinner(t, router, `{"name":"Chili"}`)
|
|
|
|
var before string
|
|
snapshot := `SELECT group_concat(id || name || status || is_favorite || coalesce(method, '') || updated_at, '|') FROM dinners`
|
|
if err := st.Reader.QueryRow(snapshot).Scan(&before); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
writes := []struct{ method, target, body string }{
|
|
{http.MethodPost, dinnersPath, `{"name":"Lasagna"}`},
|
|
{http.MethodPatch, dinnersPath + "/" + id, `{"method":"oven"}`},
|
|
{http.MethodDelete, dinnersPath + "/" + id, ""},
|
|
{http.MethodPost, dinnersPath + "/" + id + "/favorite", ""},
|
|
}
|
|
for _, w := range writes {
|
|
for _, header := range []*string{nil, ptr("wrong-token")} {
|
|
req := httptest.NewRequest(w.method, w.target, strings.NewReader(w.body))
|
|
req.AddCookie(validSessionCookie)
|
|
if header != nil {
|
|
req.Header.Set(CSRFHeader, *header)
|
|
}
|
|
rec, body := serve(t, router, req)
|
|
if rec.Code != http.StatusForbidden || errorCode(body) != "csrf" {
|
|
t.Errorf("%s %s (header %v) = %d %v, want 403 csrf", w.method, w.target, header != nil, rec.Code, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
var after string
|
|
if err := st.Reader.QueryRow(snapshot).Scan(&after); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if after != before {
|
|
t.Errorf("refused writes changed dinners:\n before %s\n after %s", before, after)
|
|
}
|
|
|
|
// Without a session, the list is 401 too.
|
|
req := httptest.NewRequest(http.MethodGet, dinnersPath+"?status=regular", nil)
|
|
if rec, body := serve(t, router, req); rec.Code != http.StatusUnauthorized || errorCode(body) != "unauthenticated" {
|
|
t.Errorf("GET without a session = %d %v, want 401", rec.Code, body)
|
|
}
|
|
}
|
|
|
|
func ptr[T any](v T) *T { return &v }
|