2026-10-06 21:28:23 -04:00
package server
import (
"bytes"
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"testing/fstest"
2026-10-07 05:53:22 -04:00
"time"
2026-10-06 21:28:23 -04:00
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
2026-10-07 05:53:22 -04:00
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
2026-10-06 21:28:23 -04:00
"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 )
2026-10-07 05:53:22 -04:00
// 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 )
}
2026-10-06 21:28:23 -04:00
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
2026-10-08 19:46:31 -04:00
// 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 )
}
2026-10-06 21:28:23 -04:00
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 ),
2026-10-08 19:46:31 -04:00
svc , newTestSuggest ( t ), newTestPlan ( t ), newTestShopping ( t ), newTestGoogle ( t ), runner , photos , fstest . MapFS { "index.html" : { Data : [] byte ( fixtureIndex )}})
2026-10-06 21:28:23 -04:00
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 )
}
2026-10-06 23:12:26 -04:00
// 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.
2026-10-06 21:28:23 -04:00
for _ , resp := range [] struct { name , body string }{{ "create" , created }, { "list" , listed }, { "duplicate" , rec . Body . String ()}} {
lower := strings . ToLower ( resp . body )
2026-10-06 23:12:26 -04:00
for _ , secret := range [] string { link , "c.example" , "recipes/stew" , "stew-key" , "abcdefghijklmno" } {
2026-10-06 21:28:23 -04:00
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 )
}
}
}
2026-10-07 05:53:22 -04:00
// 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 )
}
}
2026-10-06 21:28:23 -04:00
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 }