Implement the ideas_from_ingredients v1 skill on the AI harness: it asks
for 8 ideas {name, uses[], missing[], est_minutes, method, difficulty,
search_query}, keeps only "uses" entries whose harness.Normalize form is
one of the household's listed ingredients (written as the household wrote
them, in its order), drops ideas using none or repeating another, and
computes the summary line from the corrected lists.
Add internal/ideas: an ideas_from_ingredients job that grounds the ideas
with Grounder.GroundMany in the model's order until 5 are kept, drops the
ungroundable ones and, for quick_only, those whose grounded recipe is not
a Quick dinner (crockpot or minutes_total <= 30), publishing the result
as each idea is kept. POST /api/v1/ideas/from-ingredients answers 202
{jobId}.
Maestro task c3c5f98e-7f7d-4c6b-a1ab-d45bb8e497f9.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
545 lines
21 KiB
Go
545 lines
21 KiB
Go
package server
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"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/gcal"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
|
||
"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/shopping"
|
||
"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
|
||
shop *shopping.Service
|
||
runner *jobs.Runner
|
||
questLog string
|
||
// google is the fake Google the plan service pushes to; it starts
|
||
// unconnected.
|
||
google *gcaltest.Google
|
||
gcal *gcal.Service
|
||
// logs holds every log line the router writes.
|
||
logs *bytes.Buffer
|
||
}
|
||
|
||
func newPlansRouter(t *testing.T) plansEnv {
|
||
t.Helper()
|
||
return newPlansRouterTitled(t, untitled)
|
||
}
|
||
|
||
// newPlansRouterTitled is newPlansRouter whose Supply runs are titled by
|
||
// title.
|
||
func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||
t.Helper()
|
||
st := newTestStore(t)
|
||
ctx := t.Context()
|
||
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
logs := &bytes.Buffer{}
|
||
logger := slog.New(slog.NewJSONHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||
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")
|
||
}
|
||
pub := &url.URL{Scheme: "https", Host: "mealquest.example"}
|
||
google := gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath)
|
||
googleSvc, err := gcal.NewService(st, newTestBox(t), gcal.Config{
|
||
ClientID: gcaltest.ClientID,
|
||
ClientSecret: gcaltest.ClientSecret,
|
||
PublicURL: pub,
|
||
SecretKey: []byte("server-secret-key"),
|
||
Endpoint: google.Endpoint(),
|
||
RevokeURL: google.RevokeURL(),
|
||
CalendarAPI: google.CalendarAPI(),
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
svc, err := plan.NewService(st, fail, runner, googleSvc, pub, logger)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
shop, err := shopping.NewService(st, title, 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, shop, googleSvc, newTestIdeas(t), 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, shop: shop, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
|
||
google: google, gcal: googleSvc, logs: logs}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
// pushCalendar is the calendar the push tests choose.
|
||
var pushCalendar = gcaltest.Calendar{ID: "dinners@group.calendar.google.com", Summary: "Dinners", AccessRole: "owner"}
|
||
|
||
// connectCalendar connects the fake Google and, when choose, chooses
|
||
// pushCalendar.
|
||
func (e plansEnv) connectCalendar(t *testing.T, choose bool) {
|
||
t.Helper()
|
||
e.google.SetCalendars(0, pushCalendar)
|
||
e.google.Connect(t, e.gcal)
|
||
if !choose {
|
||
return
|
||
}
|
||
if _, err := e.gcal.SelectCalendar(t.Context(), pushCalendar.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// push calls POST /plans/{weekStart}/push-calendar and checks that neither
|
||
// the response nor any log line holds a secret.
|
||
func (e plansEnv) push(t *testing.T, weekStart string) (*httptest.ResponseRecorder, map[string]any) {
|
||
t.Helper()
|
||
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+weekStart+"/push-calendar", "")
|
||
for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} {
|
||
if strings.Contains(rec.Body.String(), secret) {
|
||
t.Errorf("response holds a secret: %s", rec.Body)
|
||
}
|
||
if strings.Contains(e.logs.String(), secret) {
|
||
t.Errorf("logs hold a secret: %s", e.logs)
|
||
}
|
||
}
|
||
return rec, body
|
||
}
|
||
|
||
func TestPushPlanToCalendar(t *testing.T) {
|
||
t.Parallel()
|
||
e := newPlansRouter(t)
|
||
e.connectCalendar(t, true)
|
||
|
||
rec, body := e.push(t, "2026-10-12")
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("push = %d: %s", rec.Code, rec.Body)
|
||
}
|
||
if body["status"] != "synced" || body["calendar_synced_at"] == nil {
|
||
t.Errorf("pushed plan = %v, want synced", body)
|
||
}
|
||
if days, _ := body["days"].([]any); len(days) != 7 {
|
||
t.Errorf("days = %v, want 7", body["days"])
|
||
}
|
||
if n := len(e.google.Events(pushCalendar.ID)); n != 7 {
|
||
t.Errorf("calendar has %d events, want 7", n)
|
||
}
|
||
monday := e.google.Events(pushCalendar.ID)[plan.EventID(mustLocalDate(t, "2026-10-12"), mustLocalDate(t, "2026-10-12"))]
|
||
if !strings.HasPrefix(monday.Description, "Beef Chili\nStart by 1:30 pm\nhttps://mealquest.example/dinner/") {
|
||
t.Errorf("Monday's description = %q", monday.Description)
|
||
}
|
||
}
|
||
|
||
func TestPushPlanToCalendarErrors(t *testing.T) {
|
||
t.Parallel()
|
||
for _, tc := range []struct {
|
||
name string
|
||
week string
|
||
setup func(t *testing.T, e plansEnv)
|
||
status int
|
||
code string
|
||
}{
|
||
{"not connected", "2026-10-12", func(*testing.T, plansEnv) {}, http.StatusConflict, "not_connected"},
|
||
{"no calendar chosen", "2026-10-12", func(t *testing.T, e plansEnv) { e.connectCalendar(t, false) }, http.StatusConflict, "no_calendar"},
|
||
{"calendar deleted", "2026-10-12", func(t *testing.T, e plansEnv) {
|
||
e.connectCalendar(t, true)
|
||
e.google.SetCalendars(0)
|
||
}, http.StatusConflict, "calendar_unavailable"},
|
||
{"calendar read-only", "2026-10-12", func(t *testing.T, e plansEnv) {
|
||
e.connectCalendar(t, true)
|
||
e.google.SetCalendars(0, gcaltest.Calendar{ID: pushCalendar.ID, Summary: "Dinners", AccessRole: "reader"})
|
||
}, http.StatusConflict, "calendar_unavailable"},
|
||
{"grant revoked", "2026-10-12", func(t *testing.T, e plansEnv) {
|
||
e.connectCalendar(t, true)
|
||
e.google.RevokeGrant()
|
||
}, http.StatusConflict, "google_reconnect_required"},
|
||
{"401 after a refresh", "2026-10-12", func(t *testing.T, e plansEnv) {
|
||
e.connectCalendar(t, true)
|
||
e.google.RejectAccessToken()
|
||
}, http.StatusConflict, "google_reconnect_required"},
|
||
{"Google unavailable", "2026-10-12", func(t *testing.T, e plansEnv) {
|
||
e.connectCalendar(t, true)
|
||
e.google.FailEventWrites(3, 3, http.StatusServiceUnavailable)
|
||
}, http.StatusServiceUnavailable, "google_unavailable"},
|
||
{"Google fails part way", "2026-10-12", func(t *testing.T, e plansEnv) {
|
||
e.connectCalendar(t, true)
|
||
e.google.FailEventWrite(3, http.StatusBadRequest)
|
||
}, http.StatusBadGateway, "google_failed"},
|
||
{"no plan", "2026-10-19", func(t *testing.T, e plansEnv) { e.connectCalendar(t, true) }, http.StatusNotFound, "not_found"},
|
||
{"not a Monday", "2026-10-13", func(t *testing.T, e plansEnv) { e.connectCalendar(t, true) }, http.StatusBadRequest, "invalid_week"},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
e := newPlansRouter(t)
|
||
tc.setup(t, e)
|
||
rec, body := e.push(t, tc.week)
|
||
if rec.Code != tc.status || errorCode(body) != tc.code {
|
||
t.Errorf("push = %d %s, want %d %s", rec.Code, rec.Body, tc.status, tc.code)
|
||
}
|
||
if apiErr, _ := body["error"].(map[string]any); apiErr["retryable"] != (tc.code == "google_unavailable") {
|
||
t.Errorf("push = %s, want retryable only when Google is unavailable", rec.Body)
|
||
}
|
||
if tc.code == "google_reconnect_required" {
|
||
_, status := callDinners(t, e.h, http.MethodGet, googlePath, "")
|
||
if status["status"] != "reconnect_required" || status["last_sync_error"] != gcal.ReconnectReason {
|
||
t.Errorf("GET %s after the push = %v, want reconnect_required with the reason", googlePath, status)
|
||
}
|
||
}
|
||
p, err := e.svc.Get(t.Context(), mustLocalDate(t, "2026-10-12"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if p.Status != plan.StatusDraft || p.CalendarSyncedAt != nil {
|
||
t.Errorf("plan = %s synced at %v after a failed push, want draft", p.Status, p.CalendarSyncedAt)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestPushPlanAfterRefusalWithNoCalendar pushes after Google refused the
|
||
// grant while listing calendars, before any was chosen: the push is 409
|
||
// google_reconnect_required, not no_calendar, since only connecting again
|
||
// restores it. After connecting again and choosing a calendar it succeeds.
|
||
func TestPushPlanAfterRefusalWithNoCalendar(t *testing.T) {
|
||
t.Parallel()
|
||
e := newPlansRouter(t)
|
||
e.connectCalendar(t, false)
|
||
e.google.RevokeGrant()
|
||
if _, err := e.gcal.Calendars(t.Context()); !errors.Is(err, gcal.ErrReconnect) {
|
||
t.Fatalf("Calendars = %v, want ErrReconnect", err)
|
||
}
|
||
|
||
rec, body := e.push(t, "2026-10-12")
|
||
if rec.Code != http.StatusConflict || errorCode(body) != "google_reconnect_required" {
|
||
t.Errorf("push = %d %s, want 409 google_reconnect_required", rec.Code, rec.Body)
|
||
}
|
||
|
||
e.connectCalendar(t, true)
|
||
if rec, _ := e.push(t, "2026-10-12"); rec.Code != http.StatusOK {
|
||
t.Errorf("push after connecting again = %d: %s", rec.Code, rec.Body)
|
||
}
|
||
}
|
||
|
||
func TestPushPlanChangedWhilePushing(t *testing.T) {
|
||
t.Parallel()
|
||
e := newPlansRouter(t)
|
||
e.connectCalendar(t, true)
|
||
arrived, release := e.google.HoldEvents()
|
||
defer release()
|
||
pushed := make(chan *httptest.ResponseRecorder, 1)
|
||
go func() {
|
||
rec := httptest.NewRecorder()
|
||
e.h.ServeHTTP(rec, signedIn(http.MethodPost, plansPath+"2026-10-12/push-calendar", nil))
|
||
pushed <- rec
|
||
}()
|
||
<-arrived
|
||
if _, err := e.svc.Swap(t.Context(), mustLocalDate(t, "2026-10-12"), "2026-10-13", e.questLog); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
release()
|
||
rec := <-pushed
|
||
if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), `"code":"plan_changed"`) {
|
||
t.Errorf("push = %d %s, want 409 plan_changed", rec.Code, rec.Body)
|
||
}
|
||
}
|
||
|
||
func mustLocalDate(t *testing.T, s string) store.LocalDate {
|
||
t.Helper()
|
||
d, err := store.ParseLocalDate(s)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return d
|
||
}
|