Feature 14: Weekly Plan Builder (#121)
CI / ci (push) Successful in 3m51s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #121.
This commit is contained in:
2026-10-07 16:53:23 -04:00
committed by approver_agent
parent 5291457214
commit 1862f450db
70 changed files with 9332 additions and 114 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ 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), newTestDinners(t), newTestSuggest(t),
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -92,7 +92,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
t.Fatal(err)
}
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
svc, newTestSuggest(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
svc, newTestSuggest(t), newTestPlan(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+5 -1
View File
@@ -30,7 +30,8 @@ type jobCreated struct {
}
// postJob serves POST /api/v1/jobs: it enqueues a job of a registered kind
// and returns 202 with its id, which GET /api/v1/jobs/{id} reports on.
// that is not service-only and returns 202 with its id, which
// GET /api/v1/jobs/{id} reports on.
func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
req, err := decodeJobRequest(http.MaxBytesReader(w, r.Body, maxJobBody))
@@ -43,6 +44,9 @@ func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
case errors.Is(err, jobs.ErrUnknownKind):
writeError(w, r, logger, http.StatusBadRequest, "unknown_kind", "unknown job kind")
return
case errors.Is(err, jobs.ErrServiceOnly):
writeError(w, r, logger, http.StatusBadRequest, "service_only_kind", "job kind is started only by its own endpoint")
return
case errors.Is(err, jobs.ErrInvalidPayload):
writeError(w, r, logger, http.StatusBadRequest, "invalid_payload", "payload does not match the job kind")
return
+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), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(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), newTestDinners(t), newTestSuggest(t), newTestRunner(t),
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(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), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+137
View File
@@ -0,0 +1,137 @@
package server
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// maxPlanBody bounds a POST /plans/{weekStart}/draft or PUT
// /plans/{weekStart}/days/{date} body.
const maxPlanBody = 4 << 10
// planDraft is the POST /plans/{weekStart}/draft body. confirm is needed
// to draft over a plan pushed to Calendar.
type planDraft struct {
Confirm *bool `json:"confirm"`
}
// planDaySwap is the PUT /plans/{weekStart}/days/{date} body.
type planDaySwap struct {
DinnerID *string `json:"dinner_id"`
}
// getPlan answers GET /plans/{weekStart} with the week's plan.
func getPlan(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
if !ok {
return
}
p, err := svc.Get(r.Context(), week)
if err != nil {
writePlanError(w, r, logger, "read plan", err)
return
}
writeJSON(w, r, logger, http.StatusOK, p)
}
}
// postPlanDraft enqueues the draft of the week and answers 202 with the
// jobId that GET /jobs/{id} reports on, without waiting for the job. When
// a draft of the week is already queued or running, it answers 202 with
// that job instead, or 409 when this draft is confirmed and that one is
// not.
func postPlanDraft(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
if !ok {
return
}
body, err := decodePatch[planDraft](http.MaxBytesReader(w, r.Body, maxPlanBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
d, err := svc.StartDraft(r.Context(), week, body.Confirm != nil && *body.Confirm)
if err != nil {
writePlanError(w, r, logger, "start plan draft", err)
return
}
writeJSON(w, r, logger, http.StatusAccepted, d)
}
}
// putPlanDay swaps the dinner of one day of the week's plan and answers
// with the plan as swapped.
func putPlanDay(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
if !ok {
return
}
body, err := decodePatch[planDaySwap](http.MaxBytesReader(w, r.Body, maxPlanBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if body.DinnerID == nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "dinner_id: required")
return
}
p, err := svc.Swap(r.Context(), week, chi.URLParam(r, "date"), *body.DinnerID)
if err != nil {
writePlanError(w, r, logger, "swap plan day", err)
return
}
writeJSON(w, r, logger, http.StatusOK, p)
}
}
// weekStartOrFail parses the {weekStart} path parameter, or answers 400
// when it is not a Monday date.
func weekStartOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (store.LocalDate, bool) {
week, err := plan.ParseWeekStart(chi.URLParam(r, "weekStart"))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_week", err.Error())
return store.LocalDate{}, false
}
return week, true
}
// writePlanError maps a plan.Service error to its API error: a week with
// no plan or an unknown dinner is 404, a date outside the week is 400, and
// drafting over a synced plan without confirm, a confirmed draft while an
// unconfirmed one runs, planning a dinner that is neither a Regular nor in
// the Quest log, or one already on another day of the week, is 409.
func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var day *plan.InvalidDayError
var week *plan.InvalidWeekError
var notPlannable *plan.DinnerNotPlannableError
switch {
case errors.As(err, &day):
writeError(w, r, logger, http.StatusBadRequest, "invalid_date", day.Error())
case errors.As(err, &week):
writeError(w, r, logger, http.StatusBadRequest, "invalid_week", week.Error())
case errors.As(err, &notPlannable):
writeError(w, r, logger, http.StatusConflict, "dinner_not_plannable", notPlannable.Error())
case errors.Is(err, plan.ErrDinnerAlreadyPlanned):
writeError(w, r, logger, http.StatusConflict, "dinner_already_planned", err.Error())
case errors.Is(err, plan.ErrConfirmRequired):
writeError(w, r, logger, http.StatusConflict, "confirm_required", err.Error())
case errors.Is(err, plan.ErrDraftRunning):
writeError(w, r, logger, http.StatusConflict, "draft_running", err.Error())
case errors.Is(err, plan.ErrNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "plan not found")
case errors.Is(err, plan.ErrDinnerNotFound):
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")
}
}
+326
View File
@@ -0,0 +1,326 @@
package server
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"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
svc *plan.Service
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, svc: svc, 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.MethodGet, plansPath + "2026-02-30", ""},
{http.MethodPost, plansPath + "2027-02-29/draft", "{}"},
{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["jobId"].(string)
if jobID == "" || len(body) != 1 {
t.Fatalf("POST draft = %v, want only a jobId", 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["jobId"] != 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["jobId"] == jobID {
t.Errorf("POST draft of a new week = %d %v, want 202 with its own job", rec.Code, body)
}
// A confirmed draft does not join the unconfirmed one and enqueues
// nothing.
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":true}`)
if rec.Code != http.StatusConflict || errorCode(body) != "draft_running" {
t.Errorf("confirmed POST draft = %d %v, want 409 draft_running", rec.Code, body)
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 2 {
t.Errorf("%d draft jobs, want one per week", queued)
}
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)
}
}
// TestDraftWeekNotEnqueuedThroughJobs checks POST /api/v1/jobs refuses a
// draft_week job, so a confirmed draft cannot run beside the week's
// unconfirmed one: POST /plans/{weekStart}/draft is the only way to start
// a draft.
func TestDraftWeekNotEnqueuedThroughJobs(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
rec, _ := 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)
}
rec, body := callDinners(t, e.h, http.MethodPost, "/api/v1/jobs",
`{"kind":"`+plan.KindDraftWeek+`","payload":{"week_start":"2026-10-12","confirm":true}}`)
if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" {
t.Errorf("POST job %s = %d %v, want 400 service_only_kind", plan.KindDraftWeek, rec.Code, body)
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 1 {
t.Errorf("%d draft jobs, want only the one POST draft enqueued", queued)
}
}
func TestSimultaneousPostPlanDraftsShareOneJob(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
const n = 10
recs := make([]*httptest.ResponseRecorder, n)
var wg sync.WaitGroup
for i := range n {
wg.Go(func() {
recs[i] = httptest.NewRecorder()
e.h.ServeHTTP(recs[i], signedIn(http.MethodPost, plansPath+"2026-10-12/draft", strings.NewReader("{}")))
})
}
wg.Wait()
jobIDs := map[any]int{}
for i, rec := range recs {
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || rec.Code != http.StatusAccepted {
t.Fatalf("POST draft %d = %d %s, want 202", i, rec.Code, rec.Body)
}
jobIDs[body["jobId"]]++
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
t.Fatal(err)
}
if len(jobIDs) != 1 || jobIDs[nil] != 0 || queued != 1 {
t.Errorf("jobIds = %v, %d draft jobs; want one jobId for all %d drafts", jobIDs, queued, n)
}
}
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["jobId"] == 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-32", `{"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"},
{"2026-10-12/days/2026-10-15", `{"dinner_id":"` + e.questLog + `"}`, http.StatusConflict, "dinner_already_planned"},
} {
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)
}
}
+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), newTestDinners(t), newTestSuggest(t), newTestRunner(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+87
View File
@@ -0,0 +1,87 @@
package server
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
)
// maxRotationReviewBody bounds a POST /dinners/{id}/rotation-review body.
const maxRotationReviewBody = 1 << 10
// rotationDecision is the POST /dinners/{id}/rotation-review body:
// keep, drop ("Not for us") or not_made ("We didn't make it").
type rotationDecision struct {
Decision *string `json:"decision"`
}
type rotationReviewList struct {
Reviews []plan.Review `json:"reviews"`
}
// getRotationReviews answers GET /rotation-reviews with the pending
// Rotation checks, oldest plan date first.
func getRotationReviews(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
list, err := svc.PendingReviews(r.Context())
if err != nil {
writeRotationError(w, r, logger, "list Rotation checks", err)
return
}
writeJSON(w, r, logger, http.StatusOK, rotationReviewList{Reviews: list})
}
}
// postRotationReview applies the household's answer to "Keep it in
// rotation?" for a tried dinner, recording the session's actor as
// updated_by, and answers with the Rotation check as decided.
func postRotationReview(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
body, err := decodePatch[rotationDecision](http.MaxBytesReader(w, r.Body, maxRotationReviewBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if body.Decision == nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "decision: required")
return
}
d, err := svc.DecideReview(r.Context(), chi.URLParam(r, "id"), *body.Decision, actor.Subject)
if err != nil {
writeRotationError(w, r, logger, "decide Rotation check", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// writeRotationError maps a Rotation check error to its API error: a
// decision other than keep, drop or not_made is 400, an unknown dinner or
// a dinner never checked 404, and a dinner whose checks are all decided
// (such as a second decision) or no longer tried 409.
func writeRotationError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var invalid *plan.InvalidDecisionError
switch {
case errors.As(err, &invalid):
writeError(w, r, logger, http.StatusBadRequest, "invalid_decision", invalid.Error())
case errors.Is(err, plan.ErrDinnerNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
case errors.Is(err, plan.ErrReviewNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "rotation check not found")
case errors.Is(err, plan.ErrNoPendingReview):
writeError(w, r, logger, http.StatusConflict, "no_pending_review", err.Error())
case errors.Is(err, plan.ErrDinnerNotTried):
writeError(w, r, logger, http.StatusConflict, "dinner_not_tried", err.Error())
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
+147
View File
@@ -0,0 +1,147 @@
package server
import (
"net/http"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const rotationReviewsPath = "/api/v1/rotation-reviews"
// triedPadThai plans the Quest log dinner Pad Thai on Wednesday 2026-10-14
// and marks the day tried, so Pad Thai has a pending Rotation check.
func triedPadThai(t *testing.T, e plansEnv) {
t.Helper()
rec, _ := 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("swap Pad Thai in = %d: %s", rec.Code, rec.Body)
}
day, err := store.ParseLocalDate("2026-10-14")
if err != nil {
t.Fatal(err)
}
if n, err := e.svc.MarkTried(t.Context(), day); err != nil || n != 1 {
t.Fatalf("MarkTried = %d, %v, want 1", n, err)
}
}
func TestGetRotationReviews(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
rec, body := callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
}
if reviews, ok := body["reviews"].([]any); !ok || len(reviews) != 0 {
t.Errorf("GET before MarkTried = %v, want an empty reviews list", body)
}
triedPadThai(t, e)
rec, body = callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
}
reviews, _ := body["reviews"].([]any)
if len(reviews) != 1 {
t.Fatalf("reviews = %v, want one", body)
}
r := reviews[0].(map[string]any)
d, _ := r["dinner"].(map[string]any)
if len(r) != 2 || r["plan_date"] != "2026-10-14" || len(d) != 3 ||
d["id"] != e.questLog || d["name"] != "Pad Thai" || d["photo"] != nil {
t.Errorf("review = %v, want {dinner {id, name, photo}, plan_date}", r)
}
}
func TestPostRotationReview(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ decision, status string }{
{"keep", "regular"},
{"drop", "archived"},
{"not_made", "quest_log"},
} {
t.Run(tc.decision, func(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
triedPadThai(t, e)
target := dinnersPath + "/" + e.questLog + "/rotation-review"
rec, body := callDinners(t, e.h, http.MethodPost, target, `{"decision":"`+tc.decision+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
if body["decision"] != tc.decision || body["dinner_status"] != tc.status || body["plan_date"] != "2026-10-14" ||
body["updated_by"] != testSession.Subject || body["decided_at"] == "" {
t.Errorf("decided = %v", body)
}
var status, origin, updatedBy string
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status, origin, updated_by FROM dinners WHERE id = ?`, e.questLog).
Scan(&status, &origin, &updatedBy); err != nil {
t.Fatal(err)
}
if status != tc.status || origin != "regular" || updatedBy != testSession.Subject {
t.Errorf("dinner = %s %s %s, want %s regular %s", status, origin, updatedBy, tc.status, testSession.Subject)
}
rec, body = callDinners(t, e.h, http.MethodPost, target, `{"decision":"keep"}`)
if rec.Code != http.StatusConflict || errorCode(body) != "no_pending_review" {
t.Errorf("second decision = %d %v, want 409 no_pending_review", rec.Code, body)
}
rec, body = callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "")
if reviews, _ := body["reviews"].([]any); rec.Code != http.StatusOK || len(reviews) != 0 {
t.Errorf("GET after deciding = %d %v, want no reviews", rec.Code, body)
}
})
}
}
// A dinner that has never had a Rotation check is 404; a second decision
// on one already decided is 409 (TestPostRotationReview).
func TestPostRotationReviewNeverChecked(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+e.questLog+"/rotation-review", `{"decision":"keep"}`)
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("never checked = %d %v, want 404 not_found", rec.Code, body)
}
var status string
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status FROM dinners WHERE id = ?`, e.questLog).Scan(&status); err != nil {
t.Fatal(err)
}
if status != "quest_log" {
t.Errorf("status after a refused decision = %s, want quest_log", status)
}
}
func TestPostRotationReviewRefused(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
triedPadThai(t, e)
target := dinnersPath + "/" + e.questLog + "/rotation-review"
for _, tc := range []struct {
name, target, body string
code int
errCode string
}{
{"no decision", target, `{}`, http.StatusBadRequest, "invalid_request"},
{"null decision", target, `{"decision":null}`, http.StatusBadRequest, "invalid_request"},
{"unknown field", target, `{"decision":"keep","status":"regular"}`, http.StatusBadRequest, "invalid_request"},
{"unknown decision", target, `{"decision":"promote"}`, http.StatusBadRequest, "invalid_decision"},
{"unknown dinner", dinnersPath + "/nope/rotation-review", `{"decision":"keep"}`, http.StatusNotFound, "not_found"},
} {
rec, body := callDinners(t, e.h, http.MethodPost, tc.target, tc.body)
if rec.Code != tc.code || errorCode(body) != tc.errCode {
t.Errorf("%s = %d %v, want %d %s", tc.name, rec.Code, body, tc.code, tc.errCode)
}
}
var status string
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status FROM dinners WHERE id = ?`, e.questLog).Scan(&status); err != nil {
t.Fatal(err)
}
if status != "tried" {
t.Errorf("status after refused decisions = %s, want tried", status)
}
}
+14 -2
View File
@@ -16,6 +16,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"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/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
)
@@ -41,12 +42,15 @@ type RouteMounter interface {
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
// suggestSvc serves POST /api/v1/suggestions/batches, GET
// /api/v1/suggestions/deck and POST /api/v1/suggestions/{id}/accept and
// /decline; runner serves
// /decline; planSvc serves GET /api/v1/plans/{weekStart}, POST
// /api/v1/plans/{weekStart}/draft, PUT
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
// POST /api/v1/dinners/{id}/rotation-review; 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, dinnerSvc *dinners.Service, suggestSvc *suggest.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, suggestSvc *suggest.Service, planSvc *plan.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
@@ -62,6 +66,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
if suggestSvc == nil {
return nil, errors.New("server: nil suggestions service")
}
if planSvc == nil {
return nil, errors.New("server: nil plan service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
@@ -116,6 +123,11 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Get("/suggestions/deck", getSuggestionDeck(logger, suggestSvc))
apiV1.Post("/suggestions/{id}/accept", postSuggestionDecision(logger, suggestSvc, true))
apiV1.Post("/suggestions/{id}/decline", postSuggestionDecision(logger, suggestSvc, false))
apiV1.Get("/plans/{weekStart}", getPlan(logger, planSvc))
apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc))
apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc))
apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc))
apiV1.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
media.Get("/photos/{name}", getPhoto(logger, photos))
+38 -8
View File
@@ -23,6 +23,7 @@ import (
"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/plan"
"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/secretbox"
@@ -49,7 +50,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), newTestDinners(t), newTestSuggest(t), runner, newTestPhotos(t), assets)
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), runner, newTestPhotos(t), assets)
if err != nil {
t.Fatal(err)
}
@@ -185,6 +186,26 @@ func newTestSuggest(t *testing.T) *suggest.Service {
return svc
}
// newTestPlan returns a plan service over a migrated temp database, with
// its own unstarted job runner and a drafter that always fails.
func newTestPlan(t *testing.T) *plan.Service {
t.Helper()
st := newTestStore(t)
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)
}
return svc
}
// newTestBox returns a secret box under a random key.
func newTestBox(t *testing.T) *secretbox.Box {
t.Helper()
@@ -216,7 +237,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, newTestDinners(t), newTestSuggest(t), 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), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil AI settings service")
}
}
@@ -225,7 +246,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), newTestDinners(t), newTestSuggest(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), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil recipe search settings service")
}
}
@@ -234,7 +255,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), newTestDinners(t), newTestSuggest(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), newTestSuggest(t), newTestPlan(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted nil photo files")
}
}
@@ -243,7 +264,7 @@ 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), newTestDinners(t), newTestSuggest(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), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
@@ -252,7 +273,7 @@ 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, newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil dinners service")
}
}
@@ -261,16 +282,25 @@ func TestNewRouterRequiresSuggestions(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), newTestDinners(t), nil, newTestRunner(t), 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, newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil suggestions service")
}
}
func TestNewRouterRequiresPlan(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), newTestDinners(t), newTestSuggest(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil plan 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), newTestDinners(t), newTestSuggest(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), newTestSuggest(t), newTestPlan(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner")
}
}
+1 -1
View File
@@ -277,7 +277,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), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestDinners(t), svc, newTestPlan(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}