Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 a31bbce1dc Make the Supply run from the plan revision Push to Calendar pushed
CI / ci (pull_request) Successful in 5m40s
CI / ci (push) Successful in 5m6s
A plan edit landing between a successful Push and the Generate that
followed it made the Supply run from the newer plan while the push answer
reported supply_run generated, so the list could hold a dinner Calendar
never showed (MQ-133-01).

Push now returns the revision it pushed, and the push hook calls
shopping.GeneratePushed, which checks inside its transaction that the
week's plan is still at that revision (week_plans.revision, as the draft
path does) and otherwise fails with ErrPlanChanged, leaving the Supply run
as it was. The push answer then reports supply_run failed with error
plan_changed (retryable: push again), logged as a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 04:59:16 -04:00
dev_agentandClaude Opus 5.5 6aa5113488 Add the Supply run API, item toggles and the Push to Calendar hook
CI / ci (push) Successful in 4m51s
CI / ci (pull_request) Successful in 4m49s
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>
2026-10-08 04:28:16 -04:00
orchestrator-agent 7f7a91db0d Shopping list generation from the plan (Feature 17) (#132)
CI / ci (push) Successful in 4m55s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 04:17:03 -04:00
30 changed files with 2860 additions and 79 deletions
+7 -1
View File
@@ -28,6 +28,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "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/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server" "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/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
@@ -132,6 +133,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err return err
} }
shopSvc, err := shopping.NewService(db)
if err != nil {
return err
}
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil { if err != nil {
return err return err
@@ -148,7 +154,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err 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 { if err != nil {
return err return err
} }
+34 -5
View File
@@ -102,8 +102,8 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at, revision (raised by every swap and stored draft). | | `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at, revision (raised by every swap and stored draft). |
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). | | `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). |
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. | | `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. |
| `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. | | `shopping_lists` | week_start (unique), plan_id, title (the AI-written post title; NULL until written or when the AI fails, when the week label is shown instead; never empty), status (`current` / `archived`), sent_to_chat_at, frozen_at. |
| `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. | | `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by `shopping.Generate` from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged. |
| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. | | `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
| `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url (the SearXNG instance), api_key_ct and api_key_last4 (Brave). | | `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url (the SearXNG instance), api_key_ct and api_key_last4 (Brave). |
| `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. | | `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. |
@@ -738,9 +738,38 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
failures), 404 when the week failures), 404 when the week
has no plan, 400 `invalid_week`. The push holds the account lock has no plan, 400 `invalid_week`. The push holds the account lock
across its Google requests and its record, like listing and choosing. 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 - The week's Supply run (Feature 17) is made from a pushed plan: after
successful `Push` returns that plan, which is where Feature 17 hooks a successful `Push`, the handler runs `shopping.GeneratePushed` for
in. Nothing here generates a shopping list. 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 ## Frontend
+2 -1
View File
@@ -258,6 +258,7 @@ type ShoppingGroup struct {
ID string ID string
ListID string ListID string
DinnerID string DinnerID string
Date string
Position int64 Position int64
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
@@ -278,7 +279,7 @@ type ShoppingList struct {
ID string ID string
WeekStart string WeekStart string
PlanID string PlanID string
Title string Title sql.NullString
Status string Status string
SentToChatAt sql.NullString SentToChatAt sql.NullString
FrozenAt sql.NullString FrozenAt sql.NullString
+28
View File
@@ -43,6 +43,11 @@ type Querier interface {
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
CreateRotationReview(ctx context.Context, arg CreateRotationReviewParams) error CreateRotationReview(ctx context.Context, arg CreateRotationReviewParams) error
CreateShoppingGroup(ctx context.Context, arg CreateShoppingGroupParams) error
// A new item is needed until the household marks it "have it".
CreateShoppingItem(ctx context.Context, arg CreateShoppingItemParams) error
// A new, untitled Supply run: the title is written later, by the AI.
CreateShoppingList(ctx context.Context, arg CreateShoppingListParams) error
// name is the name the dish is suggested under, kept whatever its dinner is // name is the name the dish is suggested under, kept whatever its dinner is
// later renamed. // later renamed.
CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error
@@ -59,6 +64,8 @@ type Querier interface {
DeleteIntegrationsGoogle(ctx context.Context) error DeleteIntegrationsGoogle(ctx context.Context) error
DeleteRecipeIngredients(ctx context.Context, recipeID string) error DeleteRecipeIngredients(ctx context.Context, recipeID string) error
DeleteRecipeSteps(ctx context.Context, recipeID string) error DeleteRecipeSteps(ctx context.Context, recipeID string) error
DeleteShoppingGroup(ctx context.Context, id string) error
DeleteShoppingItem(ctx context.Context, id string) error
FailJob(ctx context.Context, arg FailJobParams) (int64, error) FailJob(ctx context.Context, arg FailJobParams) (int64, error)
// Fails every unarchived dinner still pending whose grounding job for its // Fails every unarchived dinner still pending whose grounding job for its
// current grounding_revision is no longer queued or running: the process // current grounding_revision is no longer queued or running: the process
@@ -113,6 +120,12 @@ type Querier interface {
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error) GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
// Returns sql.ErrNoRows until the row exists. // Returns sql.ErrNoRows until the row exists.
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error) 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)
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id. // A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error) GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error)
// The id of the job that runs batch batch_id. // The id of the job that runs batch batch_id.
@@ -164,6 +177,13 @@ type Querier interface {
ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error) ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error)
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error) ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, 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
// group, then by item.
ListShoppingItems(ctx context.Context, listID string) ([]ListShoppingItemsRow, error)
// The ids of the dinners with status quest_log planned on date, on a plan // The ids of the dinners with status quest_log planned on date, on a plan
// of any status, that have no Rotation check for date yet. // of any status, that have no Rotation check for date yet.
ListTriedCandidates(ctx context.Context, date string) ([]string, error) ListTriedCandidates(ctx context.Context, date string) ([]string, error)
@@ -191,6 +211,10 @@ type Querier interface {
// becomes synced. A plan edited since (another revision) is left as it is // becomes synced. A plan edited since (another revision) is left as it is
// (0 rows). // (0 rows).
MarkWeekPlanSynced(ctx context.Context, arg MarkWeekPlanSyncedParams) (int64, error) MarkWeekPlanSynced(ctx context.Context, arg MarkWeekPlanSyncedParams) (int64, error)
MoveShoppingGroup(ctx context.Context, arg MoveShoppingGroupParams) error
// Places item id in group group_id at position with quantity qty; its
// name and needed flag are kept.
MoveShoppingItem(ctx context.Context, arg MoveShoppingItemParams) error
// Moves a dinner still suggested to status (quest_log or archived), // Moves a dinner still suggested to status (quest_log or archived),
// recording updated_by; a dinner no longer suggested is left as it is // recording updated_by; a dinner no longer suggested is left as it is
// (0 rows). // (0 rows).
@@ -208,6 +232,8 @@ type Querier interface {
// Records a new draft over plan id: its status and note, raising its // Records a new draft over plan id: its status and note, raising its
// revision. // revision.
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
// Records a generation of list id from plan plan_id.
RegenerateShoppingList(ctx context.Context, arg RegenerateShoppingListParams) error
// An unknown id returns sql.ErrNoRows. // An unknown id returns sql.ErrNoRows.
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
// Sets the chosen calendar; affects no row when nothing is connected. // Sets the chosen calendar; affects no row when nothing is connected.
@@ -215,6 +241,8 @@ type Querier interface {
// Records that the event calendar_event_id on Calendar shows date of // Records that the event calendar_event_id on Calendar shows date of
// plan_id as planned. // plan_id as planned.
SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error) 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 // Marks a pending batch running; a batch in any other status is left as
// it is (0 rows). // it is (0 rows).
StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error) StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error)
+380
View File
@@ -0,0 +1,380 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: shopping.sql
package db
import (
"context"
"database/sql"
)
const createShoppingGroup = `-- name: CreateShoppingGroup :exec
INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`
type CreateShoppingGroupParams struct {
ID string
ListID string
DinnerID string
Date string
Position int64
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateShoppingGroup(ctx context.Context, arg CreateShoppingGroupParams) error {
_, err := q.db.ExecContext(ctx, createShoppingGroup,
arg.ID,
arg.ListID,
arg.DinnerID,
arg.Date,
arg.Position,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const createShoppingItem = `-- name: CreateShoppingItem :exec
INSERT INTO shopping_items (id, group_id, name, qty, needed, position, created_at, updated_at)
VALUES (?, ?, ?, ?, 1, ?, ?, ?)
`
type CreateShoppingItemParams struct {
ID string
GroupID string
Name string
Qty sql.NullString
Position int64
CreatedAt string
UpdatedAt string
}
// A new item is needed until the household marks it "have it".
func (q *Queries) CreateShoppingItem(ctx context.Context, arg CreateShoppingItemParams) error {
_, err := q.db.ExecContext(ctx, createShoppingItem,
arg.ID,
arg.GroupID,
arg.Name,
arg.Qty,
arg.Position,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const createShoppingList = `-- name: CreateShoppingList :exec
INSERT INTO shopping_lists (id, week_start, plan_id, status, created_at, updated_at)
VALUES (?, ?, ?, 'current', ?, ?)
`
type CreateShoppingListParams struct {
ID string
WeekStart string
PlanID string
CreatedAt string
UpdatedAt string
}
// A new, untitled Supply run: the title is written later, by the AI.
func (q *Queries) CreateShoppingList(ctx context.Context, arg CreateShoppingListParams) error {
_, err := q.db.ExecContext(ctx, createShoppingList,
arg.ID,
arg.WeekStart,
arg.PlanID,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const deleteShoppingGroup = `-- name: DeleteShoppingGroup :exec
DELETE FROM shopping_groups
WHERE id = ?
`
func (q *Queries) DeleteShoppingGroup(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deleteShoppingGroup, id)
return err
}
const deleteShoppingItem = `-- name: DeleteShoppingItem :exec
DELETE FROM shopping_items
WHERE id = ?
`
func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deleteShoppingItem, id)
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 = ?
`
// The Supply run of the week starting week_start. Returns sql.ErrNoRows
// when there is none.
func (q *Queries) GetShoppingList(ctx context.Context, weekStart string) (ShoppingList, error) {
row := q.db.QueryRowContext(ctx, getShoppingList, weekStart)
var i ShoppingList
err := row.Scan(
&i.ID,
&i.WeekStart,
&i.PlanID,
&i.Title,
&i.Status,
&i.SentToChatAt,
&i.FrozenAt,
&i.CreatedAt,
&i.UpdatedAt,
)
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 = ?
ORDER BY position, id
`
// The groups of list list_id, in list order.
func (q *Queries) ListShoppingGroups(ctx context.Context, listID string) ([]ShoppingGroup, error) {
rows, err := q.db.QueryContext(ctx, listShoppingGroups, listID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ShoppingGroup
for rows.Next() {
var i ShoppingGroup
if err := rows.Scan(
&i.ID,
&i.ListID,
&i.DinnerID,
&i.Date,
&i.Position,
&i.CreatedAt,
&i.UpdatedAt,
); 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 listShoppingItems = `-- name: ListShoppingItems :many
SELECT shopping_items.id, shopping_items.group_id, shopping_items.name, shopping_items.qty, shopping_items.needed, shopping_items.position, shopping_items.created_at, shopping_items.updated_at, shopping_groups.dinner_id
FROM shopping_items
JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id
WHERE shopping_groups.list_id = ?
ORDER BY shopping_groups.position, shopping_items.position, shopping_items.id
`
type ListShoppingItemsRow struct {
ShoppingItem ShoppingItem
DinnerID string
}
// The items of list list_id with their group's dinner, in list order: by
// group, then by item.
func (q *Queries) ListShoppingItems(ctx context.Context, listID string) ([]ListShoppingItemsRow, error) {
rows, err := q.db.QueryContext(ctx, listShoppingItems, listID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListShoppingItemsRow
for rows.Next() {
var i ListShoppingItemsRow
if err := rows.Scan(
&i.ShoppingItem.ID,
&i.ShoppingItem.GroupID,
&i.ShoppingItem.Name,
&i.ShoppingItem.Qty,
&i.ShoppingItem.Needed,
&i.ShoppingItem.Position,
&i.ShoppingItem.CreatedAt,
&i.ShoppingItem.UpdatedAt,
&i.DinnerID,
); 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 moveShoppingGroup = `-- name: MoveShoppingGroup :exec
UPDATE shopping_groups
SET position = ?, updated_at = ?
WHERE id = ?
`
type MoveShoppingGroupParams struct {
Position int64
UpdatedAt string
ID string
}
func (q *Queries) MoveShoppingGroup(ctx context.Context, arg MoveShoppingGroupParams) error {
_, err := q.db.ExecContext(ctx, moveShoppingGroup, arg.Position, arg.UpdatedAt, arg.ID)
return err
}
const moveShoppingItem = `-- name: MoveShoppingItem :exec
UPDATE shopping_items
SET group_id = ?, qty = ?, position = ?, updated_at = ?
WHERE id = ?
`
type MoveShoppingItemParams struct {
GroupID string
Qty sql.NullString
Position int64
UpdatedAt string
ID string
}
// Places item id in group group_id at position with quantity qty; its
// name and needed flag are kept.
func (q *Queries) MoveShoppingItem(ctx context.Context, arg MoveShoppingItemParams) error {
_, err := q.db.ExecContext(ctx, moveShoppingItem,
arg.GroupID,
arg.Qty,
arg.Position,
arg.UpdatedAt,
arg.ID,
)
return err
}
const regenerateShoppingList = `-- name: RegenerateShoppingList :exec
UPDATE shopping_lists
SET plan_id = ?, updated_at = ?
WHERE id = ?
`
type RegenerateShoppingListParams struct {
PlanID string
UpdatedAt string
ID string
}
// Records a generation of list id from plan plan_id.
func (q *Queries) RegenerateShoppingList(ctx context.Context, arg RegenerateShoppingListParams) error {
_, 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
}
+7
View File
@@ -68,6 +68,13 @@ const (
manualSourcePrefix = "mealquest:manual:" manualSourcePrefix = "mealquest:manual:"
) )
// IsManualRecipe reports whether sourceURL, a recipes.source_url, is that
// of a recipe entered by hand. Its ingredients are stored as typed, in
// raw_text only: no model normalizes them, so name, qty and unit are NULL.
func IsManualRecipe(sourceURL string) bool {
return strings.HasPrefix(sourceURL, manualSourcePrefix)
}
// GroundTimeout bounds grounding a candidate or link by hand, which runs in // GroundTimeout bounds grounding a candidate or link by hand, which runs in
// the request: the page fetch (recipes.Timeout), its photo download and // the request: the page fetch (recipes.Timeout), its photo download and
// normalize_recipe (harness.DefaultTimeout), with some slack. // normalize_recipe (harness.DefaultTimeout), with some slack.
+7 -7
View File
@@ -84,20 +84,20 @@ type pushDay struct {
// //
// A successful Push is the household confirming the week's plan, which is // 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 // what the week's Supply run (Feature 17) is made from; the returned plan
// is the one pushed. // is the one pushed, and revision is its revision as pushed, so the Supply
func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (Plan, error) { // 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 { 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) planID, revision, days, err := s.pushDays(ctx, weekStart)
if err != nil { if err != nil {
return Plan{}, err return Plan{}, 0, err
} }
events := make([]gcal.Event, len(days)) events := make([]gcal.Event, len(days))
for i, d := range days { for i, d := range days {
events[i] = d.event events[i] = d.event
} }
var p Plan
record := func() error { record := func() error {
return s.st.WithTx(ctx, func(tx *sql.Tx) error { return s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx) 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 { 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 // pushDays reads, in one read transaction, the plan of the week starting
+31 -31
View File
@@ -135,7 +135,7 @@ func TestPushCreatesOneEventPerDay(t *testing.T) {
e.connectCalendar(t) e.connectCalendar(t)
dates := e.storePlan(t, week, fullWeek...) 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 { if err != nil {
t.Fatalf("Push: %v", err) t.Fatalf("Push: %v", err)
} }
@@ -197,7 +197,7 @@ func TestPushAgainUpdatesOnly(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.connectCalendar(t) e.connectCalendar(t)
e.storePlan(t, week, fullWeek...) 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) t.Fatal(err)
} }
// The household deletes Monday's event in Google Calendar; Google keeps // 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.CancelEvent(dinnersCal.ID, monday)
e.google.ResetEventWrites() e.google.ResetEventWrites()
p, err := e.svc.Push(t.Context(), week) p, _, err := e.svc.Push(t.Context(), week)
if err != nil { if err != nil {
t.Fatalf("second Push: %v", err) t.Fatalf("second Push: %v", err)
} }
@@ -229,7 +229,7 @@ func TestSwapThenPushUpdatesThatDay(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.connectCalendar(t) e.connectCalendar(t)
e.storePlan(t, week, fullWeek...) 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) t.Fatal(err)
} }
before := e.google.Events(dinnersCal.ID) before := e.google.Events(dinnersCal.ID)
@@ -247,7 +247,7 @@ func TestSwapThenPushUpdatesThatDay(t *testing.T) {
t.Errorf("swapped day kept event id %q", id) 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 { if err != nil {
t.Fatalf("Push: %v", err) t.Fatalf("Push: %v", err)
} }
@@ -296,7 +296,7 @@ func TestPushAcrossDaylightSavingTime(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.connectCalendar(t) e.connectCalendar(t)
dates := e.storePlan(t, tc.week, fullWeek...) 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) t.Fatalf("Push: %v", err)
} }
events := e.google.Events(dinnersCal.ID) events := e.google.Events(dinnersCal.ID)
@@ -330,12 +330,12 @@ func TestPushAcrossDaylightSavingTime(t *testing.T) {
func TestPushNeedsAChosenCalendar(t *testing.T) { func TestPushNeedsAChosenCalendar(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.storePlan(t, week, fullWeek...) 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) t.Errorf("Push with nothing connected = %v, want ErrNotConnected", err)
} }
e.google.SetCalendars(0, dinnersCal) e.google.SetCalendars(0, dinnersCal)
e.google.Connect(t, e.gcal) 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) t.Errorf("Push with no calendar chosen = %v, want ErrNoCalendar", err)
} }
if inserts, updates := e.google.EventWrites(); inserts+updates != 0 { if inserts, updates := e.google.EventWrites(); inserts+updates != 0 {
@@ -348,7 +348,7 @@ func TestPushWithoutAPlan(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.connectCalendar(t) e.connectCalendar(t)
before := e.google.TokenCalls() 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) t.Errorf("Push = %v, want ErrNotFound", err)
} }
if n := e.google.TokenCalls() - before; n != 0 { 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. // Google fails the fourth event on every try.
e.google.FailEventWrites(4, 3, http.StatusServiceUnavailable) 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 var calendar *gcal.CalendarError
if !errors.As(err, &calendar) || !calendar.Retryable() { if !errors.As(err, &calendar) || !calendar.Retryable() {
t.Fatalf("Push = %v, want a retryable *gcal.CalendarError", err) 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, // Pushing again writes every day: the three already there are updated,
// not added twice. // not added twice.
e.google.ResetEventWrites() e.google.ResetEventWrites()
p, err := e.svc.Push(t.Context(), week) p, _, err := e.svc.Push(t.Context(), week)
if err != nil { if err != nil {
t.Fatalf("retried Push: %v", err) t.Fatalf("retried Push: %v", err)
} }
@@ -393,7 +393,7 @@ func TestPushPartialFailureKeepsAChangedPlanChanged(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.connectCalendar(t) e.connectCalendar(t)
e.storePlan(t, week, fullWeek...) e.storePlan(t, week, fullWeek...)
first, err := e.svc.Push(t.Context(), week) first, _, err := e.svc.Push(t.Context(), week)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -403,7 +403,7 @@ func TestPushPartialFailureKeepsAChangedPlanChanged(t *testing.T) {
} }
} }
e.google.FailEventWrite(2, http.StatusBadRequest) 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") t.Fatal("Push succeeded, want the second event to fail")
} }
p, err := e.svc.Get(t.Context(), week) p, err := e.svc.Get(t.Context(), week)
@@ -431,7 +431,7 @@ func TestPushToStaleCalendar(t *testing.T) {
e.connectCalendar(t) e.connectCalendar(t)
e.storePlan(t, week, fullWeek...) e.storePlan(t, week, fullWeek...)
edit(e.google) 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) t.Errorf("Push = %v, want ErrCalendarUnavailable", err)
} }
e.assertUnsynced(t, week, plan.StatusDraft) e.assertUnsynced(t, week, plan.StatusDraft)
@@ -444,7 +444,7 @@ func TestPushGrantNoLongerAccepted(t *testing.T) {
e.connectCalendar(t) e.connectCalendar(t)
e.storePlan(t, week, fullWeek...) e.storePlan(t, week, fullWeek...)
e.google.RevokeGrant() e.google.RevokeGrant()
_, err := e.svc.Push(t.Context(), week) _, _, err := e.svc.Push(t.Context(), week)
if !errors.Is(err, gcal.ErrReconnect) { if !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Push = %v, want ErrReconnect", err) t.Fatalf("Push = %v, want ErrReconnect", err)
} }
@@ -479,12 +479,12 @@ func TestRefusedGrantKeepsPushedWeeks(t *testing.T) {
dates := map[store.LocalDate][]string{} dates := map[store.LocalDate][]string{}
for _, w := range pushedWeeks { for _, w := range pushedWeeks {
dates[w] = e.storePlan(t, w, fullWeek...) 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) t.Fatalf("Push %s: %v", w, err)
} }
} }
e.google.RevokeGrant() 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) t.Fatalf("Push = %v, want ErrReconnect", err)
} }
e.assertRefused(t) e.assertRefused(t)
@@ -497,7 +497,7 @@ func TestRefusedGrantKeepsPushedWeeks(t *testing.T) {
// The refused token is not offered to Google again. // The refused token is not offered to Google again.
before := e.google.TokenCalls() 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) t.Fatalf("Push again = %v, want ErrReconnect", err)
} }
if n := e.google.TokenCalls() - before; n != 0 { 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.Connect(t, e.gcal)
e.google.ResetEventWrites() 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 { if err != nil || p.Status != plan.StatusSynced {
t.Fatalf("Push after connecting again = %s, %v; want synced", p.Status, err) 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 := newEnv(t)
e.connectCalendar(t) e.connectCalendar(t)
e.storePlan(t, week, fullWeek...) 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) t.Fatal(err)
} }
e.google.RevokeGrant() e.google.RevokeGrant()
@@ -551,7 +551,7 @@ func TestPushFailsWhenPlanSwappedWhilePushing(t *testing.T) {
defer release() defer release()
pushed := make(chan error, 1) pushed := make(chan error, 1)
go func() { go func() {
_, err := e.svc.Push(t.Context(), week) _, _, err := e.svc.Push(t.Context(), week)
pushed <- err pushed <- err
}() }()
<-arrived <-arrived
@@ -596,7 +596,7 @@ func TestPushAfterChoosingAnotherCalendarWritesTheWholeWeek(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.connectCalendar(t) e.connectCalendar(t)
dates := e.storePlan(t, week, fullWeek...) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -640,14 +640,14 @@ func TestPushAfterChoosingAnotherCalendarWritesTheWholeWeek(t *testing.T) {
// A push that fails part way leaves the plan changed. // A push that fails part way leaves the plan changed.
e.google.ResetEventWrites() e.google.ResetEventWrites()
e.google.FailEventWrite(4, http.StatusBadRequest) 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") 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 { 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) 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 { if err != nil {
t.Fatalf("Push: %v", err) t.Fatalf("Push: %v", err)
} }
@@ -678,7 +678,7 @@ func TestChoosingAnotherCalendarWaitsForAPush(t *testing.T) {
defer release() defer release()
pushed := make(chan error, 1) pushed := make(chan error, 1)
go func() { go func() {
_, err := e.svc.Push(t.Context(), week) _, _, err := e.svc.Push(t.Context(), week)
pushed <- err pushed <- err
}() }()
<-arrived <-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) t.Fatalf("Push = %s, %v; want synced", p.Status, err)
} }
e.assertWeekOn(t, kitchenCal.ID, week, dates, fullWeek...) e.assertWeekOn(t, kitchenCal.ID, week, dates, fullWeek...)
@@ -746,7 +746,7 @@ func TestDisconnectForgetsEveryPushedWeek(t *testing.T) {
dates := map[store.LocalDate][]string{} dates := map[store.LocalDate][]string{}
for _, w := range pushedWeeks { for _, w := range pushedWeeks {
dates[w] = e.storePlan(t, w, fullWeek...) 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) 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 // Connecting again chooses no calendar, so nothing is pushed until one
// is chosen; then a push writes the whole week to it. // is chosen; then a push writes the whole week to it.
e.google.Connect(t, e.gcal) 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) t.Fatalf("Push after connecting again = %v, want ErrNoCalendar", err)
} }
e.google.SetCalendars(0, dinnersCal, kitchenCal) e.google.SetCalendars(0, dinnersCal, kitchenCal)
@@ -773,7 +773,7 @@ func TestDisconnectForgetsEveryPushedWeek(t *testing.T) {
} }
for _, w := range pushedWeeks { for _, w := range pushedWeeks {
e.assertForgotten(t, w, "after connecting again") 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 { if err != nil || p.Status != plan.StatusSynced {
t.Fatalf("Push %s = %s, %v; want synced", w, p.Status, err) 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{} dates := map[store.LocalDate][]string{}
for _, w := range pushedWeeks { for _, w := range pushedWeeks {
dates[w] = e.storePlan(t, w, fullWeek...) 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) t.Fatalf("Push %s: %v", w, err)
} }
} }
@@ -816,7 +816,7 @@ func TestDisconnectWaitsForAPush(t *testing.T) {
defer release() defer release()
pushed := make(chan error, 1) pushed := make(chan error, 1)
go func() { go func() {
_, err := e.svc.Push(t.Context(), week) _, _, err := e.svc.Push(t.Context(), week)
pushed <- err pushed <- err
}() }()
<-arrived <-arrived
+1 -1
View File
@@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler {
t.Helper() t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 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), 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+1 -1
View File
@@ -92,7 +92,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
t.Fatal(err) t.Fatal(err)
} }
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+1 -1
View File
@@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv {
} }
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug})) 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), 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)}}) fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+1 -1
View File
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
t.Fatal(err) t.Fatal(err)
} }
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t), 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+1 -1
View File
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
t.Fatal(err) t.Fatal(err)
} }
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 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)}}) photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+1 -1
View File
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
} }
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil))) logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t), 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+58 -4
View File
@@ -1,6 +1,7 @@
package server package server
import ( import (
"context"
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -9,6 +10,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" "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/plan"
"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/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 // postPlanPush pushes the week's plan to the chosen Google calendar
// (Push to Calendar) and answers with the plan as synced. // (Push to Calendar) and, once the push is recorded, makes the week's
func postPlanPush(logger *slog.Logger, svc *plan.Service) http.HandlerFunc { // 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) { return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger) week, ok := weekStartOrFail(w, r, logger)
if !ok { if !ok {
return return
} }
p, err := svc.Push(r.Context(), week) p, revision, err := svc.Push(r.Context(), week)
if err != nil { if err != nil {
writePlanPushError(w, r, logger, err) writePlanPushError(w, r, logger, err)
return 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})
} }
} }
+6 -1
View File
@@ -23,6 +23,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "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/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" "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" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
) )
@@ -79,8 +80,12 @@ func newPlansRouter(t *testing.T) plansEnv {
if err != nil { if err != nil {
t.Fatal(err) 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), 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+1 -1
View File
@@ -22,7 +22,7 @@ const (
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler { func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
t.Helper() t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 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)}}) newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+14 -3
View File
@@ -19,6 +19,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "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/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "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" "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 // /decline; planSvc serves GET /api/v1/plans/{weekStart}, POST
// /api/v1/plans/{weekStart}/draft, PUT // /api/v1/plans/{weekStart}/draft, PUT
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and // /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, // /api/v1/integrations/google and its /connect, /callback and /calendars,
// PUT /api/v1/integrations/google/calendar and DELETE // PUT /api/v1/integrations/google/calendar and DELETE
// /api/v1/integrations/google; runner serves // /api/v1/integrations/google; runner serves
@@ -54,7 +59,7 @@ type RouteMounter interface {
// GET /media/photos/{id}-{size}.jpg; assets is the web build // GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public. // 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. // 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 { if settings == nil {
return nil, errors.New("server: nil household settings service") 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 { if planSvc == nil {
return nil, errors.New("server: nil plan service") return nil, errors.New("server: nil plan service")
} }
if shopSvc == nil {
return nil, errors.New("server: nil shopping service")
}
if googleSvc == nil { if googleSvc == nil {
return nil, errors.New("server: nil Google Calendar service") 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.Get("/plans/{weekStart}", getPlan(logger, planSvc))
apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc)) apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(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.Get("/rotation-reviews", getRotationReviews(logger, planSvc))
apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc)) apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc))
// connect and callback are top-level browser navigations, so they are // connect and callback are top-level browser navigations, so they are
+31 -10
View File
@@ -30,6 +30,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "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/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" "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/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" "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)}, "index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, "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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -209,6 +210,17 @@ func newTestPlan(t *testing.T) *plan.Service {
return svc 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. // newTestBox returns a secret box under a random key.
func newTestBox(t *testing.T) *secretbox.Box { func newTestBox(t *testing.T) *secretbox.Box {
t.Helper() t.Helper()
@@ -263,7 +275,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
t.Parallel() t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 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") t.Fatal("NewRouter accepted a nil AI settings service")
} }
} }
@@ -272,7 +284,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
t.Parallel() t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 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") t.Fatal("NewRouter accepted a nil recipe search settings service")
} }
} }
@@ -281,7 +293,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
t.Parallel() t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 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") t.Fatal("NewRouter accepted nil photo files")
} }
} }
@@ -290,7 +302,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel() t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 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") t.Fatal("NewRouter accepted a nil settings service")
} }
} }
@@ -299,7 +311,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
t.Parallel() t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 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") t.Fatal("NewRouter accepted a nil dinners service")
} }
} }
@@ -308,7 +320,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
t.Parallel() t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 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") t.Fatal("NewRouter accepted a nil suggestions service")
} }
} }
@@ -317,16 +329,25 @@ func TestNewRouterRequiresPlan(t *testing.T) {
t.Parallel() t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 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") 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) { func TestNewRouterRequiresGoogle(t *testing.T) {
t.Parallel() t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 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") t.Fatal("NewRouter accepted a nil Google Calendar service")
} }
} }
@@ -335,7 +356,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
t.Parallel() t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 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") t.Fatal("NewRouter accepted a nil job runner")
} }
} }
+1 -1
View File
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
} }
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st), 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+198
View File
@@ -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")
}
}
+511
View File
@@ -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())
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
t.Fatal(err) t.Fatal(err)
} }
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+534
View File
@@ -0,0 +1,534 @@
// Package shopping keeps the household's Supply runs: each week's
// shopping list, generated from the week's plan and grouped by dinner, and
// the Supply archive they are frozen into.
//
// Generate makes a week's Supply run from its plan, and remakes it after
// the plan changes. It is deterministic and runs no model: an item is one
// ingredient of the dinner's recipe exactly as recipe grounding stored it
// (see Generate).
package shopping
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// List statuses (the shopping_lists.status column).
const (
StatusCurrent = "current"
StatusArchived = "archived"
)
// Reasons a plan day has no group on its Supply run.
const (
// SkipNoRecipe: the day's dinner has no recipe, as while its grounding
// is pending or after it failed.
SkipNoRecipe = "no_recipe"
// SkipNoIngredients: the day's dinner's recipe lists no ingredients.
SkipNoIngredients = "no_ingredients"
)
// ErrPlanNotFound is a generation for a week with no plan.
var ErrPlanNotFound = errors.New("the week has no plan")
// 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
}
// NewService returns a service over st.
func NewService(st *store.Store) (*Service, error) {
if st == nil {
return nil, errors.New("shopping: nil store")
}
return &Service{st: st}, nil
}
// List is a Supply run.
type List struct {
ID string `json:"id"`
// WeekStart is the Monday of the list's week, YYYY-MM-DD.
WeekStart string `json:"week_start"`
PlanID string `json:"plan_id"`
// Title is the AI-written post title; nil until it is written, when
// the week label stands in for it.
Title *string `json:"title"`
Status string `json:"status"`
// 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.
type Group struct {
ID string `json:"id"`
DinnerID string `json:"dinner_id"`
DinnerName string `json:"dinner_name"`
// Date is the plan day, household-local YYYY-MM-DD.
Date string `json:"date"`
Position int `json:"position"`
Items []Item `json:"items"`
}
// Item is one ingredient to buy.
type Item struct {
ID string `json:"id"`
Name string `json:"name"`
// Qty is the quantity as shown, such as "2 cups" or "500 g"; nil when
// the recipe gives none.
Qty *string `json:"qty"`
// Needed is false once the household marks the item "have it".
Needed bool `json:"needed"`
Position int `json:"position"`
}
// SkippedDay is a plan day with no group on the list, and why.
type SkippedDay struct {
Date string `json:"date"`
DinnerID string `json:"dinner_id"`
DinnerName string `json:"dinner_name"`
// Reason is SkipNoRecipe or SkipNoIngredients.
Reason string `json:"reason"`
}
// Generated is the outcome of Generate.
type Generated struct {
List List `json:"list"`
// Skipped are the plan days whose dinner has no ingredients to shop
// for, by date.
Skipped []SkippedDay `json:"skipped"`
}
// Generate makes the Supply run of the week starting weekStart from the
// week's plan, creating it untitled the first time and remaking it after
// that, in one transaction.
//
// The list has one group per plan day with a dinner, by date, holding one
// item per ingredient of the dinner's recipe, by the ingredient's
// position. A fetched recipe's ingredients are normalized when it is
// grounded (normalize_recipe): the item is the ingredient's name, and its
// quantity is qty and unit joined by a space ("2 cups"), either alone when
// the other is missing, or nil when both are. A recipe entered by hand is
// stored as typed, unnormalized: each item is its line as typed, with no
// quantity. A day whose dinner has no recipe or whose recipe has no
// ingredients gets no group and is reported in Skipped.
//
// Remaking keeps what the household marked: an item is the same item as
// before when its dinner and name are the same, wherever the dinner and
// the ingredient now are, so it keeps its id and needed flag. When a
// dinner lists a name more than once, its first such item matches the
// first one before, the second the second, and so on. Any other item is
// new and needed; an item no longer on the plan is removed, and with it a
// group no longer on the plan.
//
// A week with no plan is ErrPlanNotFound; a list frozen into the Supply
// 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)
}
var out Generated
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
plan, err := q.GetWeekPlan(ctx, weekStart.String())
if errors.Is(err, sql.ErrNoRows) {
return ErrPlanNotFound
}
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
}
now := store.Now().String()
list, err := q.GetShoppingList(ctx, weekStart.String())
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("shopping: read list: %w", err)
}
switch {
case err != nil: // no list yet
list = db.ShoppingList{ID: store.NewID(), WeekStart: weekStart.String(), PlanID: plan.ID, Status: StatusCurrent}
err = q.CreateShoppingList(ctx, db.CreateShoppingListParams{
ID: list.ID, WeekStart: list.WeekStart, PlanID: list.PlanID, CreatedAt: now, UpdatedAt: now,
})
case frozen(list):
return ErrFrozen
default:
list.PlanID = plan.ID
err = q.RegenerateShoppingList(ctx, db.RegenerateShoppingListParams{PlanID: plan.ID, UpdatedAt: now, ID: list.ID})
}
if err != nil {
return fmt.Errorf("shopping: store list: %w", err)
}
groups, err := reconcile(ctx, q, list.ID, want, now)
if err != nil {
return err
}
out = Generated{List: listOf(list, groups), Skipped: skipped}
return nil
})
if err != nil {
return Generated{}, err
}
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
items []wantItem
}
type wantItem struct {
name string
qty sql.NullString
}
// wanted reads plan planID's days and their dinners' ingredients: the
// groups the list must have, by date, and the days that get none.
func wanted(ctx context.Context, q *db.Queries, planID string) ([]wantGroup, []SkippedDay, error) {
days, err := q.ListPlanDays(ctx, planID)
if err != nil {
return nil, nil, fmt.Errorf("shopping: read plan days: %w", err)
}
var (
groups []wantGroup
skipped []SkippedDay
)
for _, day := range days {
d := day.Dinner
skip := SkippedDay{Date: day.Date, DinnerID: d.ID, DinnerName: d.Name}
if !d.RecipeID.Valid {
skip.Reason = SkipNoRecipe
skipped = append(skipped, skip)
continue
}
recipe, err := q.GetRecipe(ctx, d.RecipeID.String)
if err != nil {
return nil, nil, fmt.Errorf("shopping: read recipe %s of dinner %s: %w", d.RecipeID.String, d.ID, err)
}
ings, err := q.ListRecipeIngredients(ctx, recipe.ID)
if err != nil {
return nil, nil, fmt.Errorf("shopping: read ingredients of recipe %s: %w", recipe.ID, err)
}
if len(ings) == 0 {
skip.Reason = SkipNoIngredients
skipped = append(skipped, skip)
continue
}
manual := dinners.IsManualRecipe(recipe.SourceUrl)
g := wantGroup{date: day.Date, dinnerID: d.ID, dinnerName: d.Name}
for _, ing := range ings {
it, err := item(ing, manual)
if err != nil {
return nil, nil, fmt.Errorf("shopping: recipe %s: %w", recipe.ID, err)
}
g.items = append(g.items, it)
}
groups = append(groups, g)
}
return groups, skipped, nil
}
// item is the item for ingredient ing of a recipe entered by hand
// (manual) or fetched.
func item(ing db.RecipeIngredient, manual bool) (wantItem, error) {
if manual {
return wantItem{name: ing.RawText}, nil
}
if !ing.Name.Valid || ing.Name.String == "" {
return wantItem{}, fmt.Errorf("ingredient %d (%q) has no normalized name", ing.Position+1, ing.RawText)
}
var parts []string
for _, p := range []sql.NullString{ing.Qty, ing.Unit} {
if p.Valid && p.String != "" {
parts = append(parts, p.String)
}
}
qty := strings.Join(parts, " ")
return wantItem{name: ing.Name.String, qty: sql.NullString{String: qty, Valid: qty != ""}}, nil
}
// itemKey is what makes an item the same item across a remake.
type itemKey struct {
dinnerID, name string
}
// groupKey is what makes a group the same group across a remake.
type groupKey struct {
date, dinnerID string
}
// reconcile makes the groups and items of list listID those of want, as
// Generate says, and returns them.
func reconcile(ctx context.Context, q *db.Queries, listID string, want []wantGroup, now string) ([]Group, error) {
oldGroups, err := q.ListShoppingGroups(ctx, listID)
if err != nil {
return nil, fmt.Errorf("shopping: read groups: %w", err)
}
oldItems, err := q.ListShoppingItems(ctx, listID)
if err != nil {
return nil, fmt.Errorf("shopping: read items: %w", err)
}
groupsByKey := make(map[groupKey]db.ShoppingGroup, len(oldGroups))
for _, g := range oldGroups {
groupsByKey[groupKey{g.Date, g.DinnerID}] = g
}
// itemsByKey holds each key's items in list order, so the nth item a
// dinner lists under a name matches its nth one before.
itemsByKey := make(map[itemKey][]db.ShoppingItem)
for _, r := range oldItems {
k := itemKey{r.DinnerID, r.ShoppingItem.Name}
itemsByKey[k] = append(itemsByKey[k], r.ShoppingItem)
}
kept := make(map[string]bool) // ids of the groups and items kept
groups := make([]Group, 0, len(want))
for gi, w := range want {
g := Group{DinnerID: w.dinnerID, DinnerName: w.dinnerName, Date: w.date, Position: gi}
if old, ok := groupsByKey[groupKey{w.date, w.dinnerID}]; ok {
g.ID = old.ID
kept[old.ID] = true
if err := q.MoveShoppingGroup(ctx, db.MoveShoppingGroupParams{Position: int64(gi), UpdatedAt: now, ID: g.ID}); err != nil {
return nil, fmt.Errorf("shopping: move group %s: %w", g.ID, err)
}
} else {
g.ID = store.NewID()
if err := q.CreateShoppingGroup(ctx, db.CreateShoppingGroupParams{
ID: g.ID, ListID: listID, DinnerID: w.dinnerID, Date: w.date, Position: int64(gi),
CreatedAt: now, UpdatedAt: now,
}); err != nil {
return nil, fmt.Errorf("shopping: store group of %s: %w", w.date, err)
}
}
for ii, wi := range w.items {
it := Item{Name: wi.name, Qty: nullString(wi.qty), Needed: true, Position: ii}
k := itemKey{w.dinnerID, wi.name}
if same := itemsByKey[k]; len(same) > 0 {
old := same[0]
itemsByKey[k] = same[1:]
it.ID, it.Needed = old.ID, old.Needed == 1
kept[old.ID] = true
if err := q.MoveShoppingItem(ctx, db.MoveShoppingItemParams{
GroupID: g.ID, Qty: wi.qty, Position: int64(ii), UpdatedAt: now, ID: it.ID,
}); err != nil {
return nil, fmt.Errorf("shopping: move item %s: %w", it.ID, err)
}
} else {
it.ID = store.NewID()
if err := q.CreateShoppingItem(ctx, db.CreateShoppingItemParams{
ID: it.ID, GroupID: g.ID, Name: wi.name, Qty: wi.qty, Position: int64(ii),
CreatedAt: now, UpdatedAt: now,
}); err != nil {
return nil, fmt.Errorf("shopping: store item %q of %s: %w", wi.name, w.date, err)
}
}
g.Items = append(g.Items, it)
}
groups = append(groups, g)
}
// Items first: a group is removed only once it is empty.
for _, r := range oldItems {
if !kept[r.ShoppingItem.ID] {
if err := q.DeleteShoppingItem(ctx, r.ShoppingItem.ID); err != nil {
return nil, fmt.Errorf("shopping: remove item %s: %w", r.ShoppingItem.ID, err)
}
}
}
for _, g := range oldGroups {
if !kept[g.ID] {
if err := q.DeleteShoppingGroup(ctx, g.ID); err != nil {
return nil, fmt.Errorf("shopping: remove group %s: %w", g.ID, err)
}
}
}
return groups, nil
}
func nullString(s sql.NullString) *string {
if !s.Valid {
return nil
}
return &s.String
}
+703
View File
@@ -0,0 +1,703 @@
package shopping_test
import (
"database/sql"
"errors"
"fmt"
"io"
"log/slog"
"reflect"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"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/migrations"
)
// week is the Monday the tests plan, and weekDates its days.
var (
week = mustDate("2026-10-12")
weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
)
func mustDate(s string) store.LocalDate {
d, err := store.ParseLocalDate(s)
if err != nil {
panic(err)
}
return d
}
type env struct {
svc *shopping.Service
st *store.Store
dataDir string
planID string
// ids are the dinners' ids by name.
ids map[string]string
}
func newEnv(t *testing.T) *env {
t.Helper()
dataDir := t.TempDir()
st, err := store.Open(t.Context(), dataDir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
svc, err := shopping.NewService(st)
if err != nil {
t.Fatal(err)
}
return &env{svc: svc, st: st, dataDir: dataDir, ids: map[string]string{}}
}
// ing is a recipe ingredient as recipe grounding stores it: an empty
// qty or unit is NULL.
type ing struct {
name, qty, unit string
}
// dinner stores a Regular named name and returns its id. With ings it is
// grounded in a fetched recipe listing them; with none it has no recipe.
func (e *env) dinner(t *testing.T, name string, ings ...ing) string {
t.Helper()
q := db.New(e.st.Writer)
now := store.Now().String()
var recipeID sql.NullString
status := "pending"
if ings != nil {
recipeID = e.recipe(t, name, ings)
status = "grounded"
}
d, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: "regular",
Origin: "regular",
RecipeID: recipeID,
GroundingStatus: status,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
e.ids[name] = d.ID
return d.ID
}
// recipe stores a fetched recipe titled title with ings, as
// recipes.Grounder stores a normalized page, and returns its id.
func (e *env) recipe(t *testing.T, title string, ings []ing) sql.NullString {
t.Helper()
q := db.New(e.st.Writer)
now := store.Now().String()
r, err := q.UpsertRecipe(t.Context(), db.UpsertRecipeParams{
ID: store.NewID(),
SourceUrl: "https://recipes.example/" + strings.ReplaceAll(strings.ToLower(title), " ", "-"),
Title: title,
RawJsonld: "{}",
FetchedAt: now,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
e.setIngredients(t, r.ID, ings)
return sql.NullString{String: r.ID, Valid: true}
}
// setIngredients replaces recipe id's ingredients with ings, as a
// regrounding does.
func (e *env) setIngredients(t *testing.T, id string, ings []ing) {
t.Helper()
q := db.New(e.st.Writer)
if err := q.DeleteRecipeIngredients(t.Context(), id); err != nil {
t.Fatal(err)
}
now := store.Now().String()
for i, in := range ings {
qtyUnit := strings.TrimSpace(in.qty + " " + in.unit)
if err := q.CreateRecipeIngredient(t.Context(), db.CreateRecipeIngredientParams{
ID: store.NewID(),
RecipeID: id,
Position: int64(i),
RawText: strings.TrimSpace(qtyUnit + " " + 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)
}
}
}
// recipeOf returns the id of dinner name's recipe.
func (e *env) recipeOf(t *testing.T, name string) string {
t.Helper()
d, err := db.New(e.st.Reader).GetDinner(t.Context(), e.ids[name])
if err != nil {
t.Fatal(err)
}
return d.RecipeID.String
}
// plan stores the week's plan with the dinners named names on weekDates,
// in order.
func (e *env) plan(t *testing.T, names ...string) {
t.Helper()
q := db.New(e.st.Writer)
now := store.Now().String()
e.planID = store.NewID()
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: e.planID, WeekStart: week.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
for i, name := range names {
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{
ID: store.NewID(), PlanID: e.planID, Date: weekDates[i],
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
}
}
// swap plans the dinner named name on date, as a swap on the Draft board
// does.
func (e *env) swap(t *testing.T, date, name string) {
t.Helper()
n, err := db.New(e.st.Writer).SwapPlanDay(t.Context(), db.SwapPlanDayParams{
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, UpdatedAt: store.Now().String(),
PlanID: e.planID, Date: date,
})
if err != nil || n != 1 {
t.Fatalf("swap %s to %s: %d rows, %v", date, name, n, err)
}
}
// haveIt marks the item named name of dinner's group "have it".
func (e *env) haveIt(t *testing.T, l shopping.List, dinner, name string) string {
t.Helper()
for _, g := range l.Groups {
for _, it := range g.Items {
if g.DinnerName == dinner && it.Name == name {
if _, err := e.st.Writer.Exec(`UPDATE shopping_items SET needed = 0 WHERE id = ?`, it.ID); err != nil {
t.Fatal(err)
}
return it.ID
}
}
}
t.Fatalf("no item %q of %s", name, dinner)
return ""
}
func (e *env) generate(t *testing.T) shopping.Generated {
t.Helper()
g, err := e.svc.Generate(t.Context(), week)
if err != nil {
t.Fatal(err)
}
return g
}
// lines renders l one item a line as "position date dinner: position
// name [qty] needed|have it".
func lines(l shopping.List) []string {
var out []string
for _, g := range l.Groups {
for _, it := range g.Items {
qty := "-"
if it.Qty != nil {
qty = *it.Qty
}
mark := "needed"
if !it.Needed {
mark = "have it"
}
out = append(out, fmt.Sprintf("%d %s %s: %d %s [%s] %s", g.Position, g.Date, g.DinnerName, it.Position, it.Name, qty, mark))
}
}
return out
}
// stored renders the stored list of the week as lines does, so a test
// checks the database holds what Generate returned.
func (e *env) stored(t *testing.T) []string {
t.Helper()
rows, err := e.st.Reader.Query(`SELECT g.position, g.date, d.name, i.position, i.name, COALESCE(i.qty, '-'), i.needed
FROM shopping_lists l
JOIN shopping_groups g ON g.list_id = l.id
JOIN dinners d ON d.id = g.dinner_id
JOIN shopping_items i ON i.group_id = g.id
WHERE l.week_start = ?
ORDER BY g.position, i.position`, week.String())
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []string
for rows.Next() {
var (
gpos, ipos, needed int
date, dinner, name string
qty string
)
if err := rows.Scan(&gpos, &date, &dinner, &ipos, &name, &qty, &needed); err != nil {
t.Fatal(err)
}
mark := "needed"
if needed == 0 {
mark = "have it"
}
out = append(out, fmt.Sprintf("%d %s %s: %d %s [%s] %s", gpos, date, dinner, ipos, name, qty, mark))
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return out
}
// count returns the number of rows in table.
func (e *env) count(t *testing.T, table string) int {
t.Helper()
var n int
if err := e.st.Reader.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
func wantLines(t *testing.T, what string, got, want []string) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Errorf("%s =\n %s\nwant\n %s", what, strings.Join(got, "\n "), strings.Join(want, "\n "))
}
}
func TestGenerateSevenDayPlan(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"kidney beans", "2", "cans"}, ing{"onion", "1", ""})
e.dinner(t, "Pot Roast", ing{"chuck roast", "3", "lb"}, ing{"carrots", "4", ""})
e.dinner(t, "Chicken Fajitas", ing{"chicken breast", "500", "g"}, ing{"bell peppers", "3", ""}, ing{"tortillas", "8", ""})
e.dinner(t, "Spaghetti Bolognese", ing{"spaghetti", "1", "lb"}, ing{"crushed tomatoes", "28", "oz"})
e.dinner(t, "Sheet Pan Salmon", ing{"salmon fillets", "4", ""}, ing{"lemon", "1", ""}, ing{"dill", "", "sprig"})
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"bean sprouts", "2", "cups"}, ing{"salt", "", ""})
e.dinner(t, "Shawarma Bowls", ing{"chicken thighs", "2", "lb"}, ing{"pita", "4", ""})
e.plan(t, "Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Pad Thai", "Shawarma Bowls")
g := e.generate(t)
want := []string{
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
"0 2026-10-12 Beef Chili: 1 kidney beans [2 cans] needed",
"0 2026-10-12 Beef Chili: 2 onion [1] needed",
"1 2026-10-13 Pot Roast: 0 chuck roast [3 lb] needed",
"1 2026-10-13 Pot Roast: 1 carrots [4] needed",
"2 2026-10-14 Chicken Fajitas: 0 chicken breast [500 g] needed",
"2 2026-10-14 Chicken Fajitas: 1 bell peppers [3] needed",
"2 2026-10-14 Chicken Fajitas: 2 tortillas [8] needed",
"3 2026-10-15 Spaghetti Bolognese: 0 spaghetti [1 lb] needed",
"3 2026-10-15 Spaghetti Bolognese: 1 crushed tomatoes [28 oz] needed",
"4 2026-10-16 Sheet Pan Salmon: 0 salmon fillets [4] needed",
"4 2026-10-16 Sheet Pan Salmon: 1 lemon [1] needed",
"4 2026-10-16 Sheet Pan Salmon: 2 dill [sprig] needed",
"5 2026-10-17 Pad Thai: 0 rice noodles [8 oz] needed",
"5 2026-10-17 Pad Thai: 1 bean sprouts [2 cups] needed",
"5 2026-10-17 Pad Thai: 2 salt [-] needed",
"6 2026-10-18 Shawarma Bowls: 0 chicken thighs [2 lb] needed",
"6 2026-10-18 Shawarma Bowls: 1 pita [4] needed",
}
wantLines(t, "list", lines(g.List), want)
wantLines(t, "stored list", e.stored(t), want)
if len(g.Skipped) != 0 {
t.Errorf("skipped = %+v, want none", g.Skipped)
}
for _, grp := range g.List.Groups {
if grp.DinnerID != e.ids[grp.DinnerName] {
t.Errorf("group %s dinner id = %s, want %s", grp.DinnerName, grp.DinnerID, e.ids[grp.DinnerName])
}
}
if g.List.WeekStart != "2026-10-12" || g.List.PlanID != e.planID || g.List.Status != shopping.StatusCurrent || g.List.Title != nil {
t.Errorf("list = %+v, want the week's current, untitled list of plan %s", g.List, e.planID)
}
var title sql.NullString
if err := e.st.Reader.QueryRow(`SELECT title FROM shopping_lists WHERE id = ?`, g.List.ID).Scan(&title); err != nil {
t.Fatal(err)
}
if title.Valid {
t.Errorf("stored title = %q, want NULL until the AI titles the list", title.String)
}
// Generating again from the same plan changes nothing: same rows, same
// ids, no extra rows.
again := e.generate(t)
if !reflect.DeepEqual(again, g) {
t.Errorf("second generation =\n %+v\nwant the first\n %+v", again, g)
}
if n := e.count(t, "shopping_lists"); n != 1 {
t.Errorf("%d lists, want 1", n)
}
if n := e.count(t, "shopping_items"); n != len(want) {
t.Errorf("%d items stored, want %d", n, len(want))
}
}
// TestGenerateManualRecipe grounds a dinner with a recipe typed in, through
// dinners.Service as the household does: its lines are stored unnormalized,
// so each is an item as typed, with no quantity.
func TestGenerateManualRecipe(t *testing.T) {
e := newEnv(t)
model := &llm.Fake{}
grounder := recipestest.NewGrounder(t, e.st, e.dataDir, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, model)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(e.st, logger, 1)
if err != nil {
t.Fatal(err)
}
dsvc, err := dinners.NewService(e.st, grounder, runner, logger)
if err != nil {
t.Fatal(err)
}
e.dinner(t, "Grandma's Goulash")
minutes, method := 45, "stovetop"
if _, err := dsvc.Ground(t.Context(), e.ids["Grandma's Goulash"], dinners.Fix{Manual: &dinners.ManualRecipe{
Ingredients: []string{"2 lb beef chuck", "1 onion, diced", "paprika to taste"},
Steps: []string{"Brown the beef."},
Minutes: &minutes,
Method: &method,
}}, "member-1"); err != nil {
t.Fatal(err)
}
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Grandma's Goulash", "Beef Chili")
g := e.generate(t)
want := []string{
"0 2026-10-12 Grandma's Goulash: 0 2 lb beef chuck [-] needed",
"0 2026-10-12 Grandma's Goulash: 1 1 onion, diced [-] needed",
"0 2026-10-12 Grandma's Goulash: 2 paprika to taste [-] needed",
"1 2026-10-13 Beef Chili: 0 ground beef [1 lb] needed",
}
wantLines(t, "list", lines(g.List), want)
wantLines(t, "stored list", e.stored(t), want)
if len(g.Skipped) != 0 {
t.Errorf("skipped = %+v, want none", g.Skipped)
}
if reqs := model.Requests(); len(reqs) != 0 {
t.Errorf("%d model requests, want none", len(reqs))
}
}
// TestGenerateSkipsDinnersWithoutIngredients plans a dinner with no recipe
// (its grounding pending) and one whose recipe lists no ingredients: each
// day gets no group and is reported with why.
func TestGenerateSkipsDinnersWithoutIngredients(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Mystery Stew")
e.dinner(t, "Toast", []ing{}...) // a recipe with no ingredients
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Mystery Stew", "Toast", "Beef Chili")
g := e.generate(t)
want := []string{"0 2026-10-14 Beef Chili: 0 ground beef [1 lb] needed"}
wantLines(t, "list", lines(g.List), want)
wantLines(t, "stored list", e.stored(t), want)
wantSkipped := []shopping.SkippedDay{
{Date: "2026-10-12", DinnerID: e.ids["Mystery Stew"], DinnerName: "Mystery Stew", Reason: shopping.SkipNoRecipe},
{Date: "2026-10-13", DinnerID: e.ids["Toast"], DinnerName: "Toast", Reason: shopping.SkipNoIngredients},
}
if !reflect.DeepEqual(g.Skipped, wantSkipped) {
t.Errorf("skipped = %+v, want %+v", g.Skipped, wantSkipped)
}
if n := e.count(t, "shopping_groups"); n != 1 {
t.Errorf("%d groups stored, want 1: a skipped day gets no empty group", n)
}
}
// TestGenerateAfterSwapKeepsHaveIt swaps a day to a new dinner and moves a
// dinner to another day, and regrounds a dinner so its ingredients change
// order and quantity: an item whose dinner and name are unchanged keeps
// its id and "have it" wherever it now is; the rest is added as needed or
// removed.
func TestGenerateAfterSwapKeepsHaveIt(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}, ing{"kidney beans", "2", "cans"})
e.dinner(t, "Chicken Fajitas", ing{"chicken breast", "500", "g"}, ing{"tortillas", "8", ""})
e.dinner(t, "Sheet Pan Salmon", ing{"salmon fillets", "4", ""}, ing{"lemon", "1", ""})
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"lime", "2", ""})
e.plan(t, "Beef Chili", "Chicken Fajitas", "Sheet Pan Salmon")
first := e.generate(t).List
onion := e.haveIt(t, first, "Beef Chili", "onion")
tortillas := e.haveIt(t, first, "Chicken Fajitas", "tortillas")
lemon := e.haveIt(t, first, "Sheet Pan Salmon", "lemon")
// Tuesday becomes Pad Thai and Fajitas moves to Wednesday, dropping
// the Salmon; the Chili is regrounded: onion first, no beans, a
// pepper added and more beef.
e.swap(t, "2026-10-13", "Pad Thai")
e.swap(t, "2026-10-14", "Chicken Fajitas")
e.setIngredients(t, e.recipeOf(t, "Beef Chili"), []ing{{"onion", "2", ""}, {"ground beef", "2", "lb"}, {"jalapeño", "1", ""}})
g := e.generate(t)
want := []string{
"0 2026-10-12 Beef Chili: 0 onion [2] have it",
"0 2026-10-12 Beef Chili: 1 ground beef [2 lb] needed",
"0 2026-10-12 Beef Chili: 2 jalapeño [1] needed",
"1 2026-10-13 Pad Thai: 0 rice noodles [8 oz] needed",
"1 2026-10-13 Pad Thai: 1 lime [2] needed",
"2 2026-10-14 Chicken Fajitas: 0 chicken breast [500 g] needed",
"2 2026-10-14 Chicken Fajitas: 1 tortillas [8] have it",
}
wantLines(t, "list", lines(g.List), want)
wantLines(t, "stored list", e.stored(t), want)
ids := map[string]string{}
for _, grp := range g.List.Groups {
for _, it := range grp.Items {
ids[grp.DinnerName+"/"+it.Name] = it.ID
}
}
if ids["Beef Chili/onion"] != onion || ids["Chicken Fajitas/tortillas"] != tortillas {
t.Errorf("kept items have ids %s and %s, want %s and %s", ids["Beef Chili/onion"], ids["Chicken Fajitas/tortillas"], onion, tortillas)
}
var n int
if err := e.st.Reader.QueryRow(`SELECT count(*) FROM shopping_items WHERE id = ?`, lemon).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Error("the Salmon's lemon is still stored after the Salmon left the plan")
}
if got := e.count(t, "shopping_items"); got != len(want) {
t.Errorf("%d items stored, want %d", got, len(want))
}
if got := e.count(t, "shopping_groups"); got != 3 {
t.Errorf("%d groups stored, want 3", got)
}
if g.List.ID != first.ID {
t.Errorf("list id = %s, want the first generation's %s", g.List.ID, first.ID)
}
}
// TestGenerateMatchesRepeatedNamesInOrder lists a name twice in one
// dinner: the second "salt" marked "have it" stays the second.
func TestGenerateMatchesRepeatedNamesInOrder(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Pasta", ing{"salt", "1", "tbsp"}, ing{"spaghetti", "1", "lb"}, ing{"salt", "", "pinch"})
e.plan(t, "Pasta")
first := e.generate(t).List
second := first.Groups[0].Items[2].ID
if _, err := e.st.Writer.Exec(`UPDATE shopping_items SET needed = 0 WHERE id = ?`, second); err != nil {
t.Fatal(err)
}
e.setIngredients(t, e.recipeOf(t, "Pasta"), []ing{{"spaghetti", "1", "lb"}, {"salt", "1", "tbsp"}, {"salt", "", "pinch"}})
g := e.generate(t)
wantLines(t, "list", lines(g.List), []string{
"0 2026-10-12 Pasta: 0 spaghetti [1 lb] needed",
"0 2026-10-12 Pasta: 1 salt [1 tbsp] needed",
"0 2026-10-12 Pasta: 2 salt [pinch] have it",
})
if id := g.List.Groups[0].Items[2].ID; id != second {
t.Errorf("second salt id = %s, want %s", id, second)
}
}
func TestGenerateWeekWithoutPlan(t *testing.T) {
e := newEnv(t)
if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrPlanNotFound) {
t.Fatalf("Generate = %v, want ErrPlanNotFound", err)
}
if n := e.count(t, "shopping_lists"); n != 0 {
t.Errorf("%d lists stored, want none", n)
}
}
func TestGenerateRefusesNonMonday(t *testing.T) {
e := newEnv(t)
if _, err := e.svc.Generate(t.Context(), mustDate("2026-10-13")); err == nil {
t.Fatal("Generate of a Tuesday succeeded")
}
}
// TestGenerateRefusesFrozenList leaves a list frozen into the Supply
// archive as it is.
func TestGenerateRefusesFrozenList(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")
e.generate(t)
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
t.Fatal(err)
}
e.swap(t, "2026-10-12", "Pad Thai")
if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrFrozen) {
t.Fatalf("Generate = %v, want ErrFrozen", err)
}
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.
func TestGenerateRefusesUnnamedIngredient(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
e.plan(t, "Beef Chili")
if _, err := e.st.Writer.Exec(`UPDATE recipe_ingredients SET name = NULL WHERE position = 1`); err != nil {
t.Fatal(err)
}
_, err := e.svc.Generate(t.Context(), week)
if err == nil || !strings.Contains(err.Error(), `ingredient 2 ("1 onion") has no normalized name`) {
t.Fatalf("Generate = %v, want the unnamed ingredient refused", err)
}
if n := e.count(t, "shopping_lists"); n != 0 {
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"})
}
+132 -4
View File
@@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required" { if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required", got) t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled", got)
} }
} }
@@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required" { if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required", got) t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled", got)
} }
if !tableExists(t, s, "dinners") { if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created") t.Error("table from 0001_init was not created")
@@ -761,3 +761,131 @@ func TestMigrateGoogleReconnectKeepsConnection(t *testing.T) {
t.Errorf("connection after 0015 = %s, want %s", got, row) t.Errorf("connection after 0015 = %s, want %s", got, row)
} }
} }
// migrateBefore applies every migration whose name sorts before prefix.
func migrateBefore(t *testing.T, s *Store, prefix string) {
t.Helper()
before := fstest.MapFS{}
matches, err := fs.Glob(migrations.FS, "*.sql")
if err != nil {
t.Fatal(err)
}
for _, name := range matches {
if name >= prefix {
continue
}
body, err := fs.ReadFile(migrations.FS, name)
if err != nil {
t.Fatal(err)
}
before[name] = &fstest.MapFile{Data: body}
}
if err := Migrate(context.Background(), s.Writer, before); err != nil {
t.Fatal(err)
}
}
// seedShopping stores two dinners, a plan with dinner-1 on 2026-10-13
// and dinner-2 on 2026-10-14, and a titled list with a group of
// groupDinner holding an item marked "have it".
func seedShopping(t *testing.T, s *Store, groupDinner string) {
t.Helper()
for _, stmt := range []string{
`INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at)
VALUES ('dinner-1', 'Pho', 'pho', 'regular', 'regular', 0, 'grounded', 'c', 'u'),
('dinner-2', 'Ramen', 'ramen', 'regular', 'regular', 0, 'grounded', 'c', 'u'),
('dinner-3', 'Laksa', 'laksa', 'regular', 'regular', 0, 'grounded', 'c', 'u')`,
`INSERT INTO week_plans (id, week_start, status, created_at, updated_at)
VALUES ('plan-1', '2026-10-12', 'synced', 'c', 'u')`,
`INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at)
VALUES ('day-1', 'plan-1', '2026-10-13', 'dinner-1', 0, 'c', 'u'),
('day-2', 'plan-1', '2026-10-14', 'dinner-2', 0, 'c', 'u')`,
`INSERT INTO shopping_lists (id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at)
VALUES ('list-1', '2026-10-12', 'plan-1', 'Noodle week', 'current', 's', NULL, 'c1', 'u1')`,
`INSERT INTO shopping_groups (id, list_id, dinner_id, position, created_at, updated_at)
VALUES ('group-1', 'list-1', '` + groupDinner + `', 0, 'c2', 'u2')`,
`INSERT INTO shopping_items (id, group_id, name, qty, needed, position, created_at, updated_at)
VALUES ('item-1', 'group-1', 'rice noodles', '200 g', 0, 0, 'c3', 'u3')`,
} {
if _, err := s.Writer.Exec(stmt); err != nil {
t.Fatalf("seed: %v", err)
}
}
}
// TestMigrateShoppingListUntitled applies 0016, which rebuilds the
// shopping tables: every row survives, a group takes its dinner's plan
// day, and a list may then be untitled but never have an empty title.
func TestMigrateShoppingListUntitled(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
migrateBefore(t, s, "0016")
seedShopping(t, s, "dinner-2")
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
var got string
if err := s.Reader.QueryRowContext(ctx, `SELECT
l.id || '|' || l.week_start || '|' || l.plan_id || '|' || l.title || '|' || l.status || '|' ||
l.sent_to_chat_at || '|' || COALESCE(l.frozen_at, '') || '|' || l.created_at || '|' || l.updated_at || ' / ' ||
g.id || '|' || g.list_id || '|' || g.dinner_id || '|' || g.date || '|' || g.position || '|' || g.created_at || '|' || g.updated_at || ' / ' ||
i.id || '|' || i.group_id || '|' || i.name || '|' || i.qty || '|' || i.needed || '|' || i.position || '|' || i.created_at || '|' || i.updated_at
FROM shopping_lists l JOIN shopping_groups g ON g.list_id = l.id JOIN shopping_items i ON i.group_id = g.id`).Scan(&got); err != nil {
t.Fatal(err)
}
const want = `list-1|2026-10-12|plan-1|Noodle week|current|s||c1|u1 / ` +
`group-1|list-1|dinner-2|2026-10-14|0|c2|u2 / ` +
`item-1|group-1|rice noodles|200 g|0|0|c3|u3`
if got != want {
t.Errorf("shopping rows after 0016 =\n %s\nwant\n %s", got, want)
}
if _, err := s.Writer.Exec(`UPDATE shopping_lists SET title = NULL WHERE id = 'list-1'`); err != nil {
t.Errorf("untitled list refused: %v", err)
}
for _, tc := range []struct{ name, stmt, want string }{
{"empty title", `UPDATE shopping_lists SET title = '' WHERE id = 'list-1'`, "CHECK constraint failed"},
{"group with no date", `UPDATE shopping_groups SET date = NULL WHERE id = 'group-1'`, "NOT NULL constraint failed"},
{"group of an unknown list", `UPDATE shopping_groups SET list_id = 'nope' WHERE id = 'group-1'`, "FOREIGN KEY constraint failed"},
{"item of an unknown group", `UPDATE shopping_items SET group_id = 'nope' WHERE id = 'item-1'`, "FOREIGN KEY constraint failed"},
{"needed not a boolean", `UPDATE shopping_items SET needed = 2 WHERE id = 'item-1'`, "CHECK constraint failed"},
{"second list of a week", `INSERT INTO shopping_lists (id, week_start, plan_id, status, created_at, updated_at)
VALUES ('list-2', '2026-10-12', 'plan-1', 'current', 'c', 'u')`, "UNIQUE constraint failed"},
} {
if _, err := s.Writer.Exec(tc.stmt); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: error %v, want %s", tc.name, err, tc.want)
}
}
for _, table := range []string{"shopping_lists_copy", "shopping_groups_copy", "shopping_items_copy"} {
if tableExists(t, s, table) {
t.Errorf("%s left behind", table)
}
}
}
// TestMigrateShoppingListUntitledRefusesUndatedGroup applies 0016 over a
// group whose dinner is not on its list's plan: it has no plan day, so the
// migration fails and leaves the database as it was.
func TestMigrateShoppingListUntitledRefusesUndatedGroup(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
migrateBefore(t, s, "0016")
seedShopping(t, s, "dinner-3")
err := Migrate(ctx, s.Writer, migrations.FS)
if err == nil || !strings.Contains(err.Error(), "NOT NULL constraint failed: shopping_groups.date") {
t.Fatalf("Migrate = %v, want the undated group refused", err)
}
var n int
if err := s.Reader.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations WHERE version = '0016_shopping_list_untitled'`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Error("0016 recorded as applied")
}
if tableExists(t, s, "shopping_groups_copy") {
t.Error("shopping_groups_copy left behind")
}
}
+3 -3
View File
@@ -43,7 +43,7 @@ var repoParents = map[string]repoRow{
}}, }},
"shopping_groups": {"shopping_groups", map[string]any{ "shopping_groups": {"shopping_groups", map[string]any{
"id": "group-1", "list_id": "list-1", "dinner_id": "dinner-1", "id": "group-1", "list_id": "list-1", "dinner_id": "dinner-1",
"position": int64(0), "created_at": repoTime, "updated_at": repoTime, "date": "2026-10-05", "position": int64(0), "created_at": repoTime, "updated_at": repoTime,
}}, }},
} }
@@ -108,12 +108,12 @@ func TestRepositoryInsertReadUpdate(t *testing.T) {
"title": "Supply run", "status": "current", "sent_to_chat_at": nil, "frozen_at": nil, "title": "Supply run", "status": "current", "sent_to_chat_at": nil, "frozen_at": nil,
"created_at": repoTime, "updated_at": repoTime, "created_at": repoTime, "updated_at": repoTime,
}}, }},
update: map[string]any{"status": "archived", "frozen_at": "2026-10-12T00:00:00Z"}, update: map[string]any{"title": nil, "status": "archived", "frozen_at": "2026-10-12T00:00:00Z"},
}, },
{ {
parents: []string{"dinners", "week_plans", "shopping_lists"}, parents: []string{"dinners", "week_plans", "shopping_lists"},
row: repoRow{"shopping_groups", map[string]any{ row: repoRow{"shopping_groups", map[string]any{
"id": "group-2", "list_id": "list-1", "dinner_id": "dinner-1", "position": int64(0), "id": "group-2", "list_id": "list-1", "dinner_id": "dinner-1", "date": "2026-10-06", "position": int64(0),
"created_at": repoTime, "updated_at": repoTime, "created_at": repoTime, "updated_at": repoTime,
}}, }},
update: map[string]any{"position": int64(3)}, update: map[string]any{"position": int64(3)},
+1
View File
@@ -136,6 +136,7 @@ func testRow(table string, i int) map[string]any {
case "shopping_groups": case "shopping_groups":
r["list_id"] = rowID("shopping_lists", 0) r["list_id"] = rowID("shopping_lists", 0)
r["dinner_id"] = rowID("dinners", 0) r["dinner_id"] = rowID("dinners", 0)
r["date"] = fmt.Sprintf("2026-01-%02d", i+5)
r["position"] = i r["position"] = i
case "shopping_items": case "shopping_items":
r["group_id"] = rowID("shopping_groups", 0) r["group_id"] = rowID("shopping_groups", 0)
@@ -0,0 +1,81 @@
-- 0016_shopping_list_untitled: a Supply run is generated from the plan
-- before it has a title, and stays untitled when the AI cannot write one,
-- so shopping_lists.title becomes nullable; an untitled list shows its
-- week label instead. A title is never empty. Each group also keeps the
-- plan day it shops for, so a list read back, or frozen into the Supply
-- archive, says which day each dinner was planned on.
-- SQLite cannot drop a NOT NULL constraint or add a NOT NULL column
-- without a default, so the three shopping tables are rebuilt as 0012
-- does: copy the rows aside, drop the tables children first, create them
-- again, copy the rows back parents first and drop the copies. A group
-- takes the date its dinner has on its list's plan; a group whose dinner
-- is not on that plan has no date and fails the migration. Nothing else
-- references the shopping tables.
CREATE TABLE shopping_items_copy AS SELECT * FROM shopping_items;
CREATE TABLE shopping_groups_copy AS SELECT * FROM shopping_groups;
CREATE TABLE shopping_lists_copy AS SELECT * FROM shopping_lists;
DROP TABLE shopping_items;
DROP TABLE shopping_groups;
DROP TABLE shopping_lists;
CREATE TABLE shopping_lists (
id TEXT NOT NULL PRIMARY KEY,
week_start TEXT NOT NULL UNIQUE,
plan_id TEXT NOT NULL REFERENCES week_plans (id),
title TEXT CHECK (title <> ''), -- NULL until the AI titles it
status TEXT NOT NULL CHECK (status IN ('current', 'archived')),
sent_to_chat_at TEXT,
frozen_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE shopping_groups (
id TEXT NOT NULL PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES shopping_lists (id),
dinner_id TEXT NOT NULL REFERENCES dinners (id),
date TEXT NOT NULL, -- the plan day, household-local YYYY-MM-DD
position INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE shopping_items (
id TEXT NOT NULL PRIMARY KEY,
group_id TEXT NOT NULL REFERENCES shopping_groups (id),
name TEXT NOT NULL,
qty TEXT,
needed INTEGER NOT NULL DEFAULT 1 CHECK (needed IN (0, 1)),
position INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO shopping_lists (
id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at
)
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at
FROM shopping_lists_copy;
INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
SELECT
shopping_groups_copy.id, shopping_groups_copy.list_id, shopping_groups_copy.dinner_id,
(
SELECT MIN(plan_days.date)
FROM plan_days
JOIN shopping_lists_copy ON shopping_lists_copy.plan_id = plan_days.plan_id
WHERE shopping_lists_copy.id = shopping_groups_copy.list_id
AND plan_days.dinner_id = shopping_groups_copy.dinner_id
),
shopping_groups_copy.position, shopping_groups_copy.created_at, shopping_groups_copy.updated_at
FROM shopping_groups_copy;
INSERT INTO shopping_items (id, group_id, name, qty, needed, position, created_at, updated_at)
SELECT id, group_id, name, qty, needed, position, created_at, updated_at
FROM shopping_items_copy;
DROP TABLE shopping_items_copy;
DROP TABLE shopping_groups_copy;
DROP TABLE shopping_lists_copy;
+83
View File
@@ -0,0 +1,83 @@
-- name: GetShoppingList :one
-- The Supply run of the week starting week_start. Returns sql.ErrNoRows
-- when there is none.
SELECT * FROM shopping_lists
WHERE week_start = ?;
-- name: CreateShoppingList :exec
-- A new, untitled Supply run: the title is written later, by the AI.
INSERT INTO shopping_lists (id, week_start, plan_id, status, created_at, updated_at)
VALUES (?, ?, ?, 'current', ?, ?);
-- name: RegenerateShoppingList :exec
-- Records a generation of list id from plan plan_id.
UPDATE shopping_lists
SET plan_id = ?, updated_at = ?
WHERE id = ?;
-- name: ListShoppingGroups :many
-- The groups of list list_id, in list order.
SELECT * FROM shopping_groups
WHERE list_id = ?
ORDER BY position, id;
-- name: ListShoppingItems :many
-- The items of list list_id with their group's dinner, in list order: by
-- group, then by item.
SELECT sqlc.embed(shopping_items), shopping_groups.dinner_id
FROM shopping_items
JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id
WHERE shopping_groups.list_id = ?
ORDER BY shopping_groups.position, shopping_items.position, shopping_items.id;
-- name: CreateShoppingGroup :exec
INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?);
-- name: MoveShoppingGroup :exec
UPDATE shopping_groups
SET position = ?, updated_at = ?
WHERE id = ?;
-- name: DeleteShoppingGroup :exec
DELETE FROM shopping_groups
WHERE id = ?;
-- name: CreateShoppingItem :exec
-- A new item is needed until the household marks it "have it".
INSERT INTO shopping_items (id, group_id, name, qty, needed, position, created_at, updated_at)
VALUES (?, ?, ?, ?, 1, ?, ?, ?);
-- name: MoveShoppingItem :exec
-- Places item id in group group_id at position with quantity qty; its
-- name and needed flag are kept.
UPDATE shopping_items
SET group_id = ?, qty = ?, position = ?, updated_at = ?
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 = ?;