Add the Supply run API, item toggles and the Push to Calendar hook
GET /api/v1/shopping/{weekStart} reads the week's Supply run with its
week label, counts and groups by plan day; POST
/api/v1/shopping/{weekStart}/generate makes or remakes it through
shopping.Generate; PATCH /api/v1/shopping/items/{id} sets an item's
needed flag and answers with the list's new counts, 409 archived once
the list is in the Supply archive and 404 for an unknown item.
A successful Push to Calendar now makes the pushed week's Supply run.
Generation runs after the push is recorded and never fails or undoes
it: the 200 answer reports supply_run generated, archived or failed,
and a failure is logged as an error.
Maestro task d076480c-6c59-4c57-9023-6941badb4520.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+7
-1
@@ -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
|
||||
}
|
||||
|
||||
+28
-3
@@ -738,9 +738,34 @@ 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.Generate` for the week
|
||||
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
|
||||
`supply_run_failed`, retryable, logged as an error) when it could not
|
||||
be made. Generation never fails or undoes the push — Calendar already
|
||||
shows the plan — and `POST /api/v1/shopping/{weekStart}/generate`
|
||||
makes it again.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,9 +96,35 @@ 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
|
||||
// could not be made, when error says so 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 as an error, so nothing claims a Supply run
|
||||
// that was not made.
|
||||
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 {
|
||||
@@ -107,7 +135,22 @@ func postPlanPush(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||
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.Generate(ctx, week); 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 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})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+145
-15
@@ -39,10 +39,16 @@ 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.
|
||||
// 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 +72,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.
|
||||
@@ -163,7 +193,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 +206,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 +215,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
|
||||
|
||||
@@ -580,3 +580,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"})
|
||||
}
|
||||
|
||||
@@ -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 = ?;
|
||||
|
||||
Reference in New Issue
Block a user