A pasted recipe link may carry a personal token in its query, and the dinners API returned it from create, list, get, patch and ground, where other household clients and the PWA's cache of dinner reads could keep it. Drop recipe_url from the public Dinner shape so it stays in the database for grounding and dedupe only. TestCreateDinnerByURL and TestCreateDinnerByName now assert the field is absent; TestDinnerResponsesHideRecipeURL creates a Regular from a token-bearing link and checks neither the create nor the list response holds the link, host or token while the stored link remains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
581 lines
22 KiB
Go
581 lines
22 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"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/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)
|
|
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
|
|
g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model)
|
|
svc, err := dinners.NewService(st, g, runner, logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
|
svc, runner, newTestPhotos(t), 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, `{"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": "Beef and broccoli stir fry",
|
|
"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, `{"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", `{"recipe_url":"http://example.com/recipes/chili"}`},
|
|
{"not a URL", `{"recipe_url":"chili"}`},
|
|
{"credentials", `{"recipe_url":"https://me:pw@example.com/recipes/chili"}`},
|
|
{"no name in path", `{"recipe_url":"https://example.com/"}`},
|
|
{"numeric slug", `{"recipe_url":"https://example.com/recipe/98765"}`},
|
|
{"both fields", `{"name":"Chili","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, `{"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)
|
|
}
|
|
}
|
|
|
|
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 slug naming the same dish is a duplicate too.
|
|
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"https://example.com/easy-chicken-tikka-masala.html"}`)
|
|
if rec.Code != http.StatusConflict {
|
|
t.Errorf("URL slug duplicate = %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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 }
|