Dinners API for Regulars (#89)
CI / ci (push) Successful in 2m15s

Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #89.
This commit is contained in:
2026-10-06 15:57:13 -04:00
committed by approver_agent
parent 0c89eef577
commit 729c5bd0fe
22 changed files with 1347 additions and 34 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ const (
func newAIRouter(t *testing.T) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t),
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+148
View File
@@ -0,0 +1,148 @@
package server
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
)
// maxDinnerBody bounds a POST or PATCH /dinners request body.
const maxDinnerBody = 16 << 10
// dinnerCreate is the POST /dinners body: a name or a recipe_url.
type dinnerCreate struct {
Name *string `json:"name"`
RecipeURL *string `json:"recipe_url"`
}
// dinnerPatch is the PATCH /dinners/{id} body. Every field is optional;
// unknown fields and null values are refused.
type dinnerPatch struct {
Name *string `json:"name"`
Method *string `json:"method"`
MinutesTotal *int `json:"minutes_total"`
Difficulty *string `json:"difficulty"`
}
type dinnerList struct {
Dinners []dinners.Dinner `json:"dinners"`
}
// getDinners answers GET /dinners?status=regular|quest_log.
func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
list, err := svc.List(r.Context(), r.URL.Query().Get("status"))
if err != nil {
writeDinnerError(w, r, logger, "list dinners", err)
return
}
writeJSON(w, r, logger, http.StatusOK, dinnerList{Dinners: list})
}
}
// postDinner adds a Regular, recording the session's actor as updated_by.
func postDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
body, err := decodePatch[dinnerCreate](http.MaxBytesReader(w, r.Body, maxDinnerBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
d, err := svc.CreateRegular(r.Context(), dinners.NewRegular(body), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "create dinner", err)
return
}
writeJSON(w, r, logger, http.StatusCreated, d)
}
}
// patchDinner edits a dinner, recording the session's actor as updated_by.
func patchDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
patch, err := decodePatch[dinnerPatch](http.MaxBytesReader(w, r.Body, maxDinnerBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
d, err := svc.Update(r.Context(), chi.URLParam(r, "id"), dinners.Patch(patch), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "update dinner", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// deleteDinner archives a dinner; the row is never deleted.
func deleteDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
d, err := svc.Archive(r.Context(), chi.URLParam(r, "id"), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "archive dinner", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// postDinnerFavorite toggles a dinner's is_favorite.
func postDinnerFavorite(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
d, err := svc.ToggleFavorite(r.Context(), chi.URLParam(r, "id"), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "toggle dinner favorite", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// actorOrFail returns the session's actor, or answers 500 when the request
// has none, which behind requireSession is a wiring bug.
func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (auth.Actor, bool) {
actor, ok := auth.ActorFrom(r.Context())
if !ok {
logger.ErrorContext(r.Context(), r.Method+" "+r.URL.Path+" without a session")
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
return actor, ok
}
// writeDinnerError maps a dinners.Service error to its API error.
func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var invalid *dinners.ValidationError
var dup *dinners.DuplicateError
switch {
case errors.As(err, &invalid):
writeError(w, r, logger, http.StatusBadRequest, "invalid_dinner", invalid.Error())
case errors.As(err, &dup):
writeError(w, r, logger, http.StatusConflict, "duplicate_dinner", dup.Error())
case errors.Is(err, dinners.ErrNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
+464
View File
@@ -0,0 +1,464 @@
package server
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"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.
func newDinnersRouter(t *testing.T) (http.Handler, *store.Store) {
t.Helper()
st := newTestStore(t)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
dinners.NewService(st), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
return r, st
}
// 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,
"recipe_url": nil,
"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)
}
}
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",
"recipe_url": link,
"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 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)
}
}
}
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 }
+1 -1
View File
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
t.Fatal(err)
}
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestRunner(t),
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t),
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+1 -1
View File
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
}
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -22,7 +22,7 @@ const (
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestRunner(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+12 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
@@ -34,12 +35,13 @@ type RouteMounter interface {
// /media; settings serves /api/v1/settings/household; search serves
// /api/v1/settings/recipe-search and its /test; ai serves
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
// runner serves
// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE
// /api/v1/dinners/{id} and POST /api/v1/dinners/{id}/favorite; runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
// GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
@@ -49,6 +51,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
if ai == nil {
return nil, errors.New("server: nil AI settings service")
}
if dinnerSvc == nil {
return nil, errors.New("server: nil dinners service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
@@ -92,6 +97,11 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai))
apiV1.Get("/settings/ai/models", getAIModels(logger, ai))
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc))
apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc))
apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc))
apiV1.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
media.Get("/photos/{name}", getPhoto(logger, photos))
+22 -6
View File
@@ -17,6 +17,7 @@ import (
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"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"
@@ -43,7 +44,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
"index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
}
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), runner, newTestPhotos(t), assets)
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), runner, newTestPhotos(t), assets)
if err != nil {
t.Fatal(err)
}
@@ -139,6 +140,12 @@ func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
return search
}
// newTestDinners returns a dinners service over a migrated temp database.
func newTestDinners(t *testing.T) *dinners.Service {
t.Helper()
return dinners.NewService(newTestStore(t))
}
// newTestBox returns a secret box under a random key.
func newTestBox(t *testing.T) *secretbox.Box {
t.Helper()
@@ -170,7 +177,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil AI settings service")
}
}
@@ -179,7 +186,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil recipe search settings service")
}
}
@@ -188,7 +195,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted nil photo files")
}
}
@@ -197,16 +204,25 @@ func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
func TestNewRouterRequiresDinners(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil dinners service")
}
}
func TestNewRouterRequiresJobRunner(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner")
}
}
+1 -1
View File
@@ -110,7 +110,7 @@ func decodePatch[T any](body io.Reader) (T, error) {
if errors.As(err, &typeErr) {
return patch, fmt.Errorf("%s: want a JSON %s", typeErr.Field, typeErr.Type)
}
return patch, fmt.Errorf("invalid settings: %v", err)
return patch, fmt.Errorf("invalid request body: %v", err)
}
return patch, nil
}
+1 -1
View File
@@ -216,7 +216,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}