diff --git a/cmd/server/main.go b/cmd/server/main.go index 51b105b..8379e03 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -28,6 +28,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" @@ -132,6 +133,11 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } + shopSvc, err := shopping.NewService(db) + if err != nil { + return err + } + sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) if err != nil { return err @@ -148,7 +154,7 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, googleSvc, runner, photos, frontend.Assets()) + router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, runner, photos, frontend.Assets()) if err != nil { return err } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c519360..91af226 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -738,9 +738,38 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. failures), 404 when the week has no plan, 400 `invalid_week`. The push holds the account lock across its Google requests and its record, like listing and choosing. - - The week's Supply run (Feature 17) is made from a pushed plan: a - successful `Push` returns that plan, which is where Feature 17 hooks - in. Nothing here generates a shopping list. + - The week's Supply run (Feature 17) is made from a pushed plan: after + a successful `Push`, the handler runs `shopping.GeneratePushed` for + the week at the plan revision `Push` pushed, and the 200 answer + carries `supply_run: {status, error}`. `status` is `generated`; + `archived` when the week's Supply run is in the Supply archive and was + left as it was; or `failed` โ€” error `plan_changed` (retryable, logged + as a warning) when the plan was edited between the push and + generation, so no Supply run is made from a plan Calendar does not + show and pushing again makes it; or error `supply_run_failed` + (retryable, logged as an error) when it could not be made, and + `POST /api/v1/shopping/{weekStart}/generate` makes it again. + Generation checks the revision inside its own transaction, and never + fails or undoes the push โ€” Calendar already shows the plan. + +## Supply run API (Feature 17) + +- `GET /api/v1/shopping/{weekStart}` returns `{weekStart, label, title, + status, sent_to_chat_at, needed_count, total_count, groups: [{dinner: + {id, name}, date, day, items: [{id, name, qty, needed}]}]}`: `label` is + the week label (shown while `title` is null), `day` the plan day's + weekday ("Monday"), `qty` null when the recipe gives none, + `needed_count` the items still needed and `total_count` all items. 404 + when the week has no Supply run, 400 `invalid_week`. +- `POST /api/v1/shopping/{weekStart}/generate` makes or remakes the list + through `shopping.Generate` and answers with the same shape plus + `skipped: [{date, dinner_id, dinner_name, reason}]`. 404 when the week + has no plan, 409 `archived` when its list is in the Supply archive. +- `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required + boolean; it sets the flag, it does not flip it) and answers `{id, + needed, needed_count, total_count}` with the list's counts after the + change. 404 for an unknown item, 409 `archived` when the list is in the + Supply archive, 400 `invalid_request` for a bad body. ## Frontend diff --git a/internal/db/querier.go b/internal/db/querier.go index c105f5b..f4dd2e5 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -120,6 +120,9 @@ type Querier interface { GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error) // Returns sql.ErrNoRows until the row exists. GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error) + // The Supply run holding item id. Returns sql.ErrNoRows when there is no + // such item. + GetShoppingItemList(ctx context.Context, id string) (GetShoppingItemListRow, error) // The Supply run of the week starting week_start. Returns sql.ErrNoRows // when there is none. GetShoppingList(ctx context.Context, weekStart string) (ShoppingList, error) @@ -174,6 +177,8 @@ type Querier interface { ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error) ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error) ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error) + // The groups of list list_id with their dinner's name, in list order. + ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error) // The groups of list list_id, in list order. ListShoppingGroups(ctx context.Context, listID string) ([]ShoppingGroup, error) // The items of list list_id with their group's dinner, in list order: by @@ -236,6 +241,8 @@ type Querier interface { // Records that the event calendar_event_id on Calendar shows date of // plan_id as planned. SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error) + // Marks item id needed (1) or "have it" (0). + SetShoppingItemNeeded(ctx context.Context, arg SetShoppingItemNeededParams) error // Marks a pending batch running; a batch in any other status is left as // it is (0 rows). StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error) diff --git a/internal/db/shopping.sql.go b/internal/db/shopping.sql.go index ccb9046..3044019 100644 --- a/internal/db/shopping.sql.go +++ b/internal/db/shopping.sql.go @@ -112,6 +112,37 @@ func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error { return err } +const getShoppingItemList = `-- name: GetShoppingItemList :one +SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at +FROM shopping_items +JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id +JOIN shopping_lists ON shopping_lists.id = shopping_groups.list_id +WHERE shopping_items.id = ? +` + +type GetShoppingItemListRow struct { + ShoppingList ShoppingList +} + +// The Supply run holding item id. Returns sql.ErrNoRows when there is no +// such item. +func (q *Queries) GetShoppingItemList(ctx context.Context, id string) (GetShoppingItemListRow, error) { + row := q.db.QueryRowContext(ctx, getShoppingItemList, id) + var i GetShoppingItemListRow + err := row.Scan( + &i.ShoppingList.ID, + &i.ShoppingList.WeekStart, + &i.ShoppingList.PlanID, + &i.ShoppingList.Title, + &i.ShoppingList.Status, + &i.ShoppingList.SentToChatAt, + &i.ShoppingList.FrozenAt, + &i.ShoppingList.CreatedAt, + &i.ShoppingList.UpdatedAt, + ) + return i, err +} + const getShoppingList = `-- name: GetShoppingList :one SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists WHERE week_start = ? @@ -136,6 +167,52 @@ func (q *Queries) GetShoppingList(ctx context.Context, weekStart string) (Shoppi return i, err } +const listShoppingGroupDinners = `-- name: ListShoppingGroupDinners :many +SELECT shopping_groups.id, shopping_groups.list_id, shopping_groups.dinner_id, shopping_groups.date, shopping_groups.position, shopping_groups.created_at, shopping_groups.updated_at, dinners.name AS dinner_name +FROM shopping_groups +JOIN dinners ON dinners.id = shopping_groups.dinner_id +WHERE shopping_groups.list_id = ? +ORDER BY shopping_groups.position, shopping_groups.id +` + +type ListShoppingGroupDinnersRow struct { + ShoppingGroup ShoppingGroup + DinnerName string +} + +// The groups of list list_id with their dinner's name, in list order. +func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error) { + rows, err := q.db.QueryContext(ctx, listShoppingGroupDinners, listID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListShoppingGroupDinnersRow + for rows.Next() { + var i ListShoppingGroupDinnersRow + if err := rows.Scan( + &i.ShoppingGroup.ID, + &i.ShoppingGroup.ListID, + &i.ShoppingGroup.DinnerID, + &i.ShoppingGroup.Date, + &i.ShoppingGroup.Position, + &i.ShoppingGroup.CreatedAt, + &i.ShoppingGroup.UpdatedAt, + &i.DinnerName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listShoppingGroups = `-- name: ListShoppingGroups :many SELECT id, list_id, dinner_id, date, position, created_at, updated_at FROM shopping_groups WHERE list_id = ? @@ -283,3 +360,21 @@ func (q *Queries) RegenerateShoppingList(ctx context.Context, arg RegenerateShop _, err := q.db.ExecContext(ctx, regenerateShoppingList, arg.PlanID, arg.UpdatedAt, arg.ID) return err } + +const setShoppingItemNeeded = `-- name: SetShoppingItemNeeded :exec +UPDATE shopping_items +SET needed = ?, updated_at = ? +WHERE id = ? +` + +type SetShoppingItemNeededParams struct { + Needed int64 + UpdatedAt string + ID string +} + +// Marks item id needed (1) or "have it" (0). +func (q *Queries) SetShoppingItemNeeded(ctx context.Context, arg SetShoppingItemNeededParams) error { + _, err := q.db.ExecContext(ctx, setShoppingItemNeeded, arg.Needed, arg.UpdatedAt, arg.ID) + return err +} diff --git a/internal/plan/push.go b/internal/plan/push.go index e002397..5758a4c 100644 --- a/internal/plan/push.go +++ b/internal/plan/push.go @@ -84,20 +84,20 @@ type pushDay struct { // // A successful Push is the household confirming the week's plan, which is // what the week's Supply run (Feature 17) is made from; the returned plan -// is the one pushed. -func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (Plan, error) { +// is the one pushed, and revision is its revision as pushed, so the Supply +// run can be made from that plan and no later edit of it. +func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (p Plan, revision int64, err error) { if _, err := household.WeekDays(weekStart); err != nil { - return Plan{}, &InvalidWeekError{Value: weekStart.String()} + return Plan{}, 0, &InvalidWeekError{Value: weekStart.String()} } planID, revision, days, err := s.pushDays(ctx, weekStart) if err != nil { - return Plan{}, err + return Plan{}, 0, err } events := make([]gcal.Event, len(days)) for i, d := range days { events[i] = d.event } - var p Plan record := func() error { return s.st.WithTx(ctx, func(tx *sql.Tx) error { q := db.New(tx) @@ -133,9 +133,9 @@ func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (Plan, er }) } if err := s.cal.PushEvents(ctx, events, record); err != nil { - return Plan{}, fmt.Errorf("plan: push %s to Calendar: %w", weekStart, err) + return Plan{}, 0, fmt.Errorf("plan: push %s to Calendar: %w", weekStart, err) } - return p, nil + return p, revision, nil } // pushDays reads, in one read transaction, the plan of the week starting diff --git a/internal/plan/push_test.go b/internal/plan/push_test.go index ac570a4..1a9d92f 100644 --- a/internal/plan/push_test.go +++ b/internal/plan/push_test.go @@ -135,7 +135,7 @@ func TestPushCreatesOneEventPerDay(t *testing.T) { e.connectCalendar(t) dates := e.storePlan(t, week, fullWeek...) - p, err := e.svc.Push(t.Context(), week) + p, _, err := e.svc.Push(t.Context(), week) if err != nil { t.Fatalf("Push: %v", err) } @@ -197,7 +197,7 @@ func TestPushAgainUpdatesOnly(t *testing.T) { e := newEnv(t) e.connectCalendar(t) e.storePlan(t, week, fullWeek...) - if _, err := e.svc.Push(t.Context(), week); err != nil { + if _, _, err := e.svc.Push(t.Context(), week); err != nil { t.Fatal(err) } // The household deletes Monday's event in Google Calendar; Google keeps @@ -206,7 +206,7 @@ func TestPushAgainUpdatesOnly(t *testing.T) { e.google.CancelEvent(dinnersCal.ID, monday) e.google.ResetEventWrites() - p, err := e.svc.Push(t.Context(), week) + p, _, err := e.svc.Push(t.Context(), week) if err != nil { t.Fatalf("second Push: %v", err) } @@ -229,7 +229,7 @@ func TestSwapThenPushUpdatesThatDay(t *testing.T) { e := newEnv(t) e.connectCalendar(t) e.storePlan(t, week, fullWeek...) - if _, err := e.svc.Push(t.Context(), week); err != nil { + if _, _, err := e.svc.Push(t.Context(), week); err != nil { t.Fatal(err) } before := e.google.Events(dinnersCal.ID) @@ -247,7 +247,7 @@ func TestSwapThenPushUpdatesThatDay(t *testing.T) { t.Errorf("swapped day kept event id %q", id) } - p, err := e.svc.Push(t.Context(), week) + p, _, err := e.svc.Push(t.Context(), week) if err != nil { t.Fatalf("Push: %v", err) } @@ -296,7 +296,7 @@ func TestPushAcrossDaylightSavingTime(t *testing.T) { e := newEnv(t) e.connectCalendar(t) dates := e.storePlan(t, tc.week, fullWeek...) - if _, err := e.svc.Push(t.Context(), tc.week); err != nil { + if _, _, err := e.svc.Push(t.Context(), tc.week); err != nil { t.Fatalf("Push: %v", err) } events := e.google.Events(dinnersCal.ID) @@ -330,12 +330,12 @@ func TestPushAcrossDaylightSavingTime(t *testing.T) { func TestPushNeedsAChosenCalendar(t *testing.T) { e := newEnv(t) e.storePlan(t, week, fullWeek...) - if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNotConnected) { + if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNotConnected) { t.Errorf("Push with nothing connected = %v, want ErrNotConnected", err) } e.google.SetCalendars(0, dinnersCal) e.google.Connect(t, e.gcal) - if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) { + if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) { t.Errorf("Push with no calendar chosen = %v, want ErrNoCalendar", err) } if inserts, updates := e.google.EventWrites(); inserts+updates != 0 { @@ -348,7 +348,7 @@ func TestPushWithoutAPlan(t *testing.T) { e := newEnv(t) e.connectCalendar(t) before := e.google.TokenCalls() - if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, plan.ErrNotFound) { + if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, plan.ErrNotFound) { t.Errorf("Push = %v, want ErrNotFound", err) } if n := e.google.TokenCalls() - before; n != 0 { @@ -363,7 +363,7 @@ func TestPushPartialFailureLeavesPlanUnsynced(t *testing.T) { // Google fails the fourth event on every try. e.google.FailEventWrites(4, 3, http.StatusServiceUnavailable) - _, err := e.svc.Push(t.Context(), week) + _, _, err := e.svc.Push(t.Context(), week) var calendar *gcal.CalendarError if !errors.As(err, &calendar) || !calendar.Retryable() { t.Fatalf("Push = %v, want a retryable *gcal.CalendarError", err) @@ -377,7 +377,7 @@ func TestPushPartialFailureLeavesPlanUnsynced(t *testing.T) { // Pushing again writes every day: the three already there are updated, // not added twice. e.google.ResetEventWrites() - p, err := e.svc.Push(t.Context(), week) + p, _, err := e.svc.Push(t.Context(), week) if err != nil { t.Fatalf("retried Push: %v", err) } @@ -393,7 +393,7 @@ func TestPushPartialFailureKeepsAChangedPlanChanged(t *testing.T) { e := newEnv(t) e.connectCalendar(t) e.storePlan(t, week, fullWeek...) - first, err := e.svc.Push(t.Context(), week) + first, _, err := e.svc.Push(t.Context(), week) if err != nil { t.Fatal(err) } @@ -403,7 +403,7 @@ func TestPushPartialFailureKeepsAChangedPlanChanged(t *testing.T) { } } e.google.FailEventWrite(2, http.StatusBadRequest) - if _, err := e.svc.Push(t.Context(), week); err == nil { + if _, _, err := e.svc.Push(t.Context(), week); err == nil { t.Fatal("Push succeeded, want the second event to fail") } p, err := e.svc.Get(t.Context(), week) @@ -431,7 +431,7 @@ func TestPushToStaleCalendar(t *testing.T) { e.connectCalendar(t) e.storePlan(t, week, fullWeek...) edit(e.google) - if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrCalendarUnavailable) { + if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrCalendarUnavailable) { t.Errorf("Push = %v, want ErrCalendarUnavailable", err) } e.assertUnsynced(t, week, plan.StatusDraft) @@ -444,7 +444,7 @@ func TestPushGrantNoLongerAccepted(t *testing.T) { e.connectCalendar(t) e.storePlan(t, week, fullWeek...) e.google.RevokeGrant() - _, err := e.svc.Push(t.Context(), week) + _, _, err := e.svc.Push(t.Context(), week) if !errors.Is(err, gcal.ErrReconnect) { t.Fatalf("Push = %v, want ErrReconnect", err) } @@ -479,12 +479,12 @@ func TestRefusedGrantKeepsPushedWeeks(t *testing.T) { dates := map[store.LocalDate][]string{} for _, w := range pushedWeeks { dates[w] = e.storePlan(t, w, fullWeek...) - if _, err := e.svc.Push(t.Context(), w); err != nil { + if _, _, err := e.svc.Push(t.Context(), w); err != nil { t.Fatalf("Push %s: %v", w, err) } } e.google.RevokeGrant() - if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) { + if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) { t.Fatalf("Push = %v, want ErrReconnect", err) } e.assertRefused(t) @@ -497,7 +497,7 @@ func TestRefusedGrantKeepsPushedWeeks(t *testing.T) { // The refused token is not offered to Google again. before := e.google.TokenCalls() - if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) { + if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) { t.Fatalf("Push again = %v, want ErrReconnect", err) } if n := e.google.TokenCalls() - before; n != 0 { @@ -506,7 +506,7 @@ func TestRefusedGrantKeepsPushedWeeks(t *testing.T) { e.google.Connect(t, e.gcal) e.google.ResetEventWrites() - p, err := e.svc.Push(t.Context(), week) + p, _, err := e.svc.Push(t.Context(), week) if err != nil || p.Status != plan.StatusSynced { t.Fatalf("Push after connecting again = %s, %v; want synced", p.Status, err) } @@ -523,7 +523,7 @@ func TestDisconnectAfterRefusedGrant(t *testing.T) { e := newEnv(t) e.connectCalendar(t) e.storePlan(t, week, fullWeek...) - if _, err := e.svc.Push(t.Context(), week); err != nil { + if _, _, err := e.svc.Push(t.Context(), week); err != nil { t.Fatal(err) } e.google.RevokeGrant() @@ -551,7 +551,7 @@ func TestPushFailsWhenPlanSwappedWhilePushing(t *testing.T) { defer release() pushed := make(chan error, 1) go func() { - _, err := e.svc.Push(t.Context(), week) + _, _, err := e.svc.Push(t.Context(), week) pushed <- err }() <-arrived @@ -596,7 +596,7 @@ func TestPushAfterChoosingAnotherCalendarWritesTheWholeWeek(t *testing.T) { e := newEnv(t) e.connectCalendar(t) dates := e.storePlan(t, week, fullWeek...) - first, err := e.svc.Push(t.Context(), week) + first, _, err := e.svc.Push(t.Context(), week) if err != nil { t.Fatal(err) } @@ -640,14 +640,14 @@ func TestPushAfterChoosingAnotherCalendarWritesTheWholeWeek(t *testing.T) { // A push that fails part way leaves the plan changed. e.google.ResetEventWrites() e.google.FailEventWrite(4, http.StatusBadRequest) - if _, err := e.svc.Push(t.Context(), week); err == nil { + if _, _, err := e.svc.Push(t.Context(), week); err == nil { t.Fatal("Push succeeded, want the fourth event to fail") } if p, err := e.svc.Get(t.Context(), week); err != nil || p.Status != plan.StatusChanged { t.Errorf("plan after a failed push = %s, %v; want changed", p.Status, err) } - p, err = e.svc.Push(t.Context(), week) + p, _, err = e.svc.Push(t.Context(), week) if err != nil { t.Fatalf("Push: %v", err) } @@ -678,7 +678,7 @@ func TestChoosingAnotherCalendarWaitsForAPush(t *testing.T) { defer release() pushed := make(chan error, 1) go func() { - _, err := e.svc.Push(t.Context(), week) + _, _, err := e.svc.Push(t.Context(), week) pushed <- err }() <-arrived @@ -712,7 +712,7 @@ func TestChoosingAnotherCalendarWaitsForAPush(t *testing.T) { } } - if p, err = e.svc.Push(t.Context(), week); err != nil || p.Status != plan.StatusSynced { + if p, _, err = e.svc.Push(t.Context(), week); err != nil || p.Status != plan.StatusSynced { t.Fatalf("Push = %s, %v; want synced", p.Status, err) } e.assertWeekOn(t, kitchenCal.ID, week, dates, fullWeek...) @@ -746,7 +746,7 @@ func TestDisconnectForgetsEveryPushedWeek(t *testing.T) { dates := map[store.LocalDate][]string{} for _, w := range pushedWeeks { dates[w] = e.storePlan(t, w, fullWeek...) - if _, err := e.svc.Push(t.Context(), w); err != nil { + if _, _, err := e.svc.Push(t.Context(), w); err != nil { t.Fatalf("Push %s: %v", w, err) } } @@ -764,7 +764,7 @@ func TestDisconnectForgetsEveryPushedWeek(t *testing.T) { // Connecting again chooses no calendar, so nothing is pushed until one // is chosen; then a push writes the whole week to it. e.google.Connect(t, e.gcal) - if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) { + if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) { t.Fatalf("Push after connecting again = %v, want ErrNoCalendar", err) } e.google.SetCalendars(0, dinnersCal, kitchenCal) @@ -773,7 +773,7 @@ func TestDisconnectForgetsEveryPushedWeek(t *testing.T) { } for _, w := range pushedWeeks { e.assertForgotten(t, w, "after connecting again") - p, err := e.svc.Push(t.Context(), w) + p, _, err := e.svc.Push(t.Context(), w) if err != nil || p.Status != plan.StatusSynced { t.Fatalf("Push %s = %s, %v; want synced", w, p.Status, err) } @@ -789,7 +789,7 @@ func TestDisconnectRefusedKeepsPushedWeeks(t *testing.T) { dates := map[store.LocalDate][]string{} for _, w := range pushedWeeks { dates[w] = e.storePlan(t, w, fullWeek...) - if _, err := e.svc.Push(t.Context(), w); err != nil { + if _, _, err := e.svc.Push(t.Context(), w); err != nil { t.Fatalf("Push %s: %v", w, err) } } @@ -816,7 +816,7 @@ func TestDisconnectWaitsForAPush(t *testing.T) { defer release() pushed := make(chan error, 1) go func() { - _, err := e.svc.Push(t.Context(), week) + _, _, err := e.svc.Push(t.Context(), week) pushed <- err }() <-arrived diff --git a/internal/server/aisettings_test.go b/internal/server/aisettings_test.go index cd11105..a4d9ed7 100644 --- a/internal/server/aisettings_test.go +++ b/internal/server/aisettings_test.go @@ -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), - newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go index 1f7aef9..229b355 100644 --- a/internal/server/dinners_test.go +++ b/internal/server/dinners_test.go @@ -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), newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/google_test.go b/internal/server/google_test.go index 3035ec5..bb9f7e4 100644 --- a/internal/server/google_test.go +++ b/internal/server/google_test.go @@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv { } logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug})) e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - newTestDinners(t), newTestSuggest(t), newTestPlan(t), svc, newTestRunner(t), newTestPhotos(t), + newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/login_test.go b/internal/server/login_test.go index 3555347..9fa481a 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -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), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/media_test.go b/internal/server/media_test.go index d301ace..c95c55a 100644 --- a/internal/server/media_test.go +++ b/internal/server/media_test.go @@ -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), newTestPlan(t), newTestGoogle(t), newTestRunner(t), + r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index dfe3b5c..a141938 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -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), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/plans.go b/internal/server/plans.go index cf81dc6..73940c2 100644 --- a/internal/server/plans.go +++ b/internal/server/plans.go @@ -1,6 +1,7 @@ package server import ( + "context" "errors" "log/slog" "net/http" @@ -9,6 +10,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" "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" ) @@ -94,20 +96,72 @@ func putPlanDay(logger *slog.Logger, svc *plan.Service) http.HandlerFunc { } } +// pushedPlan is the POST /plans/{weekStart}/push-calendar answer: the +// plan as synced, and what became of the week's Supply run. +type pushedPlan struct { + plan.Plan + SupplyRun pushedSupplyRun `json:"supply_run"` +} + +// pushedSupplyRun says whether a Push to Calendar made the week's Supply +// run. status is generated when it did; archived when the week's Supply +// run is in the Supply archive and was left as it was; or failed when it +// was not made. A failed Supply run's error is plan_changed when the plan +// was edited after it was pushed, so the Supply run was not made from a +// plan Calendar does not show and pushing again makes it; or +// supply_run_failed when it could not be made, and POST +// /shopping/{weekStart}/generate makes it again. error is null when status +// is generated. +type pushedSupplyRun struct { + Status string `json:"status"` + Error *apiError `json:"error"` +} + // postPlanPush pushes the week's plan to the chosen Google calendar -// (Push to Calendar) and answers with the plan as synced. -func postPlanPush(logger *slog.Logger, svc *plan.Service) http.HandlerFunc { +// (Push to Calendar) and, once the push is recorded, makes the week's +// Supply run from the plan pushed. It answers with the plan as synced and +// the Supply run's outcome. +// +// The Supply run is made after the push, never as part of it: Calendar +// already shows the plan, so a Supply run that cannot be made does not +// undo or fail the push. The answer is still 200 with supply_run failed, +// and the failure is logged, so nothing claims a Supply run that was not +// made. It is made only from the plan as pushed: one edited between the +// push and the Supply run is not made into a Supply run. +func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { week, ok := weekStartOrFail(w, r, logger) if !ok { return } - p, err := svc.Push(r.Context(), week) + p, revision, err := svc.Push(r.Context(), week) if err != nil { writePlanPushError(w, r, logger, err) return } - writeJSON(w, r, logger, http.StatusOK, p) + // The push is done: make the Supply run even if the client has + // gone. + ctx := context.WithoutCancel(r.Context()) + run := pushedSupplyRun{Status: "generated"} + if _, err := shop.GeneratePushed(ctx, week, revision); errors.Is(err, shopping.ErrFrozen) { + logger.InfoContext(ctx, "Supply run in the Supply archive left as it was after Push to Calendar", "week", week.String()) + run = pushedSupplyRun{Status: "archived", Error: &apiError{Code: "archived", Message: err.Error()}} + } else if errors.Is(err, shopping.ErrPlanChanged) { + logger.WarnContext(ctx, "plan edited after Push to Calendar; Supply run not made", "week", week.String()) + run = pushedSupplyRun{Status: "failed", Error: &apiError{ + Code: "plan_changed", + Message: "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again", + Retryable: true, + }} + } else if err != nil { + logger.ErrorContext(ctx, "generate Supply run after Push to Calendar", "week", week.String(), "error", err) + run = pushedSupplyRun{Status: "failed", Error: &apiError{ + Code: "supply_run_failed", + Message: "the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run", + Retryable: true, + }} + } + writeJSON(w, r, logger, http.StatusOK, pushedPlan{Plan: p, SupplyRun: run}) } } diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go index 1b67833..cf9baae 100644 --- a/internal/server/plans_test.go +++ b/internal/server/plans_test.go @@ -23,6 +23,7 @@ import ( "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" ) @@ -79,8 +80,12 @@ func newPlansRouter(t *testing.T) plansEnv { if err != nil { t.Fatal(err) } + shop, err := shopping.NewService(st) + 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, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/recipesearch_test.go b/internal/server/recipesearch_test.go index 34f2032..126a67e 100644 --- a/internal/server/recipesearch_test.go +++ b/internal/server/recipesearch_test.go @@ -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), newTestPlan(t), newTestGoogle(t), newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/server.go b/internal/server/server.go index 5de1285..5c70e0b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -19,6 +19,7 @@ import ( "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/shopping" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" ) @@ -46,7 +47,11 @@ type RouteMounter interface { // /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; googleSvc serves GET +// POST /api/v1/dinners/{id}/rotation-review, and POST +// /api/v1/plans/{weekStart}/push-calendar with shopSvc, which makes the +// pushed week's Supply run; shopSvc serves GET /api/v1/shopping/{weekStart}, +// POST /api/v1/shopping/{weekStart}/generate and PATCH +// /api/v1/shopping/items/{id}; googleSvc serves GET // /api/v1/integrations/google and its /connect, /callback and /calendars, // PUT /api/v1/integrations/google/calendar and DELETE // /api/v1/integrations/google; runner serves @@ -54,7 +59,7 @@ type RouteMounter interface { // 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, planSvc *plan.Service, googleSvc *gcal.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, shopSvc *shopping.Service, googleSvc *gcal.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { if settings == nil { return nil, errors.New("server: nil household settings service") } @@ -73,6 +78,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a if planSvc == nil { return nil, errors.New("server: nil plan service") } + if shopSvc == nil { + return nil, errors.New("server: nil shopping service") + } if googleSvc == nil { return nil, errors.New("server: nil Google Calendar service") } @@ -133,7 +141,10 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a 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.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc)) + apiV1.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc, shopSvc)) + apiV1.Get("/shopping/{weekStart}", getSupplyRun(logger, shopSvc)) + apiV1.Post("/shopping/{weekStart}/generate", postSupplyRunGenerate(logger, shopSvc)) + apiV1.Patch("/shopping/items/{id}", patchShoppingItem(logger, shopSvc)) apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc)) apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc)) // connect and callback are top-level browser navigations, so they are diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 4ede01b..3a57cde 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -30,6 +30,7 @@ import ( "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" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" @@ -53,7 +54,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), newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), assets) + r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), assets) if err != nil { t.Fatal(err) } @@ -209,6 +210,17 @@ func newTestPlan(t *testing.T) *plan.Service { return svc } +// newTestShopping returns a shopping service over a migrated temp +// database. +func newTestShopping(t *testing.T) *shopping.Service { + t.Helper() + svc, err := shopping.NewService(newTestStore(t)) + 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() @@ -263,7 +275,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), newTestPlan(t), newTestGoogle(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), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil AI settings service") } } @@ -272,7 +284,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), newTestPlan(t), newTestGoogle(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), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil recipe search settings service") } } @@ -281,7 +293,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), newTestPlan(t), newTestGoogle(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), newTestShopping(t), newTestGoogle(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted nil photo files") } } @@ -290,7 +302,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), newTestPlan(t), newTestGoogle(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), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil settings service") } } @@ -299,7 +311,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), newTestPlan(t), newTestGoogle(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), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil dinners service") } } @@ -308,7 +320,7 @@ 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, newTestPlan(t), newTestGoogle(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), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil suggestions service") } } @@ -317,16 +329,25 @@ 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, newTestGoogle(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), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil plan service") } } +func TestNewRouterRequiresShopping(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), newTestPlan(t), nil, newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + t.Fatal("NewRouter accepted a nil shopping service") + } +} + func TestNewRouterRequiresGoogle(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), newTestPlan(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), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil Google Calendar service") } } @@ -335,7 +356,7 @@ 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), newTestPlan(t), newTestGoogle(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), newTestShopping(t), newTestGoogle(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil job runner") } } diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 8a4398f..45b6d7c 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -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), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/shopping.go b/internal/server/shopping.go new file mode 100644 index 0000000..3c3ac8e --- /dev/null +++ b/internal/server/shopping.go @@ -0,0 +1,198 @@ +package server + +import ( + "errors" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// maxShoppingItemBody bounds a PATCH /shopping/items/{id} body. +const maxShoppingItemBody = 1 << 10 + +// supplyRun is a Supply run as the API shows it. label is the week label, +// which stands in for title until the AI writes one. +type supplyRun struct { + WeekStart string `json:"weekStart"` + Label string `json:"label"` + Title *string `json:"title"` + Status string `json:"status"` + SentToChatAt *string `json:"sent_to_chat_at"` + shopping.Counts + Groups []supplyRunGroup `json:"groups"` +} + +// supplyRunGroup is the items of one plan day's dinner. day is the plan +// day's weekday, such as "Monday". +type supplyRunGroup struct { + Dinner supplyRunDinner `json:"dinner"` + Date string `json:"date"` + Day string `json:"day"` + Items []supplyRunItem `json:"items"` +} + +type supplyRunDinner struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// supplyRunItem is one ingredient to buy; qty is null when the recipe +// gives none, and needed is false once it is marked "have it". +type supplyRunItem struct { + ID string `json:"id"` + Name string `json:"name"` + Qty *string `json:"qty"` + Needed bool `json:"needed"` +} + +// generatedSupplyRun is the POST /shopping/{weekStart}/generate answer: +// the Supply run as made, and the plan days with nothing to shop for. +type generatedSupplyRun struct { + supplyRun + Skipped []shopping.SkippedDay `json:"skipped"` +} + +// shoppingItemPatch is the PATCH /shopping/items/{id} body. +type shoppingItemPatch struct { + Needed *bool `json:"needed"` +} + +// toggledItem is the PATCH /shopping/items/{id} answer: the item's flag +// and its Supply run's counts after the change. +type toggledItem struct { + ID string `json:"id"` + Needed bool `json:"needed"` + shopping.Counts +} + +// getSupplyRun answers GET /shopping/{weekStart} with the week's Supply +// run. +func getSupplyRun(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + week, ok := weekStartOrFail(w, r, logger) + if !ok { + return + } + l, err := svc.Get(r.Context(), week) + if err != nil { + writeShoppingError(w, r, logger, "read Supply run", err) + return + } + out, err := supplyRunOf(week, l) + if err != nil { + writeShoppingError(w, r, logger, "read Supply run", err) + return + } + writeJSON(w, r, logger, http.StatusOK, out) + } +} + +// postSupplyRunGenerate makes, or remakes, the week's Supply run from its +// plan and answers with it and the plan days it skipped. +func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + week, ok := weekStartOrFail(w, r, logger) + if !ok { + return + } + g, err := svc.Generate(r.Context(), week) + if err != nil { + writeShoppingError(w, r, logger, "generate Supply run", err) + return + } + out, err := supplyRunOf(week, g.List) + if err != nil { + writeShoppingError(w, r, logger, "generate Supply run", err) + return + } + skipped := g.Skipped + if skipped == nil { + skipped = []shopping.SkippedDay{} + } + writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped}) + } +} + +// patchShoppingItem marks a Supply run item needed or "have it" and +// answers with its Supply run's new counts. +func patchShoppingItem(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := decodePatch[shoppingItemPatch](http.MaxBytesReader(w, r.Body, maxShoppingItemBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if body.Needed == nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "needed: required") + return + } + id := chi.URLParam(r, "id") + c, err := svc.SetNeeded(r.Context(), id, *body.Needed) + if err != nil { + writeShoppingError(w, r, logger, "mark Supply run item", err) + return + } + writeJSON(w, r, logger, http.StatusOK, toggledItem{ID: id, Needed: *body.Needed, Counts: c}) + } +} + +// supplyRunOf is l, the Supply run of the week starting week, as the API +// shows it. +func supplyRunOf(week store.LocalDate, l shopping.List) (supplyRun, error) { + label, err := household.WeekLabel(week) + if err != nil { + return supplyRun{}, err + } + groups := make([]supplyRunGroup, len(l.Groups)) + for i, g := range l.Groups { + date, err := time.Parse(time.DateOnly, g.Date) + if err != nil { + return supplyRun{}, fmt.Errorf("group %s date: %w", g.ID, err) + } + items := make([]supplyRunItem, len(g.Items)) + for j, it := range g.Items { + items[j] = supplyRunItem{ID: it.ID, Name: it.Name, Qty: it.Qty, Needed: it.Needed} + } + groups[i] = supplyRunGroup{ + Dinner: supplyRunDinner{ID: g.DinnerID, Name: g.DinnerName}, + Date: g.Date, + Day: date.Weekday().String(), + Items: items, + } + } + return supplyRun{ + WeekStart: l.WeekStart, + Label: label, + Title: l.Title, + Status: l.Status, + SentToChatAt: l.SentToChatAt, + Counts: l.Counts(), + Groups: groups, + }, nil +} + +// writeShoppingError maps a shopping.Service error to its API error: a +// week with no Supply run or no plan, or an unknown item, is 404, and a +// change to a Supply run in the Supply archive is 409 archived. +func writeShoppingError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) { + switch { + case errors.Is(err, shopping.ErrListNotFound): + writeError(w, r, logger, http.StatusNotFound, "not_found", "Supply run not found") + case errors.Is(err, shopping.ErrItemNotFound): + writeError(w, r, logger, http.StatusNotFound, "not_found", "Supply run item not found") + case errors.Is(err, shopping.ErrPlanNotFound): + writeError(w, r, logger, http.StatusNotFound, "not_found", "plan not found") + case errors.Is(err, shopping.ErrFrozen): + writeError(w, r, logger, http.StatusConflict, "archived", err.Error()) + default: + logger.ErrorContext(r.Context(), action, "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + } +} diff --git a/internal/server/shopping_test.go b/internal/server/shopping_test.go new file mode 100644 index 0000000..9ef7d42 --- /dev/null +++ b/internal/server/shopping_test.go @@ -0,0 +1,511 @@ +package server + +import ( + "database/sql" + "encoding/json" + "net/http" + "reflect" + "slices" + "strings" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +const shoppingPath = "/api/v1/shopping/" + +// shopIng is a recipe ingredient as recipe grounding stores it: an empty +// qty or unit is NULL. +type shopIng struct { + name, qty, unit string +} + +// groundDay grounds the dinner planned on date of the plans env's week in +// a fetched recipe listing ings. +func (e plansEnv) groundDay(t *testing.T, date string, ings ...shopIng) { + t.Helper() + ctx := t.Context() + q := db.New(e.st.Writer) + now := store.Now().String() + r, err := q.UpsertRecipe(ctx, db.UpsertRecipeParams{ + ID: store.NewID(), SourceUrl: "https://recipes.example/" + date, Title: "Recipe of " + date, + RawJsonld: "{}", FetchedAt: now, CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatal(err) + } + for i, in := range ings { + if err := q.CreateRecipeIngredient(ctx, db.CreateRecipeIngredientParams{ + ID: store.NewID(), RecipeID: r.ID, Position: int64(i), + RawText: strings.TrimSpace(in.qty + " " + in.unit + " " + in.name), + Name: sql.NullString{String: in.name, Valid: true}, + Qty: sql.NullString{String: in.qty, Valid: in.qty != ""}, + Unit: sql.NullString{String: in.unit, Valid: in.unit != ""}, + CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + } + if _, err := e.st.Writer.ExecContext(ctx, `UPDATE dinners SET recipe_id = ?, grounding_status = 'grounded' + WHERE id = (SELECT dinner_id FROM plan_days WHERE date = ?)`, r.ID, date); err != nil { + t.Fatal(err) + } +} + +// groundWeek grounds Monday's Beef Chili and Tuesday's Regular: five items, +// one with no quantity. +func (e plansEnv) groundWeek(t *testing.T) { + t.Helper() + e.groundDay(t, "2026-10-12", shopIng{"ground beef", "1", "lb"}, shopIng{"kidney beans", "2", "cans"}, shopIng{"onion", "1", ""}) + e.groundDay(t, "2026-10-13", shopIng{"spaghetti", "500", "g"}, shopIng{"basil", "", ""}) +} + +// archive freezes the week's Supply run into the Supply archive. +func (e plansEnv) archive(t *testing.T) { + t.Helper() + if _, err := e.st.Writer.ExecContext(t.Context(), + `UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil { + t.Fatal(err) + } +} + +// supplyRunJSON is a Supply run answer decoded with every field the API +// promises. +type supplyRunJSON struct { + WeekStart string `json:"weekStart"` + Label string `json:"label"` + Title *string `json:"title"` + Status string `json:"status"` + SentToChatAt *string `json:"sent_to_chat_at"` + NeededCount int `json:"needed_count"` + TotalCount int `json:"total_count"` + Groups []struct { + Dinner struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"dinner"` + Date string `json:"date"` + Day string `json:"day"` + Items []struct { + ID string `json:"id"` + Name string `json:"name"` + Qty *string `json:"qty"` + Needed bool `json:"needed"` + } `json:"items"` + } `json:"groups"` +} + +// render is l one item a line as "day date dinner: name [qty] needed|have +// it". +func (l supplyRunJSON) render() []string { + var out []string + for _, g := range l.Groups { + for _, it := range g.Items { + qty := "null" + if it.Qty != nil { + qty = *it.Qty + } + mark := "needed" + if !it.Needed { + mark = "have it" + } + out = append(out, g.Day+" "+g.Date+" "+g.Dinner.Name+": "+it.Name+" ["+qty+"] "+mark) + } + } + return out +} + +// itemID is the id of the item named name. +func (l supplyRunJSON) itemID(t *testing.T, name string) string { + t.Helper() + for _, g := range l.Groups { + for _, it := range g.Items { + if it.Name == name { + return it.ID + } + } + } + t.Fatalf("no item %q", name) + return "" +} + +// callShopping makes a request and decodes a 200 answer into out. +func callShopping(t *testing.T, e plansEnv, method, target, body string, out any) (int, map[string]any) { + t.Helper() + rec, got := callDinners(t, e.h, method, target, body) + if rec.Code == http.StatusOK && out != nil { + if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil { + t.Fatal(err) + } + } + return rec.Code, got +} + +func keys(m map[string]any) []string { + var out []string + for k := range m { + out = append(out, k) + } + slices.Sort(out) + return out +} + +var weekLines = []string{ + "Monday 2026-10-12 Beef Chili: ground beef [1 lb] needed", + "Monday 2026-10-12 Beef Chili: kidney beans [2 cans] needed", + "Monday 2026-10-12 Beef Chili: onion [1] needed", + "Tuesday 2026-10-13 Regular 2026-10-13: spaghetti [500 g] needed", + "Tuesday 2026-10-13 Regular 2026-10-13: basil [null] needed", +} + +// TestSupplyRunShape generates the week's Supply run and reads it back: +// both answers carry exactly the promised fields. +func TestSupplyRunShape(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.groundWeek(t) + + if status, body := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", nil); status != http.StatusNotFound || errorCode(body) != "not_found" { + t.Fatalf("GET before generating = %d %v, want 404 not_found", status, body) + } + + var made supplyRunJSON + status, raw := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &made) + if status != http.StatusOK { + t.Fatalf("generate = %d %v", status, raw) + } + wantTop := []string{"groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "total_count", "weekStart"} + if got := keys(raw); !reflect.DeepEqual(got, wantTop) { + t.Errorf("generate fields = %v, want %v", got, wantTop) + } + if skipped, _ := raw["skipped"].([]any); len(skipped) != 5 { + t.Errorf("skipped = %v, want the five days without a recipe", raw["skipped"]) + } + + var got supplyRunJSON + status, raw = callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &got) + if status != http.StatusOK { + t.Fatalf("GET = %d %v", status, raw) + } + if k := keys(raw); !reflect.DeepEqual(k, slices.DeleteFunc(slices.Clone(wantTop), func(s string) bool { return s == "skipped" })) { + t.Errorf("GET fields = %v", k) + } + group := raw["groups"].([]any)[0].(map[string]any) + if k := keys(group); !reflect.DeepEqual(k, []string{"date", "day", "dinner", "items"}) { + t.Errorf("group fields = %v", k) + } + if k := keys(group["dinner"].(map[string]any)); !reflect.DeepEqual(k, []string{"id", "name"}) { + t.Errorf("dinner fields = %v", k) + } + if k := keys(group["items"].([]any)[0].(map[string]any)); !reflect.DeepEqual(k, []string{"id", "name", "needed", "qty"}) { + t.Errorf("item fields = %v", k) + } + if !reflect.DeepEqual(got, made) { + t.Errorf("GET = %+v, want what generate made %+v", got, made) + } + if got.WeekStart != "2026-10-12" || got.Label != "Week 42 ยท Oct 12 โ€“ 18" || got.Title != nil || + got.Status != "current" || got.SentToChatAt != nil || got.NeededCount != 5 || got.TotalCount != 5 { + t.Errorf("GET = %+v", got) + } + if !reflect.DeepEqual(got.render(), weekLines) { + t.Errorf("items =\n %s\nwant\n %s", strings.Join(got.render(), "\n "), strings.Join(weekLines, "\n ")) + } + if got.Groups[0].Dinner.ID == "" || got.Groups[0].Items[0].ID == "" { + t.Errorf("ids missing: %+v", got.Groups[0]) + } +} + +// TestShoppingItemToggle marks items "have it" and needed again: each +// answer carries the item and the new counts, and the list read back +// agrees. +func TestShoppingItemToggle(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.groundWeek(t) + var l supplyRunJSON + if status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l); status != http.StatusOK { + t.Fatalf("generate = %d %v", status, body) + } + onion, basil := l.itemID(t, "onion"), l.itemID(t, "basil") + + for _, step := range []struct { + id string + needed bool + neededCount int + wantAfterGet string + }{ + {onion, false, 4, "Monday 2026-10-12 Beef Chili: onion [1] have it"}, + {onion, false, 4, "Monday 2026-10-12 Beef Chili: onion [1] have it"}, // sets, never flips + {basil, false, 3, "Tuesday 2026-10-13 Regular 2026-10-13: basil [null] have it"}, + {onion, true, 4, "Monday 2026-10-12 Beef Chili: onion [1] needed"}, + } { + body := `{"needed":false}` + if step.needed { + body = `{"needed":true}` + } + status, got := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+step.id, body, nil) + want := map[string]any{"id": step.id, "needed": step.needed, "needed_count": float64(step.neededCount), "total_count": float64(5)} + if status != http.StatusOK || !reflect.DeepEqual(got, want) { + t.Fatalf("PATCH %s = %d %v, want %v", body, status, got, want) + } + var after supplyRunJSON + callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &after) + if after.NeededCount != step.neededCount || after.TotalCount != 5 || !slices.Contains(after.render(), step.wantAfterGet) { + t.Errorf("GET after PATCH %s = %d/%d %v", body, after.NeededCount, after.TotalCount, after.render()) + } + } +} + +func TestShoppingItemToggleErrors(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.groundWeek(t) + var l supplyRunJSON + callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l) + onion := l.itemID(t, "onion") + + for _, tc := range []struct { + name, id, body string + status int + code string + }{ + {"no needed", onion, `{}`, http.StatusBadRequest, "invalid_request"}, + {"needed null", onion, `{"needed":null}`, http.StatusBadRequest, "invalid_request"}, + {"needed not a bool", onion, `{"needed":"no"}`, http.StatusBadRequest, "invalid_request"}, + {"unknown field", onion, `{"needed":false,"qty":"2"}`, http.StatusBadRequest, "invalid_request"}, + {"unknown item", store.NewID(), `{"needed":false}`, http.StatusNotFound, "not_found"}, + } { + status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+tc.id, tc.body, nil) + if status != tc.status || errorCode(body) != tc.code { + t.Errorf("%s: PATCH = %d %v, want %d %s", tc.name, status, body, tc.status, tc.code) + } + } + + e.archive(t) + status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+onion, `{"needed":false}`, nil) + if status != http.StatusConflict || errorCode(body) != "archived" { + t.Errorf("PATCH of an archived list = %d %v, want 409 archived", status, body) + } + var after supplyRunJSON + callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &after) + if after.Status != "archived" || !reflect.DeepEqual(after.render(), weekLines) || after.NeededCount != 5 { + t.Errorf("archived list after PATCH = %s %v, want unchanged", after.Status, after.render()) + } +} + +// TestSupplyRunRegenerate remakes the list after a swap: the swapped-out +// dinner's group goes, and what the household marked "have it" stays. +func TestSupplyRunRegenerate(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.groundWeek(t) + var l supplyRunJSON + callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l) + onion := l.itemID(t, "onion") + callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+onion, `{"needed":false}`, nil) + if _, err := e.svc.Swap(t.Context(), mustLocalDate(t, "2026-10-12"), "2026-10-13", e.questLog); err != nil { + t.Fatal(err) + } + + var again supplyRunJSON + if status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &again); status != http.StatusOK { + t.Fatalf("generate again = %d %v", status, body) + } + want := []string{ + "Monday 2026-10-12 Beef Chili: ground beef [1 lb] needed", + "Monday 2026-10-12 Beef Chili: kidney beans [2 cans] needed", + "Monday 2026-10-12 Beef Chili: onion [1] have it", + } + if !reflect.DeepEqual(again.render(), want) || again.NeededCount != 2 || again.TotalCount != 3 { + t.Errorf("remade = %d/%d %v, want 2/3 %v", again.NeededCount, again.TotalCount, again.render(), want) + } + if again.itemID(t, "onion") != onion { + t.Error("onion has a new id after the remake") + } +} + +func TestSupplyRunErrors(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.groundWeek(t) + for _, tc := range []struct { + name, method, path string + status int + code string + }{ + {"GET not a Monday", http.MethodGet, "2026-10-13", http.StatusBadRequest, "invalid_week"}, + {"GET no list", http.MethodGet, "2026-10-19", http.StatusNotFound, "not_found"}, + {"generate not a Monday", http.MethodPost, "2026-10-13/generate", http.StatusBadRequest, "invalid_week"}, + {"generate no plan", http.MethodPost, "2026-10-19/generate", http.StatusNotFound, "not_found"}, + } { + status, body := callShopping(t, e, tc.method, shoppingPath+tc.path, "", nil) + if status != tc.status || errorCode(body) != tc.code { + t.Errorf("%s = %d %v, want %d %s", tc.name, status, body, tc.status, tc.code) + } + } + + callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil) + e.archive(t) + status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil) + if status != http.StatusConflict || errorCode(body) != "archived" { + t.Errorf("generate of an archived list = %d %v, want 409 archived", status, body) + } +} + +// TestPushPlanMakesSupplyRun: a Push to Calendar makes the week's Supply +// run from the plan pushed. +func TestPushPlanMakesSupplyRun(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.groundWeek(t) + e.connectCalendar(t, true) + + rec, body := e.push(t, "2026-10-12") + if rec.Code != http.StatusOK || body["status"] != "synced" { + t.Fatalf("push = %d %s", rec.Code, rec.Body) + } + if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) { + t.Errorf("supply_run = %v, want %v", body["supply_run"], want) + } + var l supplyRunJSON + if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l); status != http.StatusOK { + t.Fatalf("GET after push = %d %v, want the Supply run", status, got) + } + if !reflect.DeepEqual(l.render(), weekLines) { + t.Errorf("Supply run after push = %v", l.render()) + } +} + +// TestFailedPushMakesNoSupplyRun: a push Calendar refused makes no Supply +// run. +func TestFailedPushMakesNoSupplyRun(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.groundWeek(t) + + if rec, _ := e.push(t, "2026-10-12"); rec.Code != http.StatusConflict { + t.Fatalf("push unconnected = %d %s", rec.Code, rec.Body) + } + if status, body := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", nil); status != http.StatusNotFound { + t.Errorf("GET after a failed push = %d %v, want 404", status, body) + } +} + +// TestPushPlanSupplyRunFails: when the Supply run cannot be made after the +// push, the push stands (200, synced, on Calendar), the answer says the +// Supply run failed, the failure is logged as an error, and no Supply run +// is claimed. +func TestPushPlanSupplyRunFails(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.groundWeek(t) + if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE recipe_ingredients SET name = NULL WHERE name = 'onion'`); err != nil { + t.Fatal(err) + } + e.connectCalendar(t, true) + + rec, body := e.push(t, "2026-10-12") + if rec.Code != http.StatusOK || body["status"] != "synced" { + t.Fatalf("push = %d %s, want 200 synced", rec.Code, rec.Body) + } + want := map[string]any{"status": "failed", "error": map[string]any{ + "code": "supply_run_failed", + "message": "the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run", + "retryable": true, + }} + if !reflect.DeepEqual(body["supply_run"], want) { + t.Errorf("supply_run = %v, want %v", body["supply_run"], want) + } + if n := len(e.google.Events(pushCalendar.ID)); n != 7 { + t.Errorf("calendar has %d events, want 7", n) + } + p, err := e.svc.Get(t.Context(), mustLocalDate(t, "2026-10-12")) + if err != nil { + t.Fatal(err) + } + if p.Status != plan.StatusSynced { + t.Errorf("plan = %s after the push, want synced", p.Status) + } + if !strings.Contains(e.logs.String(), `"level":"ERROR","msg":"generate Supply run after Push to Calendar"`) || + !strings.Contains(e.logs.String(), "has no normalized name") { + t.Errorf("logs = %s, want the failure logged as an error", e.logs) + } + if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", nil); status != http.StatusNotFound { + t.Errorf("GET after the failure = %d %v, want 404", status, got) + } +} + +// TestPushPlanEditedBeforeSupplyRun: a plan edit landing after the push +// is recorded but before the Supply run is made does not make the Supply +// run from the edited plan, which Calendar does not show. The push stands +// (200, on Calendar) and the answer says the Supply run was not made +// because the plan changed. +func TestPushPlanEditedBeforeSupplyRun(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.groundWeek(t) + e.connectCalendar(t, true) + // Once the push records Sunday's event, the household swaps Tuesday to + // Pad Thai, as a swap does: committed before the Supply run is made. + if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER edit_after_push + AFTER UPDATE OF calendar_event_id ON plan_days + WHEN NEW.date = '2026-10-18' AND NEW.calendar_event_id IS NOT NULL + BEGIN + UPDATE plan_days SET dinner_id = '`+e.questLog+`', is_new = 1, calendar_event_id = NULL WHERE date = '2026-10-13'; + UPDATE week_plans SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END, revision = revision + 1; + END`); err != nil { + t.Fatal(err) + } + + rec, body := e.push(t, "2026-10-12") + if rec.Code != http.StatusOK { + t.Fatalf("push = %d %s, want 200", rec.Code, rec.Body) + } + want := map[string]any{"status": "failed", "error": map[string]any{ + "code": "plan_changed", + "message": "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again", + "retryable": true, + }} + if !reflect.DeepEqual(body["supply_run"], want) { + t.Errorf("supply_run = %v, want %v", body["supply_run"], want) + } + if n := len(e.google.Events(pushCalendar.ID)); n != 7 { + t.Errorf("calendar has %d events, want 7", n) + } + if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", nil); status != http.StatusNotFound { + t.Errorf("GET after the edit = %d %v, want 404: no Supply run from the edited plan", status, got) + } + if !strings.Contains(e.logs.String(), `"level":"WARN","msg":"plan edited after Push to Calendar; Supply run not made"`) { + t.Errorf("logs = %s, want the edit logged as a warning", e.logs) + } +} + +// TestPushPlanLeavesArchivedSupplyRun: pushing a week whose Supply run is +// in the Supply archive leaves it as it was and says so. +func TestPushPlanLeavesArchivedSupplyRun(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.groundWeek(t) + callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil) + e.archive(t) + if _, err := e.svc.Swap(t.Context(), mustLocalDate(t, "2026-10-12"), "2026-10-13", e.questLog); err != nil { + t.Fatal(err) + } + e.connectCalendar(t, true) + + rec, body := e.push(t, "2026-10-12") + if rec.Code != http.StatusOK || body["status"] != "synced" { + t.Fatalf("push = %d %s", rec.Code, rec.Body) + } + run, _ := body["supply_run"].(map[string]any) + if apiErr, _ := run["error"].(map[string]any); run["status"] != "archived" || apiErr["code"] != "archived" { + t.Errorf("supply_run = %v, want archived", body["supply_run"]) + } + var l supplyRunJSON + callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l) + if !reflect.DeepEqual(l.render(), weekLines) { + t.Errorf("archived Supply run after push = %v, want unchanged", l.render()) + } +} diff --git a/internal/server/suggestions_test.go b/internal/server/suggestions_test.go index 2595b13..7e4bb5a 100644 --- a/internal/server/suggestions_test.go +++ b/internal/server/suggestions_test.go @@ -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, newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/shopping/shopping.go b/internal/shopping/shopping.go index 96dc660..1530b96 100644 --- a/internal/shopping/shopping.go +++ b/internal/shopping/shopping.go @@ -39,10 +39,21 @@ const ( // ErrPlanNotFound is a generation for a week with no plan. var ErrPlanNotFound = errors.New("the week has no plan") -// ErrFrozen is a generation for a week whose Supply run is frozen into the -// Supply archive, which is read-only. +// ErrPlanChanged is a generation from a pushed plan the household has +// edited since it was pushed: the Supply run would not match Calendar, so +// none is made, and pushing the plan again makes it. +var ErrPlanChanged = errors.New("the plan changed after it was pushed to Calendar; push again") + +// ErrFrozen is a generation of, or a change to an item of, a Supply run +// frozen into the Supply archive, which is read-only. var ErrFrozen = errors.New("the week's Supply run is in the Supply archive and can no longer change") +// ErrListNotFound is a read of a week with no Supply run. +var ErrListNotFound = errors.New("the week has no Supply run") + +// ErrItemNotFound is a change to an item on no Supply run. +var ErrItemNotFound = errors.New("no Supply run has that item") + // Service generates Supply runs. type Service struct { st *store.Store @@ -66,7 +77,31 @@ type List struct { // the week label stands in for it. Title *string `json:"title"` Status string `json:"status"` - Groups []Group `json:"groups"` + // SentToChatAt is when the list was sent to Google Chat; nil until it + // is. + SentToChatAt *string `json:"sent_to_chat_at"` + Groups []Group `json:"groups"` +} + +// Counts are how many items of a Supply run are still needed and how many +// it has in all. +type Counts struct { + Needed int `json:"needed_count"` + Total int `json:"total_count"` +} + +// Counts counts l's items. +func (l List) Counts() Counts { + var c Counts + for _, g := range l.Groups { + for _, it := range g.Items { + c.Total++ + if it.Needed { + c.Needed++ + } + } + } + return c } // Group is the items of one plan day's dinner. @@ -135,6 +170,26 @@ type Generated struct { // archive is ErrFrozen. A fetched recipe's ingredient without a name is an // error: grounding always names one. func (s *Service) Generate(ctx context.Context, weekStart store.LocalDate) (Generated, error) { + return s.generate(ctx, weekStart, func(db.WeekPlan) error { return nil }) +} + +// GeneratePushed makes the Supply run of the week starting weekStart as +// Generate does, from the plan a Push to Calendar pushed at revision. A +// plan edited since (another revision) is ErrPlanChanged, and the Supply +// run is left as it was. +func (s *Service) GeneratePushed(ctx context.Context, weekStart store.LocalDate, revision int64) (Generated, error) { + return s.generate(ctx, weekStart, func(plan db.WeekPlan) error { + if plan.Revision != revision { + return ErrPlanChanged + } + return nil + }) +} + +// generate makes the Supply run of the week starting weekStart from the +// week's plan, as Generate says, once check accepts that plan, in the same +// transaction. +func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check func(db.WeekPlan) error) (Generated, error) { if _, err := household.WeekDays(weekStart); err != nil { return Generated{}, fmt.Errorf("shopping: generate: %w", err) } @@ -148,6 +203,9 @@ func (s *Service) Generate(ctx context.Context, weekStart store.LocalDate) (Gene if err != nil { return fmt.Errorf("shopping: read plan: %w", err) } + if err := check(plan); err != nil { + return err + } want, skipped, err := wanted(ctx, q, plan.ID) if err != nil { return err @@ -163,7 +221,7 @@ func (s *Service) Generate(ctx context.Context, weekStart store.LocalDate) (Gene err = q.CreateShoppingList(ctx, db.CreateShoppingListParams{ ID: list.ID, WeekStart: list.WeekStart, PlanID: list.PlanID, CreatedAt: now, UpdatedAt: now, }) - case list.FrozenAt.Valid || list.Status == StatusArchived: + case frozen(list): return ErrFrozen default: list.PlanID = plan.ID @@ -176,17 +234,7 @@ func (s *Service) Generate(ctx context.Context, weekStart store.LocalDate) (Gene if err != nil { return err } - out = Generated{ - List: List{ - ID: list.ID, - WeekStart: list.WeekStart, - PlanID: list.PlanID, - Title: nullString(list.Title), - Status: list.Status, - Groups: groups, - }, - Skipped: skipped, - } + out = Generated{List: listOf(list, groups), Skipped: skipped} return nil }) if err != nil { @@ -195,6 +243,116 @@ func (s *Service) Generate(ctx context.Context, weekStart store.LocalDate) (Gene return out, nil } +// Get returns the Supply run of the week starting weekStart, as stored: a +// week with none is ErrListNotFound. +func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, err error) { + if _, err := household.WeekDays(weekStart); err != nil { + return List{}, fmt.Errorf("shopping: get: %w", err) + } + tx, err := s.st.Reader.BeginTx(ctx, nil) + if err != nil { + return List{}, fmt.Errorf("shopping: begin read: %w", err) + } + defer func() { + err = errors.Join(err, tx.Rollback()) + }() + q := db.New(tx) + list, err := q.GetShoppingList(ctx, weekStart.String()) + if errors.Is(err, sql.ErrNoRows) { + return List{}, ErrListNotFound + } + if err != nil { + return List{}, fmt.Errorf("shopping: read list: %w", err) + } + rows, err := q.ListShoppingGroupDinners(ctx, list.ID) + if err != nil { + return List{}, fmt.Errorf("shopping: read groups: %w", err) + } + items, err := q.ListShoppingItems(ctx, list.ID) + if err != nil { + return List{}, fmt.Errorf("shopping: read items: %w", err) + } + byGroup := make(map[string][]Item, len(rows)) + for _, r := range items { + it := r.ShoppingItem + byGroup[it.GroupID] = append(byGroup[it.GroupID], Item{ + ID: it.ID, Name: it.Name, Qty: nullString(it.Qty), Needed: it.Needed == 1, Position: int(it.Position), + }) + } + groups := make([]Group, 0, len(rows)) + for _, r := range rows { + g := r.ShoppingGroup + groups = append(groups, Group{ + ID: g.ID, DinnerID: g.DinnerID, DinnerName: r.DinnerName, Date: g.Date, Position: int(g.Position), + Items: byGroup[g.ID], + }) + } + return listOf(list, groups), nil +} + +// SetNeeded marks item itemID needed, or "have it" when needed is false, +// and returns its Supply run's counts after the change. An item on no +// Supply run is ErrItemNotFound; one on a list frozen into the Supply +// archive is ErrFrozen, and is left as it was. +func (s *Service) SetNeeded(ctx context.Context, itemID string, needed bool) (Counts, error) { + var c Counts + err := s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + row, err := q.GetShoppingItemList(ctx, itemID) + if errors.Is(err, sql.ErrNoRows) { + return ErrItemNotFound + } + if err != nil { + return fmt.Errorf("shopping: read list of item %s: %w", itemID, err) + } + if frozen(row.ShoppingList) { + return ErrFrozen + } + flag := int64(0) + if needed { + flag = 1 + } + if err := q.SetShoppingItemNeeded(ctx, db.SetShoppingItemNeededParams{ + Needed: flag, UpdatedAt: store.Now().String(), ID: itemID, + }); err != nil { + return fmt.Errorf("shopping: mark item %s: %w", itemID, err) + } + items, err := q.ListShoppingItems(ctx, row.ShoppingList.ID) + if err != nil { + return fmt.Errorf("shopping: read items: %w", err) + } + for _, r := range items { + c.Total++ + if r.ShoppingItem.Needed == 1 { + c.Needed++ + } + } + return nil + }) + if err != nil { + return Counts{}, err + } + return c, nil +} + +// frozen reports whether list is frozen into the Supply archive. +func frozen(list db.ShoppingList) bool { + return list.FrozenAt.Valid || list.Status == StatusArchived +} + +// listOf is list with groups. +func listOf(list db.ShoppingList, groups []Group) List { + return List{ + ID: list.ID, + WeekStart: list.WeekStart, + PlanID: list.PlanID, + Title: nullString(list.Title), + Status: list.Status, + SentToChatAt: nullString(list.SentToChatAt), + Groups: groups, + } +} + // wantGroup is a group the plan asks for, before it is stored. type wantGroup struct { date, dinnerID, dinnerName string diff --git a/internal/shopping/shopping_test.go b/internal/shopping/shopping_test.go index 7aa5035..733ff53 100644 --- a/internal/shopping/shopping_test.go +++ b/internal/shopping/shopping_test.go @@ -561,6 +561,33 @@ func TestGenerateRefusesFrozenList(t *testing.T) { wantLines(t, "stored list", e.stored(t), []string{"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed"}) } +// TestGeneratePushedRefusesEditedPlan makes the Supply run of a pushed +// plan at the revision pushed, then edits the plan: the pushed revision no +// longer makes one, and the Supply run is left as it was. +func TestGeneratePushedRefusesEditedPlan(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) + e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}) + e.plan(t, "Beef Chili") + q := db.New(e.st.Writer) + pushed, err := q.GetWeekPlan(t.Context(), week.String()) + if err != nil { + t.Fatal(err) + } + if _, err := e.svc.GeneratePushed(t.Context(), week, pushed.Revision); err != nil { + t.Fatalf("GeneratePushed at the pushed revision: %v", err) + } + e.swap(t, "2026-10-12", "Pad Thai") + if err := q.MarkWeekPlanEdited(t.Context(), db.MarkWeekPlanEditedParams{UpdatedAt: store.Now().String(), ID: e.planID}); err != nil { + t.Fatal(err) + } + + if _, err := e.svc.GeneratePushed(t.Context(), week, pushed.Revision); !errors.Is(err, shopping.ErrPlanChanged) { + t.Fatalf("GeneratePushed after an edit = %v, want ErrPlanChanged", err) + } + wantLines(t, "stored list", e.stored(t), []string{"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed"}) +} + // TestGenerateRefusesUnnamedIngredient: grounding always names a fetched // recipe's ingredient, so one without a name is an error, and nothing is // stored. @@ -580,3 +607,97 @@ func TestGenerateRefusesUnnamedIngredient(t *testing.T) { t.Errorf("%d lists stored, want none", n) } } + +// TestGetReadsStoredList reads back what Generate stored, with what the +// household marked, and counts it. +func TestGetReadsStoredList(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}) + e.dinner(t, "Pot Roast", ing{"carrots", "4", ""}) + e.plan(t, "Beef Chili", "Pot Roast") + made := e.generate(t).List + e.haveIt(t, made, "Beef Chili", "onion") + + got, err := e.svc.Get(t.Context(), week) + if err != nil { + t.Fatal(err) + } + wantLines(t, "read list", lines(got), []string{ + "0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed", + "0 2026-10-12 Beef Chili: 1 onion [1] have it", + "1 2026-10-13 Pot Roast: 0 carrots [4] needed", + }) + if got.ID != made.ID || got.WeekStart != week.String() || got.Status != shopping.StatusCurrent || got.Title != nil || got.SentToChatAt != nil { + t.Errorf("read list = %+v", got) + } + if c := got.Counts(); c != (shopping.Counts{Needed: 2, Total: 3}) { + t.Errorf("Counts = %+v, want 2 of 3", c) + } +} + +func TestGetWeekWithoutList(t *testing.T) { + e := newEnv(t) + if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, shopping.ErrListNotFound) { + t.Fatalf("Get = %v, want ErrListNotFound", err) + } + if _, err := e.svc.Get(t.Context(), mustDate("2026-10-13")); err == nil { + t.Fatal("Get of a Tuesday succeeded") + } +} + +// TestSetNeeded marks items and returns the counts after each change. +func TestSetNeeded(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}, ing{"garlic", "2", "cloves"}) + e.plan(t, "Beef Chili") + l := e.generate(t).List + onion, garlic := l.Groups[0].Items[1].ID, l.Groups[0].Items[2].ID + + for _, step := range []struct { + id string + needed bool + want shopping.Counts + }{ + {onion, false, shopping.Counts{Needed: 2, Total: 3}}, + {onion, false, shopping.Counts{Needed: 2, Total: 3}}, + {garlic, false, shopping.Counts{Needed: 1, Total: 3}}, + {onion, true, shopping.Counts{Needed: 2, Total: 3}}, + } { + c, err := e.svc.SetNeeded(t.Context(), step.id, step.needed) + if err != nil { + t.Fatal(err) + } + if c != step.want { + t.Errorf("SetNeeded(%s, %v) = %+v, want %+v", step.id, step.needed, c, step.want) + } + } + wantLines(t, "stored list", e.stored(t), []string{ + "0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed", + "0 2026-10-12 Beef Chili: 1 onion [1] needed", + "0 2026-10-12 Beef Chili: 2 garlic [2 cloves] have it", + }) +} + +func TestSetNeededUnknownItem(t *testing.T) { + e := newEnv(t) + if _, err := e.svc.SetNeeded(t.Context(), store.NewID(), false); !errors.Is(err, shopping.ErrItemNotFound) { + t.Fatalf("SetNeeded = %v, want ErrItemNotFound", err) + } +} + +// TestSetNeededRefusesFrozenList leaves an item of a list in the Supply +// archive as it was. +func TestSetNeededRefusesFrozenList(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) + e.plan(t, "Beef Chili") + l := e.generate(t).List + if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil { + t.Fatal(err) + } + + if _, err := e.svc.SetNeeded(t.Context(), l.Groups[0].Items[0].ID, false); !errors.Is(err, shopping.ErrFrozen) { + t.Fatalf("SetNeeded = %v, want ErrFrozen", err) + } + wantLines(t, "stored list", e.stored(t), []string{"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed"}) +} diff --git a/migrations/queries/shopping.sql b/migrations/queries/shopping.sql index aefdc7b..59f8451 100644 --- a/migrations/queries/shopping.sql +++ b/migrations/queries/shopping.sql @@ -58,3 +58,26 @@ WHERE id = ?; -- name: DeleteShoppingItem :exec DELETE FROM shopping_items WHERE id = ?; + +-- name: ListShoppingGroupDinners :many +-- The groups of list list_id with their dinner's name, in list order. +SELECT sqlc.embed(shopping_groups), dinners.name AS dinner_name +FROM shopping_groups +JOIN dinners ON dinners.id = shopping_groups.dinner_id +WHERE shopping_groups.list_id = ? +ORDER BY shopping_groups.position, shopping_groups.id; + +-- name: GetShoppingItemList :one +-- The Supply run holding item id. Returns sql.ErrNoRows when there is no +-- such item. +SELECT sqlc.embed(shopping_lists) +FROM shopping_items +JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id +JOIN shopping_lists ON shopping_lists.id = shopping_groups.list_id +WHERE shopping_items.id = ?; + +-- name: SetShoppingItemNeeded :exec +-- Marks item id needed (1) or "have it" (0). +UPDATE shopping_items +SET needed = ?, updated_at = ? +WHERE id = ?;