Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b0a63e1b5 | ||
|
|
2feaced7c9 | ||
|
|
a72ef1cbdd | ||
|
|
791b321b71 | ||
|
|
be41b56690 |
+17
-6
@@ -105,7 +105,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
|
||||
| `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. |
|
||||
| `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, chat_send_unconfirmed_at (a send to Google Chat recorded as begun before posting and cleared once its outcome is known; left set when it may or may not have posted), frozen_at. |
|
||||
| `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. |
|
||||
| `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date), frozen_dinner_name (the dinner's name when its list was frozen into the Supply archive, kept whatever it is later renamed; NULL while the list is current, which shows its dinners' names now; never empty). 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. |
|
||||
| `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. |
|
||||
@@ -635,7 +635,10 @@ automatic** (D-021).
|
||||
|
||||
`shopping.Freeze(weekStart)` moves a week's Supply run into the Supply
|
||||
archive: `status` becomes `archived` and `frozen_at` is set, after which
|
||||
generating the list or changing an item is 409 `archived`. It freezes only
|
||||
generating the list or changing an item is 409 `archived`. In the same
|
||||
transaction each group keeps its dinner's name as `frozen_dinner_name`, so
|
||||
the frozen list and its post show the names they had when frozen, whatever
|
||||
a dinner is renamed later. It freezes only
|
||||
a week that has ended in the household's time zone (its Monday is before
|
||||
the Monday of the household's current week), so a week not yet ended is
|
||||
`ErrWeekNotEnded` and a week with no list `ErrListNotFound`; freezing a
|
||||
@@ -1120,7 +1123,10 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
- The week's Supply run (Feature 17) is made from a pushed plan: after
|
||||
a successful `Push`, the handler runs `shopping.GeneratePushed` for
|
||||
the week at the plan revision `Push` pushed, and the 200 answer
|
||||
carries `supply_run: {status, error}`. `status` is `generated`;
|
||||
carries `supply_run: {status, error, title_job_id}`. `title_job_id` is
|
||||
the `title_supply_run` job titling a generated list, as for `POST
|
||||
/shopping/{weekStart}/generate`; null when the list already has its
|
||||
title or was not generated. `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
|
||||
@@ -1143,8 +1149,12 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
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.
|
||||
`skipped: [{date, dinner_id, dinner_name, reason}]` and
|
||||
`title_job_id`, the `title_supply_run` job titling the list, which
|
||||
`GET /api/v1/jobs/{id}` reports on (failed `title_failed` when the title
|
||||
could not be written); null when the list already has its title. 404
|
||||
when the week has no plan, 409 `archived` when its list is in the Supply
|
||||
archive.
|
||||
- `POST /api/v1/shopping/{weekStart}/send-chat` (CSRF-checked) takes
|
||||
`{}` or `{"resend": true}` and posts the Supply run to Google Chat
|
||||
through `chat.PostSupplyRun`, in the week's thread: "Supply run · " and
|
||||
@@ -1180,7 +1190,8 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
Monday is a household-local date, so the week of Monday 28 December
|
||||
is in December. `label` is the week label ("Week 41 · Oct 5 – 11"; the
|
||||
UI sets it in capitals), `title` null while untitled, `dinners` the
|
||||
list's dinners in day order and `dinnerCount` their number,
|
||||
list's dinners in day order (as named when the list was frozen; the
|
||||
current week's as named now) and `dinnerCount` their number,
|
||||
`itemCount` all items, `onHandCount` the items marked "have it", and
|
||||
`current` true for the household's current week. 400 `invalid_request`
|
||||
for any other `by`. A frozen list is read through `GET
|
||||
|
||||
@@ -255,13 +255,14 @@ type Session struct {
|
||||
}
|
||||
|
||||
type ShoppingGroup struct {
|
||||
ID string
|
||||
ListID string
|
||||
DinnerID string
|
||||
Date string
|
||||
Position int64
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
ID string
|
||||
ListID string
|
||||
DinnerID string
|
||||
Date string
|
||||
Position int64
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
FrozenDinnerName sql.NullString
|
||||
}
|
||||
|
||||
type ShoppingItem struct {
|
||||
|
||||
@@ -107,6 +107,9 @@ type Querier interface {
|
||||
// Records a pending or running batch as done or failed; a batch already
|
||||
// finished is left as it is (0 rows).
|
||||
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
|
||||
// Keeps the name each dinner of list list_id has now as its group's
|
||||
// frozen_dinner_name, as the list is frozen into the Supply archive.
|
||||
FreezeShoppingGroupNames(ctx context.Context, listID string) error
|
||||
// Freezes list id into the Supply archive at frozen_at, unless it is
|
||||
// frozen already.
|
||||
FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error)
|
||||
@@ -228,7 +231,8 @@ type Querier interface {
|
||||
ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error)
|
||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||
// The groups of list list_id with their dinner's name, in list order.
|
||||
// The groups of list list_id with their dinner's name now, in list order.
|
||||
// A group of a frozen list shows its frozen_dinner_name instead.
|
||||
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)
|
||||
|
||||
@@ -155,6 +155,19 @@ func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const freezeShoppingGroupNames = `-- name: FreezeShoppingGroupNames :exec
|
||||
UPDATE shopping_groups
|
||||
SET frozen_dinner_name = (SELECT dinners.name FROM dinners WHERE dinners.id = shopping_groups.dinner_id)
|
||||
WHERE list_id = ?
|
||||
`
|
||||
|
||||
// Keeps the name each dinner of list list_id has now as its group's
|
||||
// frozen_dinner_name, as the list is frozen into the Supply archive.
|
||||
func (q *Queries) FreezeShoppingGroupNames(ctx context.Context, listID string) error {
|
||||
_, err := q.db.ExecContext(ctx, freezeShoppingGroupNames, listID)
|
||||
return err
|
||||
}
|
||||
|
||||
const freezeShoppingList = `-- name: FreezeShoppingList :execrows
|
||||
UPDATE shopping_lists
|
||||
SET status = 'archived', frozen_at = ?, updated_at = ?
|
||||
@@ -235,7 +248,7 @@ func (q *Queries) GetShoppingList(ctx context.Context, weekStart string) (Shoppi
|
||||
}
|
||||
|
||||
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
|
||||
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, shopping_groups.frozen_dinner_name, dinners.name AS dinner_name
|
||||
FROM shopping_groups
|
||||
JOIN dinners ON dinners.id = shopping_groups.dinner_id
|
||||
WHERE shopping_groups.list_id = ?
|
||||
@@ -247,7 +260,8 @@ type ListShoppingGroupDinnersRow struct {
|
||||
DinnerName string
|
||||
}
|
||||
|
||||
// The groups of list list_id with their dinner's name, in list order.
|
||||
// The groups of list list_id with their dinner's name now, in list order.
|
||||
// A group of a frozen list shows its frozen_dinner_name instead.
|
||||
func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listShoppingGroupDinners, listID)
|
||||
if err != nil {
|
||||
@@ -265,6 +279,7 @@ func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) (
|
||||
&i.ShoppingGroup.Position,
|
||||
&i.ShoppingGroup.CreatedAt,
|
||||
&i.ShoppingGroup.UpdatedAt,
|
||||
&i.ShoppingGroup.FrozenDinnerName,
|
||||
&i.DinnerName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -281,7 +296,7 @@ func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) (
|
||||
}
|
||||
|
||||
const listShoppingGroups = `-- name: ListShoppingGroups :many
|
||||
SELECT id, list_id, dinner_id, date, position, created_at, updated_at FROM shopping_groups
|
||||
SELECT id, list_id, dinner_id, date, position, created_at, updated_at, frozen_dinner_name FROM shopping_groups
|
||||
WHERE list_id = ?
|
||||
ORDER BY position, id
|
||||
`
|
||||
@@ -304,6 +319,7 @@ func (q *Queries) ListShoppingGroups(ctx context.Context, listID string) ([]Shop
|
||||
&i.Position,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FrozenDinnerName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -111,10 +111,13 @@ type pushedPlan struct {
|
||||
// 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.
|
||||
// is generated. title_job_id is the job titling a generated Supply run,
|
||||
// which GET /jobs/{id} reports on; null when it already has its title or
|
||||
// was not generated.
|
||||
type pushedSupplyRun struct {
|
||||
Status string `json:"status"`
|
||||
Error *apiError `json:"error"`
|
||||
Status string `json:"status"`
|
||||
Error *apiError `json:"error"`
|
||||
TitleJobID *string `json:"title_job_id"`
|
||||
}
|
||||
|
||||
// postPlanPush pushes the week's plan to the chosen Google calendar
|
||||
@@ -143,7 +146,9 @@ func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service
|
||||
// gone.
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
run := pushedSupplyRun{Status: "generated"}
|
||||
if _, err := shop.GeneratePushed(ctx, week, revision); errors.Is(err, shopping.ErrFrozen) {
|
||||
if g, err := shop.GeneratePushed(ctx, week, revision); err == nil {
|
||||
run.TitleJobID = g.TitleJobID
|
||||
} else if 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) {
|
||||
@@ -153,7 +158,7 @@ func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service
|
||||
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 {
|
||||
} else {
|
||||
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",
|
||||
|
||||
@@ -107,7 +107,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc,
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinnersOn(t, st), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -167,7 +167,12 @@ func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
|
||||
// with its own unstarted job runner and a grounder that finds nothing.
|
||||
func newTestDinners(t *testing.T) *dinners.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
return newTestDinnersOn(t, newTestStore(t))
|
||||
}
|
||||
|
||||
// newTestDinnersOn is newTestDinners over st.
|
||||
func newTestDinnersOn(t *testing.T, st *store.Store) *dinners.Service {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
|
||||
@@ -60,10 +60,13 @@ type supplyRunItem struct {
|
||||
}
|
||||
|
||||
// generatedSupplyRun is the POST /shopping/{weekStart}/generate answer:
|
||||
// the Supply run as made, and the plan days with nothing to shop for.
|
||||
// the Supply run as made, the plan days with nothing to shop for, and the
|
||||
// title_job_id of the job titling it, which GET /jobs/{id} reports on;
|
||||
// null when the Supply run already has its title.
|
||||
type generatedSupplyRun struct {
|
||||
supplyRun
|
||||
Skipped []shopping.SkippedDay `json:"skipped"`
|
||||
Skipped []shopping.SkippedDay `json:"skipped"`
|
||||
TitleJobID *string `json:"title_job_id"`
|
||||
}
|
||||
|
||||
// sendChatRequest is the POST /shopping/{weekStart}/send-chat body:
|
||||
@@ -108,7 +111,7 @@ func getSupplyRun(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// postSupplyRunGenerate makes, or remakes, the week's Supply run from its
|
||||
// plan and answers with it and the plan days it skipped.
|
||||
// plan and answers with it, the plan days it skipped and its title job.
|
||||
func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
week, ok := weekStartOrFail(w, r, logger)
|
||||
@@ -129,7 +132,7 @@ func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.Hand
|
||||
if skipped == nil {
|
||||
skipped = []shopping.SkippedDay{}
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped})
|
||||
writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped, TitleJobID: g.TitleJobID})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ func (e plansEnv) groundWeek(t *testing.T) {
|
||||
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 {
|
||||
`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -186,7 +186,7 @@ func TestSupplyRunShape(t *testing.T) {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("generate = %d %v", status, raw)
|
||||
}
|
||||
wantTop := []string{"chat_send_unconfirmed_at", "groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "total_count", "weekStart"}
|
||||
wantTop := []string{"chat_send_unconfirmed_at", "groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "title_job_id", "total_count", "weekStart"}
|
||||
if got := keys(raw); !reflect.DeepEqual(got, wantTop) {
|
||||
t.Errorf("generate fields = %v, want %v", got, wantTop)
|
||||
}
|
||||
@@ -199,7 +199,7 @@ func TestSupplyRunShape(t *testing.T) {
|
||||
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" })) {
|
||||
if k := keys(raw); !reflect.DeepEqual(k, slices.DeleteFunc(slices.Clone(wantTop), func(s string) bool { return s == "skipped" || s == "title_job_id" })) {
|
||||
t.Errorf("GET fields = %v", k)
|
||||
}
|
||||
group := raw["groups"].([]any)[0].(map[string]any)
|
||||
@@ -376,8 +376,12 @@ func TestPushPlanMakesSupplyRun(t *testing.T) {
|
||||
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)
|
||||
run, _ := body["supply_run"].(map[string]any)
|
||||
if k := keys(run); run["status"] != "generated" || run["error"] != nil || !reflect.DeepEqual(k, []string{"error", "status", "title_job_id"}) {
|
||||
t.Errorf("supply_run = %v, want generated with no error", run)
|
||||
}
|
||||
if job := titleJob(t, e, run["title_job_id"]); job["status"] != "queued" {
|
||||
t.Errorf("title job = %v, want queued: no runner in this test", job)
|
||||
}
|
||||
var l supplyRunJSON
|
||||
if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l); status != http.StatusOK {
|
||||
@@ -467,9 +471,11 @@ func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
|
||||
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)
|
||||
run, _ := body["supply_run"].(map[string]any)
|
||||
if run["status"] != "generated" || run["error"] != nil {
|
||||
t.Errorf("supply_run = %v, want generated with no error", run)
|
||||
}
|
||||
titleJobID, _ := run["title_job_id"].(string)
|
||||
held.waitEntered(t)
|
||||
var l supplyRunJSON
|
||||
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
|
||||
@@ -478,6 +484,7 @@ func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
|
||||
}
|
||||
|
||||
close(held.release)
|
||||
wantTitleJobSucceeded(t, e, titleJobID)
|
||||
if got := e.waitTitled(t); got != "Chili and Friends" {
|
||||
t.Errorf("title = %q, want the title job's", got)
|
||||
}
|
||||
@@ -493,7 +500,8 @@ func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
|
||||
e.startRunner(t)
|
||||
|
||||
var l supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l); status != http.StatusOK {
|
||||
status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("generate = %d %v", status, body)
|
||||
}
|
||||
if l.Title != nil {
|
||||
@@ -502,12 +510,66 @@ func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
|
||||
if !reflect.DeepEqual(l.render(), weekLines) {
|
||||
t.Errorf("generated = %v, want %v", l.render(), weekLines)
|
||||
}
|
||||
titleJobID, _ := body["title_job_id"].(string)
|
||||
held.waitEntered(t)
|
||||
|
||||
close(held.release)
|
||||
wantTitleJobSucceeded(t, e, titleJobID)
|
||||
if got := e.waitTitled(t); got != "Chili and Friends" {
|
||||
t.Errorf("title = %q, want the title job's", got)
|
||||
}
|
||||
if _, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil); body["title_job_id"] != nil {
|
||||
t.Errorf("remake title_job_id = %v, want null: the Supply run has its title", body["title_job_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupplyRunGenerateTitleJobFails: a title job that cannot title the
|
||||
// Supply run is reported failed with title_failed by GET /jobs/{id}, and
|
||||
// the week's next generation answers with a new title job.
|
||||
func TestSupplyRunGenerateTitleJobFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
e.groundWeek(t)
|
||||
e.startRunner(t)
|
||||
|
||||
_, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
|
||||
first := titleJob(t, e, body["title_job_id"])
|
||||
job := waitJob(t, e.h, first["id"].(string))
|
||||
if job["status"] != "failed" || job["error"] != shopping.ReasonTitleFailed {
|
||||
t.Errorf("title job = %v, want failed %s", job, shopping.ReasonTitleFailed)
|
||||
}
|
||||
|
||||
_, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
|
||||
if second := titleJob(t, e, body["title_job_id"]); second["id"] == first["id"] {
|
||||
t.Errorf("retry title_job_id = %v, want a new job", second["id"])
|
||||
}
|
||||
}
|
||||
|
||||
// titleJob returns the title_supply_run job id, as GET /jobs/{id} reports
|
||||
// it; id must be a job id string.
|
||||
func titleJob(t *testing.T, e plansEnv, id any) map[string]any {
|
||||
t.Helper()
|
||||
s, ok := id.(string)
|
||||
if !ok || s == "" {
|
||||
t.Fatalf("title_job_id = %v, want a job id", id)
|
||||
}
|
||||
rec, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+s, "")
|
||||
if rec.Code != http.StatusOK || job["kind"] != shopping.KindTitleSupplyRun {
|
||||
t.Fatalf("GET title job = %d %s, want a %s job", rec.Code, rec.Body, shopping.KindTitleSupplyRun)
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
// wantTitleJobSucceeded waits for title job id to finish and fails t
|
||||
// unless GET /jobs/{id} reports it succeeded with the title written.
|
||||
func wantTitleJobSucceeded(t *testing.T, e plansEnv, id string) {
|
||||
t.Helper()
|
||||
titleJob(t, e, id)
|
||||
job := waitJob(t, e.h, id)
|
||||
result, _ := job["result"].(map[string]any)
|
||||
if job["status"] != "succeeded" || result["title"] != "Chili and Friends" {
|
||||
t.Errorf("title job = %v, want succeeded with the title", job)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedPushMakesNoSupplyRun: a push Calendar refused makes no Supply
|
||||
@@ -546,7 +608,7 @@ func TestPushPlanSupplyRunFails(t *testing.T) {
|
||||
"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,
|
||||
}}
|
||||
}, "title_job_id": nil}
|
||||
if !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
}
|
||||
@@ -599,7 +661,7 @@ func TestPushPlanEditedBeforeSupplyRun(t *testing.T) {
|
||||
"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,
|
||||
}}
|
||||
}, "title_job_id": nil}
|
||||
if !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
}
|
||||
@@ -632,7 +694,7 @@ func TestPushPlanLeavesArchivedSupplyRun(t *testing.T) {
|
||||
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" {
|
||||
if apiErr, _ := run["error"].(map[string]any); run["status"] != "archived" || apiErr["code"] != "archived" || run["title_job_id"] != nil {
|
||||
t.Errorf("supply_run = %v, want archived", body["supply_run"])
|
||||
}
|
||||
var l supplyRunJSON
|
||||
@@ -790,6 +852,73 @@ func TestSupplyArchive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupplyArchiveKeepsFrozenNames freezes last week's Supply run, then
|
||||
// renames its dinner and this week's through the dinners API: the frozen
|
||||
// post and list keep the name the dinner had when frozen, while this
|
||||
// week's list, not frozen, shows the new name.
|
||||
func TestSupplyArchiveKeepsFrozenNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM plan_days; DELETE FROM week_plans`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := household.LoadZone("America/New_York") // the env's household zone
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
thisWeek := household.WeekStart(household.Today(time.Now(), zone))
|
||||
y, m, d := thisWeek.Date()
|
||||
lastWeek := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
last := e.weekSupplyRun(t, lastWeek, "Beef Stew", shopIng{"stewing beef", "2", "lb"})
|
||||
current := e.weekSupplyRun(t, thisWeek, "Lemon Chicken", shopIng{"lemon", "1", ""})
|
||||
if err := e.shop.Freeze(t.Context(), lastWeek); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for id, name := range map[string]string{
|
||||
last.Groups[0].Dinner.ID: "Sunday Beef Stew",
|
||||
current.Groups[0].Dinner.ID: "Lemon Garlic Chicken",
|
||||
} {
|
||||
if status, body := callShopping(t, e, http.MethodPatch, dinnersPath+"/"+id, `{"name":"`+name+`"}`, nil); status != http.StatusOK {
|
||||
t.Fatalf("PATCH dinner %s = %d %v", id, status, body)
|
||||
}
|
||||
}
|
||||
|
||||
var got archiveJSON
|
||||
if status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got); status != http.StatusOK {
|
||||
t.Fatalf("GET archive = %d %v", status, raw)
|
||||
}
|
||||
var posts []string
|
||||
for _, p := range got.Periods {
|
||||
for _, post := range p.Posts {
|
||||
posts = append(posts, fmt.Sprintf("%s %v current=%t", post.WeekStart, post.Dinners, post.Current))
|
||||
}
|
||||
}
|
||||
want := []string{
|
||||
thisWeek.String() + " [Lemon Garlic Chicken] current=true",
|
||||
lastWeek.String() + " [Beef Stew] current=false",
|
||||
}
|
||||
if !reflect.DeepEqual(posts, want) {
|
||||
t.Errorf("archive after rename =\n %s\nwant\n %s", strings.Join(posts, "\n "), strings.Join(want, "\n "))
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
week store.LocalDate
|
||||
want string
|
||||
}{
|
||||
{lastWeek, "Monday " + lastWeek.String() + " Beef Stew: stewing beef [2 lb] needed"},
|
||||
{thisWeek, "Monday " + thisWeek.String() + " Lemon Garlic Chicken: lemon [1] needed"},
|
||||
} {
|
||||
var l supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+tc.week.String(), "", &l); status != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d %v", tc.week, status, body)
|
||||
}
|
||||
if got := l.render(); !reflect.DeepEqual(got, []string{tc.want}) {
|
||||
t.Errorf("list %s after rename = %v, want [%s]", tc.week, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupplyRunSendChat posts the week's Supply run to Google Chat once,
|
||||
// records sent_to_chat_at, answers a second send with the status as it
|
||||
// is, and posts again only with resend.
|
||||
|
||||
@@ -22,7 +22,9 @@ const (
|
||||
|
||||
// Freeze moves the Supply run of the week starting weekStart into the
|
||||
// Supply archive: its status becomes archived and frozen_at is set, after
|
||||
// which it is read-only (Generate and SetNeeded are ErrFrozen). Freezing a
|
||||
// which it is read-only (Generate and SetNeeded are ErrFrozen). The list
|
||||
// keeps the names its dinners have as it is frozen, so its post in the
|
||||
// Supply archive shows them whatever a dinner is renamed later. Freezing a
|
||||
// frozen list does nothing and is no error.
|
||||
//
|
||||
// The week must have ended in the household's time zone: a week not yet
|
||||
@@ -79,14 +81,25 @@ func (s *Service) freeze(ctx context.Context, weekStart store.LocalDate, now tim
|
||||
"week", list.WeekStart, "list", list.ID, "error", err)
|
||||
}
|
||||
}
|
||||
// A list frozen meanwhile is left as it is: the freeze is done.
|
||||
// A list frozen meanwhile is left as it is, its names as they were
|
||||
// frozen: the freeze is done.
|
||||
stamp := store.Now().String()
|
||||
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
|
||||
FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
|
||||
}
|
||||
return nil
|
||||
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
n, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
|
||||
FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := q.FreezeShoppingGroupNames(ctx, list.ID); err != nil {
|
||||
return fmt.Errorf("shopping: freeze %s: keep dinner names: %w", weekStart, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// freezeTitleJob is the title job of list as Generate would enqueue it:
|
||||
@@ -145,7 +158,8 @@ type Post struct {
|
||||
Label string
|
||||
// Title is nil while the list is untitled, when Label stands in.
|
||||
Title *string
|
||||
// Dinners are the names of the list's dinners, in day order.
|
||||
// Dinners are the names of the list's dinners, in day order: as they
|
||||
// were when the list was frozen, or as they are now while it is not.
|
||||
Dinners []string
|
||||
// Items is how many items the list has, and OnHand how many of them
|
||||
// the household marked "have it".
|
||||
|
||||
@@ -422,7 +422,7 @@ func TestArchiveGroups(t *testing.T) {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili, Noodles & Co' WHERE week_start = '2026-12-28'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2027-01-03T11:05:00.000Z' WHERE week_start < '2027-01-04'`); err != nil {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2027-01-03T11:05:00.000Z' WHERE week_start < '2027-01-04'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ func TestSendToChatFailuresRecordNothing(t *testing.T) {
|
||||
t.Errorf("after failed sends sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want both unset", l.SentToChatAt, l.ChatSendUnconfirmedAt, err)
|
||||
}
|
||||
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
posts := len(e.chat.Posts())
|
||||
|
||||
@@ -492,7 +492,9 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, e
|
||||
return readList(ctx, q, list)
|
||||
}
|
||||
|
||||
// readList reads the groups and items of list through q.
|
||||
// readList reads the groups and items of list through q. A group of a
|
||||
// frozen list is named as its dinner was when the list was frozen; one
|
||||
// with no such name is an error.
|
||||
func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, error) {
|
||||
rows, err := q.ListShoppingGroupDinners(ctx, list.ID)
|
||||
if err != nil {
|
||||
@@ -512,8 +514,15 @@ func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, e
|
||||
groups := make([]Group, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
g := r.ShoppingGroup
|
||||
name := r.DinnerName
|
||||
if frozen(list) {
|
||||
if !g.FrozenDinnerName.Valid {
|
||||
return List{}, fmt.Errorf("shopping: group %s of frozen list %s has no frozen dinner name", g.ID, list.WeekStart)
|
||||
}
|
||||
name = g.FrozenDinnerName.String
|
||||
}
|
||||
groups = append(groups, Group{
|
||||
ID: g.ID, DinnerID: g.DinnerID, DinnerName: r.DinnerName, Date: g.Date, Position: int(g.Position),
|
||||
ID: g.ID, DinnerID: g.DinnerID, DinnerName: name, Date: g.Date, Position: int(g.Position),
|
||||
Items: byGroup[g.ID],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -692,7 +692,7 @@ func TestGenerateRefusesFrozenList(t *testing.T) {
|
||||
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 {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.swap(t, "2026-10-12", "Pad Thai")
|
||||
@@ -834,7 +834,7 @@ func TestSetNeededRefusesFrozenList(t *testing.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 {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -239,7 +239,7 @@ func TestGenerateTitleNotStoredOnFrozenList(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||||
e.beforeTitle = func() {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
@@ -311,3 +311,38 @@ func TestGenerateTitleOverlapAcrossRename(t *testing.T) {
|
||||
t.Errorf("stored title = %v, want one of the two titles", deref(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitleNotStoredOnReplacedList: a list replaced by a new
|
||||
// untitled list of the same week once archive_title has answered, before
|
||||
// its title is stored, keeps the replacement untitled; the old list's
|
||||
// title job fails, and the failure is logged.
|
||||
func TestGenerateTitleNotStoredOnReplacedList(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||||
e.afterTitle = func() {
|
||||
for _, stmt := range []string{
|
||||
`DELETE FROM shopping_items WHERE group_id IN (SELECT id FROM shopping_groups WHERE list_id IN (SELECT id FROM shopping_lists WHERE week_start = ?1))`,
|
||||
`DELETE FROM shopping_groups WHERE list_id IN (SELECT id FROM shopping_lists WHERE week_start = ?1)`,
|
||||
`UPDATE shopping_lists SET id = 'replacement' WHERE week_start = ?1`,
|
||||
} {
|
||||
if _, err := e.st.Writer.Exec(stmt, week.String()); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g := e.generate(t)
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusFailed, shopping.ReasonTitleFailed)
|
||||
|
||||
var id string
|
||||
if err := e.st.Reader.QueryRow(`SELECT id FROM shopping_lists WHERE week_start = ?`, week.String()).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "replacement" || id == g.List.ID {
|
||||
t.Fatalf("week's list = %s, want the replacement of %s", id, g.List.ID)
|
||||
}
|
||||
wantUntitled(t, "replacement's title", e.storedTitle(t))
|
||||
if !strings.Contains(e.log.String(), "the Supply run was frozen or replaced before its title was stored") {
|
||||
t.Errorf("log lacks the failure:\n%s", e.log.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||
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,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" {
|
||||
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,0017_shopping_list_chat_send_unconfirmed", got)
|
||||
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,0017_shopping_list_chat_send_unconfirmed,0019_shopping_group_frozen_name" {
|
||||
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,0017_shopping_list_chat_send_unconfirmed,0019_shopping_group_frozen_name", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
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,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" {
|
||||
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,0017_shopping_list_chat_send_unconfirmed", got)
|
||||
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,0017_shopping_list_chat_send_unconfirmed,0019_shopping_group_frozen_name" {
|
||||
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,0017_shopping_list_chat_send_unconfirmed,0019_shopping_group_frozen_name", got)
|
||||
}
|
||||
if !tableExists(t, s, "dinners") {
|
||||
t.Error("table from 0001_init was not created")
|
||||
@@ -922,3 +922,57 @@ func TestMigrateShoppingListChatSendUnconfirmed(t *testing.T) {
|
||||
t.Errorf("shopping list after 0017 = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateShoppingGroupFrozenName applies 0019 over a frozen list and
|
||||
// a current one: the frozen list's group takes its dinner's name now, the
|
||||
// current list's group has none, and a frozen name is never empty.
|
||||
func TestMigrateShoppingGroupFrozenName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
migrateBefore(t, s, "0019")
|
||||
for _, stmt := range []string{
|
||||
`INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at)
|
||||
VALUES ('dinner-1', 'Beef Stew', 'beef stew', 'regular', 'regular', 0, 'manual', 'c', 'u'),
|
||||
('dinner-2', 'Lemon Chicken', 'lemon chicken', 'regular', 'regular', 0, 'manual', 'c', 'u')`,
|
||||
`INSERT INTO week_plans (id, week_start, status, created_at, updated_at)
|
||||
VALUES ('plan-1', '2026-10-05', 'synced', 'c', 'u'),
|
||||
('plan-2', '2026-10-12', 'synced', 'c', 'u')`,
|
||||
`INSERT INTO shopping_lists (id, week_start, plan_id, status, frozen_at, created_at, updated_at)
|
||||
VALUES ('list-1', '2026-10-05', 'plan-1', 'archived', 'f', 'c', 'u'),
|
||||
('list-2', '2026-10-12', 'plan-2', 'current', NULL, 'c', 'u')`,
|
||||
`INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
|
||||
VALUES ('group-1', 'list-1', 'dinner-1', '2026-10-05', 0, 'c', 'u'),
|
||||
('group-2', 'list-2', 'dinner-2', '2026-10-12', 0, 'c', 'u')`,
|
||||
} {
|
||||
if _, err := s.Writer.Exec(stmt); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := s.Reader.QueryContext(ctx, `SELECT id || '|' || COALESCE(frozen_dinner_name, 'NULL') || '|' || updated_at FROM shopping_groups ORDER BY id`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var got []string
|
||||
for rows.Next() {
|
||||
var row string
|
||||
if err := rows.Scan(&row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"group-1|Beef Stew|u", "group-2|NULL|u"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("shopping groups after 0019 = %v, want %v", got, want)
|
||||
}
|
||||
if _, err := s.Writer.Exec(`UPDATE shopping_groups SET frozen_dinner_name = '' WHERE id = 'group-1'`); err == nil || !strings.Contains(err.Error(), "CHECK constraint failed") {
|
||||
t.Errorf("empty frozen name: error %v, want CHECK constraint failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 0019_shopping_group_frozen_name: a Supply run frozen into the Supply
|
||||
-- archive keeps the names its dinners had when it was frozen. A dinner can
|
||||
-- be renamed at any time, so a frozen list reads each group's dinner name
|
||||
-- from frozen_dinner_name, never from the dinner, as 0012 does for a Pass.
|
||||
-- Only a group of a frozen list has one; a current list reads its dinners'
|
||||
-- names live. A list frozen before this takes its dinners' names now.
|
||||
|
||||
ALTER TABLE shopping_groups ADD COLUMN frozen_dinner_name TEXT CHECK (frozen_dinner_name <> '');
|
||||
|
||||
UPDATE shopping_groups
|
||||
SET frozen_dinner_name = (SELECT dinners.name FROM dinners WHERE dinners.id = shopping_groups.dinner_id)
|
||||
WHERE list_id IN (
|
||||
SELECT id FROM shopping_lists WHERE status = 'archived' OR frozen_at IS NOT NULL
|
||||
);
|
||||
@@ -60,7 +60,8 @@ DELETE FROM shopping_items
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: ListShoppingGroupDinners :many
|
||||
-- The groups of list list_id with their dinner's name, in list order.
|
||||
-- The groups of list list_id with their dinner's name now, in list order.
|
||||
-- A group of a frozen list shows its frozen_dinner_name instead.
|
||||
SELECT sqlc.embed(shopping_groups), dinners.name AS dinner_name
|
||||
FROM shopping_groups
|
||||
JOIN dinners ON dinners.id = shopping_groups.dinner_id
|
||||
@@ -117,6 +118,13 @@ UPDATE shopping_lists
|
||||
SET status = 'archived', frozen_at = ?, updated_at = ?
|
||||
WHERE id = ? AND frozen_at IS NULL;
|
||||
|
||||
-- name: FreezeShoppingGroupNames :exec
|
||||
-- Keeps the name each dinner of list list_id has now as its group's
|
||||
-- frozen_dinner_name, as the list is frozen into the Supply archive.
|
||||
UPDATE shopping_groups
|
||||
SET frozen_dinner_name = (SELECT dinners.name FROM dinners WHERE dinners.id = shopping_groups.dinner_id)
|
||||
WHERE list_id = ?;
|
||||
|
||||
-- name: ListShoppingListsThrough :many
|
||||
-- The Supply runs of the weeks starting on or before week_start, newest
|
||||
-- week first.
|
||||
|
||||
Reference in New Issue
Block a user