Files
MealQuest/internal/server/plans_test.go
T

247 lines
9.8 KiB
Go
Raw Normal View History

2026-10-07 06:18:09 -04:00
package server
import (
"context"
"database/sql"
"errors"
"io"
"log/slog"
"net/http"
"testing"
"testing/fstest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"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/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const plansPath = "/api/v1/plans/"
// plansEnv is a router whose plan service has an unstarted job runner, so
// drafts stay queued, over a store holding a draft plan of the week of
// 2026-10-12: Beef Chili (crock pot, 240 minutes) on Monday and a Regular
// on every other day. questLog is the id of a Quest log dinner on no plan.
type plansEnv struct {
h http.Handler
st *store.Store
questLog string
}
func newPlansRouter(t *testing.T) plansEnv {
t.Helper()
st := newTestStore(t)
ctx := t.Context()
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no model in this test")
}
svc, err := plan.NewService(st, fail, runner, logger)
if err != nil {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), newTestSuggest(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
q := db.New(st.Writer)
now := store.Now().String()
dinner := func(name, status, method string, minutes int64) string {
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: status, Origin: "regular",
Method: sql.NullString{String: method, Valid: method != ""},
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
return d.ID
}
planID := store.NewID()
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: "2026-10-12",
AiNote: sql.NullString{String: "Mostly Regulars.", Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
for i, date := range []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"} {
id := dinner("Regular "+date, "regular", "stovetop", 30)
if i == 0 {
id = dinner("Beef Chili", "regular", "crockpot", 240)
}
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date,
DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
}
return plansEnv{h: h, st: st, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35)}
}
func (e plansEnv) setStatus(t *testing.T, status string) {
t.Helper()
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE week_plans SET status = ?`, status); err != nil {
t.Fatal(err)
}
}
func TestGetPlan(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
rec, body := callDinners(t, e.h, http.MethodGet, plansPath+"2026-10-12", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET plan = %d: %s", rec.Code, rec.Body)
}
if body["status"] != "draft" || body["week_start"] != "2026-10-12" || body["week_label"] != "Week 42 · Oct 12 – 18" ||
body["ai_note"] != "Mostly Regulars." || body["calendar_synced_at"] != nil {
t.Errorf("plan = %v", body)
}
days, _ := body["days"].([]any)
if len(days) != 7 {
t.Fatalf("days = %v, want 7", body["days"])
}
monday := days[0].(map[string]any)
if monday["date"] != "2026-10-12" || monday["is_new"] != false || monday["start_by"] != "2026-10-12T13:30:00-04:00" {
t.Errorf("Monday = %v", monday)
}
d := monday["dinner"].(map[string]any)
for _, key := range []string{"id", "name", "photo", "minutes", "method", "origin"} {
if _, ok := d[key]; !ok {
t.Errorf("dinner has no %s: %v", key, d)
}
}
if d["name"] != "Beef Chili" || d["minutes"] != 240.0 || d["method"] != "crockpot" || d["origin"] != "regular" || d["photo"] != nil {
t.Errorf("Monday dinner = %v", d)
}
if tuesday := days[1].(map[string]any); tuesday["start_by"] != nil {
t.Errorf("Tuesday start_by = %v, want null for a stovetop dinner", tuesday["start_by"])
}
rec, body = callDinners(t, e.h, http.MethodGet, plansPath+"2026-10-19", "")
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("GET week with no plan = %d %v, want 404 not_found", rec.Code, body)
}
}
func TestPlanWeekStartMustBeMonday(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
for _, tc := range []struct{ method, target, body string }{
{http.MethodGet, plansPath + "2026-10-13", ""},
{http.MethodGet, plansPath + "not-a-date", ""},
{http.MethodPost, plansPath + "2026-10-18/draft", "{}"},
{http.MethodPut, plansPath + "2026-10-14/days/2026-10-14", `{"dinner_id":"` + e.questLog + `"}`},
} {
rec, body := callDinners(t, e.h, tc.method, tc.target, tc.body)
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_week" {
t.Errorf("%s %s = %d %v, want 400 invalid_week", tc.method, tc.target, rec.Code, body)
}
}
}
func TestPostPlanDraft(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", "{}")
if rec.Code != http.StatusAccepted {
t.Fatalf("POST draft = %d: %s", rec.Code, rec.Body)
}
jobID, _ := body["job_id"].(string)
if jobID == "" {
t.Fatalf("POST draft = %v, want a job_id", body)
}
rec, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+jobID, "")
if rec.Code != http.StatusOK || job["kind"] != plan.KindDraftWeek || job["status"] != "queued" {
t.Errorf("GET job = %d %v, want a queued %s job", rec.Code, job, plan.KindDraftWeek)
}
// A second draft of the week joins the queued one.
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":false}`)
if rec.Code != http.StatusAccepted || body["job_id"] != jobID {
t.Errorf("second POST draft = %d %v, want 202 with job %s", rec.Code, body, jobID)
}
// A week with no plan needs no confirm.
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-19/draft", "{}")
if rec.Code != http.StatusAccepted || body["job_id"] == jobID {
t.Errorf("POST draft of a new week = %d %v, want 202 with its own job", rec.Code, body)
}
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":"yes"}`)
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" {
t.Errorf("POST draft with a bad confirm = %d %v, want 400", rec.Code, body)
}
}
func TestPostPlanDraftOverSyncedPlanNeedsConfirm(t *testing.T) {
t.Parallel()
for _, status := range []string{"synced", "changed"} {
e := newPlansRouter(t)
e.setStatus(t, status)
for _, b := range []string{"{}", `{"confirm":false}`} {
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", b)
if rec.Code != http.StatusConflict || errorCode(body) != "confirm_required" {
t.Errorf("%s plan: POST draft %s = %d %v, want 409 confirm_required", status, b, rec.Code, body)
}
}
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":true}`)
if rec.Code != http.StatusAccepted || body["job_id"] == nil {
t.Errorf("%s plan: POST draft with confirm = %d %v, want 202", status, rec.Code, body)
}
}
}
func TestPutPlanDay(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.setStatus(t, "synced")
rec, body := callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-14", `{"dinner_id":"`+e.questLog+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("PUT day = %d: %s", rec.Code, rec.Body)
}
if body["status"] != "changed" {
t.Errorf("status = %v, want a swapped synced plan changed", body["status"])
}
wednesday := body["days"].([]any)[2].(map[string]any)
if wednesday["date"] != "2026-10-14" || wednesday["is_new"] != true || wednesday["dinner"].(map[string]any)["name"] != "Pad Thai" {
t.Errorf("Wednesday = %v, want Pad Thai, new", wednesday)
}
for _, tc := range []struct {
target, body string
status int
code string
}{
{"2026-10-12/days/2026-10-14", `{}`, http.StatusBadRequest, "invalid_request"},
{"2026-10-12/days/2026-10-14", `{"dinner_id":7}`, http.StatusBadRequest, "invalid_request"},
{"2026-10-12/days/2026-10-14", `{"dinner_id":"x","extra":1}`, http.StatusBadRequest, "invalid_request"},
{"2026-10-12/days/2026-10-20", `{"dinner_id":"` + e.questLog + `"}`, http.StatusBadRequest, "invalid_date"},
{"2026-10-12/days/2026-10-14", `{"dinner_id":"no-such-dinner"}`, http.StatusNotFound, "not_found"},
{"2026-10-19/days/2026-10-20", `{"dinner_id":"` + e.questLog + `"}`, http.StatusNotFound, "not_found"},
} {
rec, body := callDinners(t, e.h, http.MethodPut, plansPath+tc.target, tc.body)
if rec.Code != tc.status || errorCode(body) != tc.code {
t.Errorf("PUT %s %s = %d %v, want %d %s", tc.target, tc.body, rec.Code, body, tc.status, tc.code)
}
}
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.questLog); err != nil {
t.Fatal(err)
}
rec, body = callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-15", `{"dinner_id":"`+e.questLog+`"}`)
if rec.Code != http.StatusConflict || errorCode(body) != "dinner_not_plannable" {
t.Errorf("PUT archived dinner = %d %v, want 409 dinner_not_plannable", rec.Code, body)
}
}