Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 1d19fb9545 Title Supply runs in a job, scoped by their dinner names
CI / ci (push) Successful in 4m52s
CI / ci (pull_request) Successful in 4m55s
archive_title's single-flight scope was <weekStart>@<plan revision>, but
renaming a planned dinner changes the names to title without changing
the plan revision, so two overlapping generations across a rename could
share one model result. The scope is now
<weekStart>@<plan revision>/<sha256 of the ordered dinner names>, the
same list the skill is given, through the harness's own guard.

Push to Calendar and POST /shopping/{weekStart}/generate also waited
for the title call. Generation now enqueues a title_supply_run job
(service-only, in the generation's transaction) for a list still
untitled and returns without waiting; the list reads untitled until the
job stores its title. A failed title still leaves the list untitled, is
logged, recorded in ai_runs, fails the job title_failed, and is retried
by the week's next generation. A titled list enqueues no job.

Maestro task fa4b94c9-6623-41ab-8d2a-0ca7d1e6401a.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:23:16 -04:00
dev_agentandClaude Opus 5.5 fdfd0123fb Add the archive_title skill and title Supply runs on generation
CI / ci (pull_request) Successful in 4m53s
CI / ci (push) Successful in 4m48s
archive_title v1 writes a Supply run's post title for the Supply archive
from the week's dinner names in day order. Validation requires 1-48
characters on one line, no emoji and no surrounding quotation marks; a
reply breaking a rule gets the harness's one repair turn.

shopping.Generate and GeneratePushed run it, outside the generation's
transaction, whenever the stored list is still untitled, and store
shopping_lists.title only while the list is untitled and not frozen. A
title failure never fails the generation: the list stays untitled, the
failure is logged and recorded in ai_runs, and the week's next
generation tries again. A titled list keeps its title on remakes.

Maestro task fa4b94c9-6623-41ab-8d2a-0ca7d1e6401a.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:31:59 -04:00
orchestrator-agent c4ea7a855e Supply run API, item toggles and the Push to Calendar hook (Feature 17) (#133)
CI / ci (push) Successful in 5m32s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 05:21:11 -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
37 changed files with 4041 additions and 81 deletions
+20 -2
View File
@@ -28,6 +28,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
@@ -132,6 +133,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
shopSvc, err := shopping.NewService(db, archiveTitler(ai, guard), runner, logger)
if err != nil {
return err
}
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil {
return err
@@ -148,7 +154,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, googleSvc, runner, photos, frontend.Assets())
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, runner, photos, frontend.Assets())
if err != nil {
return err
}
@@ -274,7 +280,7 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
// service registers its own kinds when it is built, before the runner
// starts (dinners.NewService registers dinners.KindGroundDinner and
// suggest.NewService suggest.KindSuggestionBatch, plan.NewService
// plan.KindDraftWeek).
// plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun).
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
builtins, err := jobs.RegisterBuiltins(runner)
if err != nil {
@@ -319,6 +325,18 @@ func weekDrafter(ai *aisettings.Service, guard *harness.Guard) plan.Drafter {
}
}
// archiveTitler runs archive_title with the AI settings current at each
// run, sharing guard.
func archiveTitler(ai *aisettings.Service, guard *harness.Guard) shopping.Titler {
return func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
cfg, err := ai.HarnessConfig(ctx, guard)
if err != nil {
return skills.ArchiveTitle{}, err
}
return skills.TitleArchive(ctx, cfg, scope, in)
}
}
// logListening logs the startup line. Only the public URL's origin is logged
// so no credential-bearing URL component can reach the log.
func logListening(logger *slog.Logger, cfg config.Config) {
+54 -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). |
| `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, 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_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 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. |
| `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. |
@@ -470,6 +470,26 @@ automatic** (D-021).
not a Regular; and one whose reason is over 180 characters or names none
of its cited Regulars. Cited names are rewritten as the household writes
them. Grounding each survivor in a recipe is the caller's next step.
- **Supply run titles** (`skills.TitleArchive`, skill `archive_title`
v1): from the names of the week's planned dinners in day order, writes
`{title}`, the Supply run's post title in the Supply archive, at the
household temperature with no household context. A title must be 1–48
characters on one line, with no emoji and no surrounding quotation
marks; a reply breaking a rule gets the one repair turn, never a
truncation. `shopping.Generate` (and `GeneratePushed`), on storing a
list that is still untitled — a new list, or one a failed title left
untitled — enqueues a `title_supply_run` job in the generation's
transaction and returns without waiting for it, so neither Push to
Calendar nor `POST /shopping/{weekStart}/generate` waits for a model
call; the list reads untitled until the job stores its title. The job's
scope is `<weekStart>@<plan revision>/<sha256 of the dinner names in
order>`, so runs share a title only for the same names (a renamed dinner
changes the names, not the revision). It writes `shopping_lists.title`
only while the list is untitled and not frozen. A title is written once;
a remake keeps it and enqueues no job. A failed title never fails the
generation: the list stays untitled (the UI shows the week label), the
failure is logged as a warning and recorded in `ai_runs`, the job fails
`title_failed`, and the week's next generation tries again.
## Recipe grounding
@@ -738,9 +758,38 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
failures), 404 when the week
has no plan, 400 `invalid_week`. The push holds the account lock
across its Google requests and its record, like listing and choosing.
- The week's Supply run (Feature 17) is made from a pushed plan: a
successful `Push` returns that plan, which is where Feature 17 hooks
in. Nothing here generates a shopping list.
- The week's Supply run (Feature 17) is made from a pushed plan: after
a successful `Push`, the handler runs `shopping.GeneratePushed` for
the week at the plan revision `Push` pushed, and the 200 answer
carries `supply_run: {status, error}`. `status` is `generated`;
`archived` when the week's Supply run is in the Supply archive and was
left as it was; or `failed` — error `plan_changed` (retryable, logged
as a warning) when the plan was edited between the push and
generation, so no Supply run is made from a plan Calendar does not
show and pushing again makes it; or error `supply_run_failed`
(retryable, logged as an error) when it could not be made, and
`POST /api/v1/shopping/{weekStart}/generate` makes it again.
Generation checks the revision inside its own transaction, and never
fails or undoes the push — Calendar already shows the plan.
## Supply run API (Feature 17)
- `GET /api/v1/shopping/{weekStart}` returns `{weekStart, label, title,
status, sent_to_chat_at, needed_count, total_count, groups: [{dinner:
{id, name}, date, day, items: [{id, name, qty, needed}]}]}`: `label` is
the week label (shown while `title` is null), `day` the plan day's
weekday ("Monday"), `qty` null when the recipe gives none,
`needed_count` the items still needed and `total_count` all items. 404
when the week has no Supply run, 400 `invalid_week`.
- `POST /api/v1/shopping/{weekStart}/generate` makes or remakes the list
through `shopping.Generate` and answers with the same shape plus
`skipped: [{date, dinner_id, dinner_name, reason}]`. 404 when the week
has no plan, 409 `archived` when its list is in the Supply archive.
- `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required
boolean; it sets the flag, it does not flip it) and answers `{id,
needed, needed_count, total_count}` with the list's counts after the
change. 404 for an unknown item, 409 `archived` when the list is in the
Supply archive, 400 `invalid_request` for a bad body.
## Frontend
+148
View File
@@ -0,0 +1,148 @@
package skills
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// TitleLimit is the most characters a Supply run's title may have.
const TitleLimit = 48
// ArchiveTitleRequest is what archive_title reads.
type ArchiveTitleRequest struct {
// Dinners are the names of the week's planned dinners in day order,
// Monday first. It must hold at least one, none blank.
Dinners []string
}
// ArchiveTitle is archive_title's output.
type ArchiveTitle struct {
// Title is the Supply run's post title in the Supply archive: 1 to
// TitleLimit characters on one line, with no emoji and no surrounding
// quotation marks.
Title string `json:"title"`
}
// TitleArchive runs archive_title v1 on in and returns the title of the
// week's Supply run, written from its dinners. A request with no dinners,
// or a blank one, fails before any LLM call. scope names the week and its
// dinners (see harness.Run).
func TitleArchive(ctx context.Context, cfg harness.Config, scope string, in ArchiveTitleRequest) (ArchiveTitle, error) {
return harness.Run(ctx, cfg, archiveTitle{}, scope, in)
}
// archiveTitle is the skill.
type archiveTitle struct{}
var _ harness.Skill[ArchiveTitleRequest, ArchiveTitle] = archiveTitle{}
func (archiveTitle) Name() string { return "archive_title" }
func (archiveTitle) Version() int { return 1 }
func (archiveTitle) Timeout() time.Duration { return harness.DefaultTimeout }
// archiveTitlePrompt is the template data of archive_title.v1.tmpl.
type archiveTitlePrompt struct {
// Dinners are the week's dinner names in day order, each on one line.
Dinners []string
}
func (archiveTitle) PromptInput(in ArchiveTitleRequest) (harness.PromptInput, error) {
if len(in.Dinners) == 0 {
return harness.PromptInput{}, errors.New("the week has no dinners to title it from")
}
var p archiveTitlePrompt
for i, name := range in.Dinners {
name = oneLine(name)
if name == "" {
return harness.PromptInput{}, fmt.Errorf("dinner %d has a blank name", i+1)
}
p.Dinners = append(p.Dinners, name)
}
return harness.PromptInput{Data: p}, nil
}
func (archiveTitle) Schema() llm.JSONSchema {
return llm.JSONSchema{Name: "archive_title", Schema: json.RawMessage(`{
"type": "object",
"properties": {
"title": {"type": "string"}
},
"required": ["title"],
"additionalProperties": false
}`)}
}
// UnmarshalJSON decodes the output, requiring its field as the schema
// does.
func (t *ArchiveTitle) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "title"); err != nil {
return fmt.Errorf("output %s", err)
}
type plain ArchiveTitle
return json.Unmarshal(data, (*plain)(t))
}
// quotes are the quotation marks a title may not start or end with.
const quotes = `"'` + "`" + "“”‘’„‚«»‹›「」『』"
// Validate finds every broken rule of the title as PostProcess will store
// it, on one line: empty, over TitleLimit characters, holding an emoji, or
// starting or ending with a quotation mark.
func (archiveTitle) Validate(out ArchiveTitle) []string {
var problems []string
title := oneLine(out.Title)
switch n := utf8.RuneCountInString(title); {
case n == 0:
problems = append(problems, fmt.Sprintf("title is empty; give a title of 1 to %d characters", TitleLimit))
case n > TitleLimit:
problems = append(problems, fmt.Sprintf("title %q has %d characters; shorten it to at most %d", title, n, TitleLimit))
}
for _, r := range title {
if isEmoji(r) {
problems = append(problems, fmt.Sprintf("title %q has an emoji (%U); use words only", title, r))
break
}
}
if first, _ := utf8.DecodeRuneInString(title); title != "" && strings.ContainsRune(quotes, first) {
problems = append(problems, fmt.Sprintf("title %q starts with a quotation mark; give the title without quotation marks around it", title))
} else if last, _ := utf8.DecodeLastRuneInString(title); title != "" && strings.ContainsRune(quotes, last) {
problems = append(problems, fmt.Sprintf("title %q ends with a quotation mark; give the title without quotation marks around it", title))
}
return problems
}
// isEmoji reports whether r is an emoji or a part of one: a pictograph,
// a symbol from the emoji blocks, a regional indicator, a keycap, a skin
// tone, an emoji variation selector or the zero-width joiner.
func isEmoji(r rune) bool {
switch {
case r >= 0x1F000 && r <= 0x1FAFF: // tiles, cards, enclosed, pictographs, emoticons, transport, supplemental symbols
return true
case r >= 0x2600 && r <= 0x27BF: // miscellaneous symbols and dingbats
return true
case r >= 0x2B00 && r <= 0x2BFF: // arrows and stars such as ⭐ and ⬛
return true
case r >= 0x2300 && r <= 0x23FF: // technical symbols such as ⌚ and ⏰
return true
case r >= 0xE0020 && r <= 0xE007F: // tag sequences of flags
return true
case r == 0x200D, r == 0x20E3, r == 0xFE0F: // joiner, keycap, emoji presentation
return true
}
return false
}
// PostProcess puts the title on one line, collapsing its white space.
func (archiveTitle) PostProcess(out ArchiveTitle) (ArchiveTitle, error) {
return ArchiveTitle{Title: oneLine(out.Title)}, nil
}
+170
View File
@@ -0,0 +1,170 @@
package skills
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// titleWeek is a week of seven dinners, Monday first.
var titleWeek = ArchiveTitleRequest{Dinners: []string{
"Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Pad Thai", "Fish Tacos",
}}
func titleReply(title string) llm.GenerateResult {
return reply(fmt.Sprintf(`{"title":%q}`, title))
}
// title runs TitleArchive on in with f and checks f was called exactly
// calls times, each at the household temperature with the archive_title
// schema.
func title(t *testing.T, f *llm.Fake, in ArchiveTitleRequest, calls int) (ArchiveTitle, []llm.Request, error) {
t.Helper()
out, err := TitleArchive(context.Background(), testConfig(t, f), "2026-10-12", in)
reqs := f.Requests()
if len(reqs) != calls {
t.Fatalf("Generate called %d times, want %d", len(reqs), calls)
}
if g, _, _ := f.Remaining(); g != 0 {
t.Fatalf("%d scripted Generate results unused", g)
}
for i, req := range reqs {
if req.Temperature == nil || *req.Temperature != 0.7 {
t.Fatalf("request %d temperature = %v, want 0.7", i+1, req.Temperature)
}
if req.Schema == nil || req.Schema.Name != "archive_title" {
t.Fatalf("request %d schema = %v, want archive_title", i+1, req.Schema)
}
}
return out, reqs, err
}
func TestArchiveTitleValid(t *testing.T) {
var f llm.Fake
f.QueueGenerate(titleReply(" Slow Simmers\nand Taco Night "))
out, reqs, err := title(t, &f, titleWeek, 1)
if err != nil {
t.Fatal(err)
}
if out.Title != "Slow Simmers and Taco Night" {
t.Fatalf("title = %q", out.Title)
}
system, user := reqs[0].System, reqs[0].Messages[0].Content
golden(t, "archive_title_valid.system.golden", system)
golden(t, "archive_title_valid.user.golden", user)
want := "Title the week whose dinners are, in day order from Monday:\n" +
"- Beef Chili\n- Pot Roast\n- Chicken Fajitas\n- Spaghetti Bolognese\n- Sheet Pan Salmon\n- Pad Thai\n- Fish Tacos"
if user != want {
t.Errorf("user message = %q, want %q", user, want)
}
if !strings.Contains(system, "## Output contract") || !strings.Contains(system, `"title"`) {
t.Error("system prompt lacks the output contract")
}
}
// titleRepaired checks a first reply is repaired once, quoting it and the
// problem wanted.
func titleRepaired(t *testing.T, first, want string) {
t.Helper()
var f llm.Fake
f.QueueGenerate(titleReply(first), titleReply("Chili, Roast and Taco Night"))
out, reqs, err := title(t, &f, titleWeek, 2)
if err != nil {
t.Fatal(err)
}
if out.Title != "Chili, Roast and Taco Night" {
t.Fatalf("title = %q", out.Title)
}
repair := reqs[1].Messages
if len(repair) != 3 || repair[1].Content != fmt.Sprintf(`{"title":%q}`, first) {
t.Fatalf("repair turn does not quote the first reply: %+v", repair)
}
if !strings.Contains(repair[2].Content, want) {
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, want)
}
}
func TestArchiveTitleTooLongRepaired(t *testing.T) {
long := "A Week of Slow Simmers, Sizzling Fajitas and Taco Night" // 55 characters
titleRepaired(t, long, fmt.Sprintf("title %q has 55 characters; shorten it to at most 48", long))
}
func TestArchiveTitleEmojiRepaired(t *testing.T) {
titleRepaired(t, "Taco Night 🌮", `title "Taco Night 🌮" has an emoji (U+1F32E); use words only`)
}
func TestArchiveTitleQuotedRepaired(t *testing.T) {
titleRepaired(t, `"Taco Night"`, `title "\"Taco Night\"" starts with a quotation mark; give the title without quotation marks around it`)
}
func TestArchiveTitleEmojiTwiceFails(t *testing.T) {
var f llm.Fake
f.QueueGenerate(titleReply("Chili Week 🔥"), titleReply("Chili Week ❤️"))
out, _, err := title(t, &f, titleWeek, 2)
var bad *harness.BadOutputError
if !errors.As(err, &bad) || !errors.Is(err, llm.ErrBadOutput) {
t.Fatalf("err = %v, want a BadOutputError", err)
}
if !slices.Equal(bad.Repair, []string{`title "Chili Week ❤️" has an emoji (U+2764); use words only`}) {
t.Fatalf("repair problems = %q", bad.Repair)
}
if out.Title != "" {
t.Fatalf("title = %q, want none", out.Title)
}
}
func TestArchiveTitleRules(t *testing.T) {
s := archiveTitle{}
for _, tc := range []struct {
title string
want []string
}{
{"Taco Night", nil},
{strings.Repeat("a", TitleLimit), nil},
{"Crème Brûlée & Co.", nil},
{"Fajitas — Then Tacos", nil},
{"Mom's Pot Roast Week", nil},
{" Spaced out ", nil},
{"", []string{"title is empty; give a title of 1 to 48 characters"}},
{" \n ", []string{"title is empty; give a title of 1 to 48 characters"}},
{strings.Repeat("é", TitleLimit+1), []string{fmt.Sprintf("title %q has 49 characters; shorten it to at most 48", strings.Repeat("é", TitleLimit+1))}},
{"Hot ☕ Week", []string{`title "Hot ☕ Week" has an emoji (U+2615); use words only`}},
{"Star ⭐ Week", []string{`title "Star ⭐ Week" has an emoji (U+2B50); use words only`}},
{"Go 🇮🇹", []string{`title "Go 🇮🇹" has an emoji (U+1F1EE); use words only`}},
{"‘Taco Night’", []string{`title "‘Taco Night’" starts with a quotation mark; give the title without quotation marks around it`}},
{"Taco Night”", []string{`title "Taco Night”" ends with a quotation mark; give the title without quotation marks around it`}},
{"«Pasta» 🍝 " + strings.Repeat("x", 45), []string{
fmt.Sprintf("title %q has 55 characters; shorten it to at most 48", "«Pasta» 🍝 "+strings.Repeat("x", 45)),
fmt.Sprintf("title %q has an emoji (U+1F35D); use words only", "«Pasta» 🍝 "+strings.Repeat("x", 45)),
fmt.Sprintf("title %q starts with a quotation mark; give the title without quotation marks around it", "«Pasta» 🍝 "+strings.Repeat("x", 45)),
}},
} {
if got := s.Validate(ArchiveTitle{Title: tc.title}); !slices.Equal(got, tc.want) {
t.Errorf("Validate(%q) =\n%q\nwant\n%q", tc.title, got, tc.want)
}
}
}
func TestArchiveTitleBadRequestFailsBeforeCall(t *testing.T) {
for name, tc := range map[string]struct {
in ArchiveTitleRequest
want string
}{
"no dinners": {ArchiveTitleRequest{}, "the week has no dinners to title it from"},
"blank dinner": {ArchiveTitleRequest{Dinners: []string{"Pad Thai", " \n"}}, "dinner 2 has a blank name"},
} {
t.Run(name, func(t *testing.T) {
var f llm.Fake
_, _, err := title(t, &f, tc.in, 0)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want one containing %q", err, tc.want)
}
})
}
}
@@ -0,0 +1,22 @@
{{/* archive_title titles a week's Supply run for the Supply archive, from the week's dinners. */}}
{{define "instructions" -}}
You title the household's Supply run, the week's shopping list, as it is filed in the Supply archive: a blog-style history of the household's weeks of dinners. Write one short, warm, playful post title for the week, drawn from its dinners.
Rules:
- At most 48 characters, on one line.
- Words only: no emoji.
- No quotation marks around the title.
- Do not name a date or week number; the archive shows those.
Give:
- title: the title.
One valid reply, for a week of Beef Chili, Pot Roast and Fish Tacos (an example only, not about this household):
{"title": "Slow Simmers and Taco Night"}
{{- end}}
{{define "user" -}}
Title the week whose dinners are, in day order from Monday:
{{- range .Dinners}}
- {{.}}
{{- end}}
{{- end}}
+1
View File
@@ -5,3 +5,4 @@ echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce
normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32
archive_title v1 b4f30562a8614dac09a69d512dc361883b917ee4228a0ce3b4583c7fd3f6101a
@@ -0,0 +1,33 @@
## Instructions
You title the household's Supply run, the week's shopping list, as it is filed in the Supply archive: a blog-style history of the household's weeks of dinners. Write one short, warm, playful post title for the week, drawn from its dinners.
Rules:
- At most 48 characters, on one line.
- Words only: no emoji.
- No quotation marks around the title.
- Do not name a date or week number; the archive shows those.
Give:
- title: the title.
One valid reply, for a week of Beef Chili, Pot Roast and Fish Tacos (an example only, not about this household):
{"title": "Slow Simmers and Taco Night"}
## Household context
No household context.
## Output contract
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "archive_title" below, and nothing else: no prose and no Markdown code fences.
{
"type": "object",
"properties": {
"title": {
"type": "string"
}
},
"required": [
"title"
],
"additionalProperties": false
}
@@ -0,0 +1,8 @@
Title the week whose dinners are, in day order from Monday:
- Beef Chili
- Pot Roast
- Chicken Fajitas
- Spaghetti Bolognese
- Sheet Pan Salmon
- Pad Thai
- Fish Tacos
+2 -1
View File
@@ -258,6 +258,7 @@ type ShoppingGroup struct {
ID string
ListID string
DinnerID string
Date string
Position int64
CreatedAt string
UpdatedAt string
@@ -278,7 +279,7 @@ type ShoppingList struct {
ID string
WeekStart string
PlanID string
Title string
Title sql.NullString
Status string
SentToChatAt sql.NullString
FrozenAt sql.NullString
+31
View File
@@ -43,6 +43,11 @@ type Querier interface {
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) 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
// later renamed.
CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error
@@ -59,6 +64,8 @@ type Querier interface {
DeleteIntegrationsGoogle(ctx context.Context) error
DeleteRecipeIngredients(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)
// Fails every unarchived dinner still pending whose grounding job for its
// 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)
// Returns sql.ErrNoRows until the row exists.
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
// The Supply run holding item id. Returns sql.ErrNoRows when there is no
// such item.
GetShoppingItemList(ctx context.Context, id string) (GetShoppingItemListRow, error)
// The Supply run of the week starting week_start. Returns sql.ErrNoRows
// when there is none.
GetShoppingList(ctx context.Context, weekStart string) (ShoppingList, error)
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error)
// 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)
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
// The groups of list list_id with their dinner's name, in list order.
ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error)
// The groups of list list_id, in list order.
ListShoppingGroups(ctx context.Context, listID string) ([]ShoppingGroup, error)
// The items of list list_id with their group's dinner, in list order: by
// 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
// of any status, that have no Rotation check for date yet.
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
// (0 rows).
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),
// recording updated_by; a dinner no longer suggested is left as it is
// (0 rows).
@@ -208,6 +232,8 @@ type Querier interface {
// Records a new draft over plan id: its status and note, raising its
// revision.
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.
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
// Sets the chosen calendar; affects no row when nothing is connected.
@@ -215,6 +241,11 @@ type Querier interface {
// Records that the event calendar_event_id on Calendar shows date of
// plan_id as planned.
SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error)
// Marks item id needed (1) or "have it" (0).
SetShoppingItemNeeded(ctx context.Context, arg SetShoppingItemNeededParams) error
// Titles list id, unless it has a title already or is frozen into the
// Supply archive.
SetShoppingListTitle(ctx context.Context, arg SetShoppingListTitleParams) (int64, error)
// Marks a pending batch running; a batch in any other status is left as
// it is (0 rows).
StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error)
+402
View File
@@ -0,0 +1,402 @@
// 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
}
const setShoppingListTitle = `-- name: SetShoppingListTitle :execrows
UPDATE shopping_lists
SET title = ?, updated_at = ?
WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL
`
type SetShoppingListTitleParams struct {
Title sql.NullString
UpdatedAt string
ID string
}
// Titles list id, unless it has a title already or is frozen into the
// Supply archive.
func (q *Queries) SetShoppingListTitle(ctx context.Context, arg SetShoppingListTitleParams) (int64, error) {
result, err := q.db.ExecContext(ctx, setShoppingListTitle, arg.Title, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+7
View File
@@ -68,6 +68,13 @@ const (
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
// the request: the page fetch (recipes.Timeout), its photo download and
// 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
// what the week's Supply run (Feature 17) is made from; the returned plan
// is the one pushed.
func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (Plan, error) {
// is the one pushed, and revision is its revision as pushed, so the Supply
// run can be made from that plan and no later edit of it.
func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (p Plan, revision int64, err error) {
if _, err := household.WeekDays(weekStart); err != nil {
return Plan{}, &InvalidWeekError{Value: weekStart.String()}
return Plan{}, 0, &InvalidWeekError{Value: weekStart.String()}
}
planID, revision, days, err := s.pushDays(ctx, weekStart)
if err != nil {
return Plan{}, err
return Plan{}, 0, err
}
events := make([]gcal.Event, len(days))
for i, d := range days {
events[i] = d.event
}
var p Plan
record := func() error {
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
@@ -133,9 +133,9 @@ func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (Plan, er
})
}
if err := s.cal.PushEvents(ctx, events, record); err != nil {
return Plan{}, fmt.Errorf("plan: push %s to Calendar: %w", weekStart, err)
return Plan{}, 0, fmt.Errorf("plan: push %s to Calendar: %w", weekStart, err)
}
return p, nil
return p, revision, nil
}
// pushDays reads, in one read transaction, the plan of the week starting
+31 -31
View File
@@ -135,7 +135,7 @@ func TestPushCreatesOneEventPerDay(t *testing.T) {
e.connectCalendar(t)
dates := e.storePlan(t, week, fullWeek...)
p, err := e.svc.Push(t.Context(), week)
p, _, err := e.svc.Push(t.Context(), week)
if err != nil {
t.Fatalf("Push: %v", err)
}
@@ -197,7 +197,7 @@ func TestPushAgainUpdatesOnly(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
if _, err := e.svc.Push(t.Context(), week); err != nil {
if _, _, err := e.svc.Push(t.Context(), week); err != nil {
t.Fatal(err)
}
// The household deletes Monday's event in Google Calendar; Google keeps
@@ -206,7 +206,7 @@ func TestPushAgainUpdatesOnly(t *testing.T) {
e.google.CancelEvent(dinnersCal.ID, monday)
e.google.ResetEventWrites()
p, err := e.svc.Push(t.Context(), week)
p, _, err := e.svc.Push(t.Context(), week)
if err != nil {
t.Fatalf("second Push: %v", err)
}
@@ -229,7 +229,7 @@ func TestSwapThenPushUpdatesThatDay(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
if _, err := e.svc.Push(t.Context(), week); err != nil {
if _, _, err := e.svc.Push(t.Context(), week); err != nil {
t.Fatal(err)
}
before := e.google.Events(dinnersCal.ID)
@@ -247,7 +247,7 @@ func TestSwapThenPushUpdatesThatDay(t *testing.T) {
t.Errorf("swapped day kept event id %q", id)
}
p, err := e.svc.Push(t.Context(), week)
p, _, err := e.svc.Push(t.Context(), week)
if err != nil {
t.Fatalf("Push: %v", err)
}
@@ -296,7 +296,7 @@ func TestPushAcrossDaylightSavingTime(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
dates := e.storePlan(t, tc.week, fullWeek...)
if _, err := e.svc.Push(t.Context(), tc.week); err != nil {
if _, _, err := e.svc.Push(t.Context(), tc.week); err != nil {
t.Fatalf("Push: %v", err)
}
events := e.google.Events(dinnersCal.ID)
@@ -330,12 +330,12 @@ func TestPushAcrossDaylightSavingTime(t *testing.T) {
func TestPushNeedsAChosenCalendar(t *testing.T) {
e := newEnv(t)
e.storePlan(t, week, fullWeek...)
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNotConnected) {
if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNotConnected) {
t.Errorf("Push with nothing connected = %v, want ErrNotConnected", err)
}
e.google.SetCalendars(0, dinnersCal)
e.google.Connect(t, e.gcal)
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) {
if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) {
t.Errorf("Push with no calendar chosen = %v, want ErrNoCalendar", err)
}
if inserts, updates := e.google.EventWrites(); inserts+updates != 0 {
@@ -348,7 +348,7 @@ func TestPushWithoutAPlan(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
before := e.google.TokenCalls()
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Push = %v, want ErrNotFound", err)
}
if n := e.google.TokenCalls() - before; n != 0 {
@@ -363,7 +363,7 @@ func TestPushPartialFailureLeavesPlanUnsynced(t *testing.T) {
// Google fails the fourth event on every try.
e.google.FailEventWrites(4, 3, http.StatusServiceUnavailable)
_, err := e.svc.Push(t.Context(), week)
_, _, err := e.svc.Push(t.Context(), week)
var calendar *gcal.CalendarError
if !errors.As(err, &calendar) || !calendar.Retryable() {
t.Fatalf("Push = %v, want a retryable *gcal.CalendarError", err)
@@ -377,7 +377,7 @@ func TestPushPartialFailureLeavesPlanUnsynced(t *testing.T) {
// Pushing again writes every day: the three already there are updated,
// not added twice.
e.google.ResetEventWrites()
p, err := e.svc.Push(t.Context(), week)
p, _, err := e.svc.Push(t.Context(), week)
if err != nil {
t.Fatalf("retried Push: %v", err)
}
@@ -393,7 +393,7 @@ func TestPushPartialFailureKeepsAChangedPlanChanged(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
first, err := e.svc.Push(t.Context(), week)
first, _, err := e.svc.Push(t.Context(), week)
if err != nil {
t.Fatal(err)
}
@@ -403,7 +403,7 @@ func TestPushPartialFailureKeepsAChangedPlanChanged(t *testing.T) {
}
}
e.google.FailEventWrite(2, http.StatusBadRequest)
if _, err := e.svc.Push(t.Context(), week); err == nil {
if _, _, err := e.svc.Push(t.Context(), week); err == nil {
t.Fatal("Push succeeded, want the second event to fail")
}
p, err := e.svc.Get(t.Context(), week)
@@ -431,7 +431,7 @@ func TestPushToStaleCalendar(t *testing.T) {
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
edit(e.google)
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrCalendarUnavailable) {
if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrCalendarUnavailable) {
t.Errorf("Push = %v, want ErrCalendarUnavailable", err)
}
e.assertUnsynced(t, week, plan.StatusDraft)
@@ -444,7 +444,7 @@ func TestPushGrantNoLongerAccepted(t *testing.T) {
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
e.google.RevokeGrant()
_, err := e.svc.Push(t.Context(), week)
_, _, err := e.svc.Push(t.Context(), week)
if !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Push = %v, want ErrReconnect", err)
}
@@ -479,12 +479,12 @@ func TestRefusedGrantKeepsPushedWeeks(t *testing.T) {
dates := map[store.LocalDate][]string{}
for _, w := range pushedWeeks {
dates[w] = e.storePlan(t, w, fullWeek...)
if _, err := e.svc.Push(t.Context(), w); err != nil {
if _, _, err := e.svc.Push(t.Context(), w); err != nil {
t.Fatalf("Push %s: %v", w, err)
}
}
e.google.RevokeGrant()
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) {
if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Push = %v, want ErrReconnect", err)
}
e.assertRefused(t)
@@ -497,7 +497,7 @@ func TestRefusedGrantKeepsPushedWeeks(t *testing.T) {
// The refused token is not offered to Google again.
before := e.google.TokenCalls()
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) {
if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Push again = %v, want ErrReconnect", err)
}
if n := e.google.TokenCalls() - before; n != 0 {
@@ -506,7 +506,7 @@ func TestRefusedGrantKeepsPushedWeeks(t *testing.T) {
e.google.Connect(t, e.gcal)
e.google.ResetEventWrites()
p, err := e.svc.Push(t.Context(), week)
p, _, err := e.svc.Push(t.Context(), week)
if err != nil || p.Status != plan.StatusSynced {
t.Fatalf("Push after connecting again = %s, %v; want synced", p.Status, err)
}
@@ -523,7 +523,7 @@ func TestDisconnectAfterRefusedGrant(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
if _, err := e.svc.Push(t.Context(), week); err != nil {
if _, _, err := e.svc.Push(t.Context(), week); err != nil {
t.Fatal(err)
}
e.google.RevokeGrant()
@@ -551,7 +551,7 @@ func TestPushFailsWhenPlanSwappedWhilePushing(t *testing.T) {
defer release()
pushed := make(chan error, 1)
go func() {
_, err := e.svc.Push(t.Context(), week)
_, _, err := e.svc.Push(t.Context(), week)
pushed <- err
}()
<-arrived
@@ -596,7 +596,7 @@ func TestPushAfterChoosingAnotherCalendarWritesTheWholeWeek(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
dates := e.storePlan(t, week, fullWeek...)
first, err := e.svc.Push(t.Context(), week)
first, _, err := e.svc.Push(t.Context(), week)
if err != nil {
t.Fatal(err)
}
@@ -640,14 +640,14 @@ func TestPushAfterChoosingAnotherCalendarWritesTheWholeWeek(t *testing.T) {
// A push that fails part way leaves the plan changed.
e.google.ResetEventWrites()
e.google.FailEventWrite(4, http.StatusBadRequest)
if _, err := e.svc.Push(t.Context(), week); err == nil {
if _, _, err := e.svc.Push(t.Context(), week); err == nil {
t.Fatal("Push succeeded, want the fourth event to fail")
}
if p, err := e.svc.Get(t.Context(), week); err != nil || p.Status != plan.StatusChanged {
t.Errorf("plan after a failed push = %s, %v; want changed", p.Status, err)
}
p, err = e.svc.Push(t.Context(), week)
p, _, err = e.svc.Push(t.Context(), week)
if err != nil {
t.Fatalf("Push: %v", err)
}
@@ -678,7 +678,7 @@ func TestChoosingAnotherCalendarWaitsForAPush(t *testing.T) {
defer release()
pushed := make(chan error, 1)
go func() {
_, err := e.svc.Push(t.Context(), week)
_, _, err := e.svc.Push(t.Context(), week)
pushed <- err
}()
<-arrived
@@ -712,7 +712,7 @@ func TestChoosingAnotherCalendarWaitsForAPush(t *testing.T) {
}
}
if p, err = e.svc.Push(t.Context(), week); err != nil || p.Status != plan.StatusSynced {
if p, _, err = e.svc.Push(t.Context(), week); err != nil || p.Status != plan.StatusSynced {
t.Fatalf("Push = %s, %v; want synced", p.Status, err)
}
e.assertWeekOn(t, kitchenCal.ID, week, dates, fullWeek...)
@@ -746,7 +746,7 @@ func TestDisconnectForgetsEveryPushedWeek(t *testing.T) {
dates := map[store.LocalDate][]string{}
for _, w := range pushedWeeks {
dates[w] = e.storePlan(t, w, fullWeek...)
if _, err := e.svc.Push(t.Context(), w); err != nil {
if _, _, err := e.svc.Push(t.Context(), w); err != nil {
t.Fatalf("Push %s: %v", w, err)
}
}
@@ -764,7 +764,7 @@ func TestDisconnectForgetsEveryPushedWeek(t *testing.T) {
// Connecting again chooses no calendar, so nothing is pushed until one
// is chosen; then a push writes the whole week to it.
e.google.Connect(t, e.gcal)
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) {
if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) {
t.Fatalf("Push after connecting again = %v, want ErrNoCalendar", err)
}
e.google.SetCalendars(0, dinnersCal, kitchenCal)
@@ -773,7 +773,7 @@ func TestDisconnectForgetsEveryPushedWeek(t *testing.T) {
}
for _, w := range pushedWeeks {
e.assertForgotten(t, w, "after connecting again")
p, err := e.svc.Push(t.Context(), w)
p, _, err := e.svc.Push(t.Context(), w)
if err != nil || p.Status != plan.StatusSynced {
t.Fatalf("Push %s = %s, %v; want synced", w, p.Status, err)
}
@@ -789,7 +789,7 @@ func TestDisconnectRefusedKeepsPushedWeeks(t *testing.T) {
dates := map[store.LocalDate][]string{}
for _, w := range pushedWeeks {
dates[w] = e.storePlan(t, w, fullWeek...)
if _, err := e.svc.Push(t.Context(), w); err != nil {
if _, _, err := e.svc.Push(t.Context(), w); err != nil {
t.Fatalf("Push %s: %v", w, err)
}
}
@@ -816,7 +816,7 @@ func TestDisconnectWaitsForAPush(t *testing.T) {
defer release()
pushed := make(chan error, 1)
go func() {
_, err := e.svc.Push(t.Context(), week)
_, _, err := e.svc.Push(t.Context(), week)
pushed <- err
}()
<-arrived
+1 -1
View File
@@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t),
newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -92,7 +92,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
t.Fatal(err)
}
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
svc, newTestSuggest(t), newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+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}))
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), newTestSuggest(t), newTestPlan(t), svc, newTestRunner(t), newTestPhotos(t),
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestRunner(t), newTestPhotos(t),
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+1 -1
View File
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
t.Fatal(err)
}
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t),
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t),
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+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)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+58 -4
View File
@@ -1,6 +1,7 @@
package server
import (
"context"
"errors"
"log/slog"
"net/http"
@@ -9,6 +10,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
@@ -94,20 +96,72 @@ func putPlanDay(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
}
}
// pushedPlan is the POST /plans/{weekStart}/push-calendar answer: the
// plan as synced, and what became of the week's Supply run.
type pushedPlan struct {
plan.Plan
SupplyRun pushedSupplyRun `json:"supply_run"`
}
// pushedSupplyRun says whether a Push to Calendar made the week's Supply
// run. status is generated when it did; archived when the week's Supply
// run is in the Supply archive and was left as it was; or failed when it
// was not made. A failed Supply run's error is plan_changed when the plan
// was edited after it was pushed, so the Supply run was not made from a
// plan Calendar does not show and pushing again makes it; or
// supply_run_failed when it could not be made, and POST
// /shopping/{weekStart}/generate makes it again. error is null when status
// is generated.
type pushedSupplyRun struct {
Status string `json:"status"`
Error *apiError `json:"error"`
}
// postPlanPush pushes the week's plan to the chosen Google calendar
// (Push to Calendar) and answers with the plan as synced.
func postPlanPush(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
// (Push to Calendar) and, once the push is recorded, makes the week's
// Supply run from the plan pushed. It answers with the plan as synced and
// the Supply run's outcome.
//
// The Supply run is made after the push, never as part of it: Calendar
// already shows the plan, so a Supply run that cannot be made does not
// undo or fail the push. The answer is still 200 with supply_run failed,
// and the failure is logged, so nothing claims a Supply run that was not
// made. It is made only from the plan as pushed: one edited between the
// push and the Supply run is not made into a Supply run.
func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
if !ok {
return
}
p, err := svc.Push(r.Context(), week)
p, revision, err := svc.Push(r.Context(), week)
if err != nil {
writePlanPushError(w, r, logger, err)
return
}
writeJSON(w, r, logger, http.StatusOK, p)
// The push is done: make the Supply run even if the client has
// gone.
ctx := context.WithoutCancel(r.Context())
run := pushedSupplyRun{Status: "generated"}
if _, err := shop.GeneratePushed(ctx, week, revision); errors.Is(err, shopping.ErrFrozen) {
logger.InfoContext(ctx, "Supply run in the Supply archive left as it was after Push to Calendar", "week", week.String())
run = pushedSupplyRun{Status: "archived", Error: &apiError{Code: "archived", Message: err.Error()}}
} else if errors.Is(err, shopping.ErrPlanChanged) {
logger.WarnContext(ctx, "plan edited after Push to Calendar; Supply run not made", "week", week.String())
run = pushedSupplyRun{Status: "failed", Error: &apiError{
Code: "plan_changed",
Message: "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again",
Retryable: true,
}}
} else if err != nil {
logger.ErrorContext(ctx, "generate Supply run after Push to Calendar", "week", week.String(), "error", err)
run = pushedSupplyRun{Status: "failed", Error: &apiError{
Code: "supply_run_failed",
Message: "the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run",
Retryable: true,
}}
}
writeJSON(w, r, logger, http.StatusOK, pushedPlan{Plan: p, SupplyRun: run})
}
}
+15 -2
View File
@@ -23,6 +23,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
@@ -36,6 +37,7 @@ type plansEnv struct {
h http.Handler
st *store.Store
svc *plan.Service
runner *jobs.Runner
questLog string
// google is the fake Google the plan service pushes to; it starts
// unconnected.
@@ -46,6 +48,13 @@ type plansEnv struct {
}
func newPlansRouter(t *testing.T) plansEnv {
t.Helper()
return newPlansRouterTitled(t, untitled)
}
// newPlansRouterTitled is newPlansRouter whose Supply runs are titled by
// title.
func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
t.Helper()
st := newTestStore(t)
ctx := t.Context()
@@ -79,8 +88,12 @@ func newPlansRouter(t *testing.T) plansEnv {
if err != nil {
t.Fatal(err)
}
shop, err := shopping.NewService(st, title, runner, logger)
if err != nil {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), newTestSuggest(t), svc, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
@@ -114,7 +127,7 @@ func newPlansRouter(t *testing.T) plansEnv {
t.Fatal(err)
}
}
return plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
return plansEnv{h: h, st: st, svc: svc, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
google: google, gcal: googleSvc, logs: logs}
}
+1 -1
View File
@@ -22,7 +22,7 @@ const (
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+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/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
)
@@ -46,7 +47,11 @@ type RouteMounter interface {
// /decline; planSvc serves GET /api/v1/plans/{weekStart}, POST
// /api/v1/plans/{weekStart}/draft, PUT
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
// POST /api/v1/dinners/{id}/rotation-review; googleSvc serves GET
// POST /api/v1/dinners/{id}/rotation-review, and POST
// /api/v1/plans/{weekStart}/push-calendar with shopSvc, which makes the
// pushed week's Supply run; shopSvc serves GET /api/v1/shopping/{weekStart},
// POST /api/v1/shopping/{weekStart}/generate and PATCH
// /api/v1/shopping/items/{id}; googleSvc serves GET
// /api/v1/integrations/google and its /connect, /callback and /calendars,
// PUT /api/v1/integrations/google/calendar and DELETE
// /api/v1/integrations/google; runner serves
@@ -54,7 +59,7 @@ type RouteMounter interface {
// GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, googleSvc *gcal.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
@@ -73,6 +78,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
if planSvc == nil {
return nil, errors.New("server: nil plan service")
}
if shopSvc == nil {
return nil, errors.New("server: nil shopping service")
}
if googleSvc == nil {
return nil, errors.New("server: nil Google Calendar service")
}
@@ -133,7 +141,10 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Get("/plans/{weekStart}", getPlan(logger, planSvc))
apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc))
apiV1.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc))
apiV1.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc, shopSvc))
apiV1.Get("/shopping/{weekStart}", getSupplyRun(logger, shopSvc))
apiV1.Post("/shopping/{weekStart}/generate", postSupplyRunGenerate(logger, shopSvc))
apiV1.Patch("/shopping/items/{id}", patchShoppingItem(logger, shopSvc))
apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc))
apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc))
// connect and callback are top-level browser navigations, so they are
+43 -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/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
@@ -53,7 +54,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
"index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
}
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), assets)
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), assets)
if err != nil {
t.Fatal(err)
}
@@ -209,6 +210,29 @@ func newTestPlan(t *testing.T) *plan.Service {
return svc
}
// newTestShopping returns a shopping service over a migrated temp
// database, whose Supply runs stay untitled.
func newTestShopping(t *testing.T) *shopping.Service {
t.Helper()
st := newTestStore(t)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
svc, err := shopping.NewService(st, untitled, runner, logger)
if err != nil {
t.Fatal(err)
}
return svc
}
// untitled is a shopping.Titler with no model: every Supply run it is
// asked to title stays untitled.
func untitled(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
return skills.ArchiveTitle{}, errors.New("no model in this test")
}
// newTestBox returns a secret box under a random key.
func newTestBox(t *testing.T) *secretbox.Box {
t.Helper()
@@ -263,7 +287,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil AI settings service")
}
}
@@ -272,7 +296,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil recipe search settings service")
}
}
@@ -281,7 +305,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted nil photo files")
}
}
@@ -290,7 +314,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
@@ -299,7 +323,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil dinners service")
}
}
@@ -308,7 +332,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil suggestions service")
}
}
@@ -317,16 +341,25 @@ func TestNewRouterRequiresPlan(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil plan service")
}
}
func TestNewRouterRequiresShopping(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil shopping service")
}
}
func TestNewRouterRequiresGoogle(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil Google Calendar service")
}
}
@@ -335,7 +368,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner")
}
}
+1 -1
View File
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+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")
}
}
+636
View File
@@ -0,0 +1,636 @@
package server
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"reflect"
"slices"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/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())
}
}
// heldTitler is a shopping.Titler that reports each call on entered and
// answers title only once release is closed.
type heldTitler struct {
entered chan struct{}
release chan struct{}
}
func newHeldTitler() heldTitler {
return heldTitler{entered: make(chan struct{}, 4), release: make(chan struct{})}
}
func (h heldTitler) title(ctx context.Context, _ string, _ skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
h.entered <- struct{}{}
select {
case <-h.release:
return skills.ArchiveTitle{Title: "Chili and Friends"}, nil
case <-ctx.Done():
return skills.ArchiveTitle{}, ctx.Err()
}
}
// startRunner starts e's job runner, stopping it when the test ends. e's
// logs must not be read while it runs.
func (e plansEnv) startRunner(t *testing.T) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
if err := e.runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
e.runner.Wait()
})
}
// waitEntered waits for the title call to begin.
func (h heldTitler) waitEntered(t *testing.T) {
t.Helper()
select {
case <-h.entered:
case <-time.After(10 * time.Second):
t.Fatal("archive_title was never called")
}
}
// waitTitled waits for the week's Supply run to have a title through GET
// and returns it.
func (e plansEnv) waitTitled(t *testing.T) string {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
var l supplyRunJSON
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
if l.Title != nil {
return *l.Title
}
if time.Now().After(deadline) {
t.Fatal("the Supply run was never titled")
}
time.Sleep(5 * time.Millisecond)
}
}
// TestPushPlanDoesNotWaitForTitle: Push to Calendar answers while the
// Supply run's title is still being written; the list reads untitled until
// the title job stores it.
func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
t.Parallel()
held := newHeldTitler()
e := newPlansRouterTitled(t, held.title)
e.groundWeek(t)
e.connectCalendar(t, true)
e.startRunner(t)
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/push-calendar", "")
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)
}
held.waitEntered(t)
var l supplyRunJSON
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
if l.Title != nil {
t.Errorf("title = %q while archive_title is still running, want null", *l.Title)
}
close(held.release)
if got := e.waitTitled(t); got != "Chili and Friends" {
t.Errorf("title = %q, want the title job's", got)
}
}
// TestSupplyRunGenerateDoesNotWaitForTitle: POST generate answers with the
// list untitled while its title is still being written.
func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
t.Parallel()
held := newHeldTitler()
e := newPlansRouterTitled(t, held.title)
e.groundWeek(t)
e.startRunner(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)
}
if l.Title != nil {
t.Errorf("title = %q while archive_title is still running, want null", *l.Title)
}
if !reflect.DeepEqual(l.render(), weekLines) {
t.Errorf("generated = %v, want %v", l.render(), weekLines)
}
held.waitEntered(t)
close(held.release)
if got := e.waitTitled(t); got != "Chili and Friends" {
t.Errorf("title = %q, want the title job's", got)
}
}
// 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)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), svc, newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+696
View File
@@ -0,0 +1,696 @@
// 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). The one model call is the Supply run's title, written by
// archive_title in a KindTitleSupplyRun job when the list is generated
// untitled (see Generate).
package shopping
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"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/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"
)
// KindTitleSupplyRun is the job kind that titles a Supply run with
// archive_title. Only Generate and GeneratePushed enqueue it.
const KindTitleSupplyRun = "title_supply_run"
// ReasonTitleFailed is the failure code of a KindTitleSupplyRun job that
// stored no title: archive_title failed, or the Supply run was frozen or
// replaced first. The Supply run stays untitled.
const ReasonTitleFailed = "title_failed"
// 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")
// Titler runs archive_title on in; scope names the week, its plan revision
// and its dinners.
// In production it is skills.TitleArchive with the household's
// harness.Config.
type Titler func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error)
// Service generates Supply runs.
type Service struct {
st *store.Store
title Titler
titleJob *jobs.Kind[TitleJob]
logger *slog.Logger
}
// NewService returns a service over st that titles Supply runs with title
// in KindTitleSupplyRun jobs, which it registers on runner, and logs to
// logger. runner must not have started yet.
func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("shopping: nil store")
case title == nil:
return nil, errors.New("shopping: nil titler")
case runner == nil:
return nil, errors.New("shopping: nil job runner")
case logger == nil:
return nil, errors.New("shopping: nil logger")
}
s := &Service{st: st, title: title, logger: logger}
kind, err := jobs.Register(runner, KindTitleSupplyRun, s.runTitleJob, ReasonTitleFailed)
if err != nil {
return nil, err
}
if err := kind.ServiceOnly(); err != nil {
return nil, err
}
s.titleJob = kind
return s, 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"`
// TitleJobID is the KindTitleSupplyRun job titling the list; nil when
// the list already has its title.
TitleJobID *string `json:"title_job_id"`
}
// 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 list still untitled once it is stored, as a new list always is, gets a
// KindTitleSupplyRun job, enqueued in the same transaction, that titles it
// with archive_title from the names of the plan's dinners in day order.
// Generate returns without waiting for the job: the list is returned
// untitled, with the job's id. A title is written once: a remake keeps it
// and enqueues no job. Titling never fails the generation: a title that
// cannot be written leaves the list untitled, the failure is logged and
// the job failed with ReasonTitleFailed, and the next generation of the
// week, Generate or GeneratePushed, tries again.
//
// 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, dinnerNames, 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}
if list.Title.Valid {
return nil
}
id, err := s.titleJob.EnqueueTx(ctx, tx, TitleJob{
ListID: list.ID, WeekStart: list.WeekStart, Revision: plan.Revision, Dinners: dinnerNames,
})
if err != nil {
return fmt.Errorf("shopping: enqueue title: %w", err)
}
out.TitleJobID = &id
return nil
})
if err != nil {
return Generated{}, err
}
if out.TitleJobID != nil {
s.titleJob.Wake()
}
return out, nil
}
// TitleJob is a KindTitleSupplyRun job's payload: the untitled Supply run
// ListID of the week WeekStart, made from the plan at Revision, whose
// dinners in day order are Dinners.
type TitleJob struct {
ListID string `json:"list_id"`
WeekStart string `json:"week_start"`
Revision int64 `json:"revision"`
Dinners []string `json:"dinners"`
}
// titleResult is a KindTitleSupplyRun job's result: the Supply run and the
// title it has.
type titleResult struct {
ListID string `json:"list_id"`
Title string `json:"title"`
}
// PublicJSON returns the Supply run's id and title.
func (r titleResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// runTitleJob titles the Supply run of p with archive_title and stores the
// title. A title that cannot be stored leaves the list untitled: the
// failure is logged and the job fails with ReasonTitleFailed.
func (s *Service) runTitleJob(ctx context.Context, p TitleJob, _ func(titleResult) error) (titleResult, error) {
title, err := s.writeTitle(ctx, p)
if err != nil {
s.logger.WarnContext(ctx, "Supply run left untitled; its next generation titles it again",
"week", p.WeekStart, "list", p.ListID, "error", err)
return titleResult{}, &jobs.Failure{Code: ReasonTitleFailed}
}
return titleResult{ListID: p.ListID, Title: title}, nil
}
// writeTitle runs archive_title for p's Supply run and stores its title.
// When the list was titled by another job, before this one ran or
// meanwhile, that title stands and is returned.
func (s *Service) writeTitle(ctx context.Context, p TitleJob) (string, error) {
q := db.New(s.st.Writer)
title, err := s.currentTitle(ctx, q, p)
if err != nil || title != "" {
return title, err
}
out, err := s.title(ctx, titleScope(p), skills.ArchiveTitleRequest{Dinners: p.Dinners})
if err != nil {
return "", fmt.Errorf("shopping: title: %w", err)
}
write, cancel := jobs.FinalWriteContext(ctx)
defer cancel()
n, err := q.SetShoppingListTitle(write, db.SetShoppingListTitleParams{
Title: sql.NullString{String: out.Title, Valid: true}, UpdatedAt: store.Now().String(), ID: p.ListID,
})
if err != nil {
return "", fmt.Errorf("shopping: store title: %w", err)
}
if n == 1 {
return out.Title, nil
}
title, err = s.currentTitle(write, q, p)
if err == nil && title == "" {
err = errors.New("shopping: store title: the Supply run is still untitled")
}
return title, err
}
// currentTitle returns the title of p's Supply run, or "" while it is
// untitled. A list frozen or replaced untitled is an error: it can no
// longer be titled.
func (s *Service) currentTitle(ctx context.Context, q *db.Queries, p TitleJob) (string, error) {
list, err := q.GetShoppingList(ctx, p.WeekStart)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("shopping: title: read list: %w", err)
}
switch {
case err == nil && list.ID == p.ListID && list.Title.Valid:
return list.Title.String, nil
case err != nil || list.ID != p.ListID || frozen(list):
return "", errors.New("shopping: store title: the Supply run was frozen or replaced before its title was stored")
}
return "", nil
}
// titleScope is the archive_title scope of p: its week and plan revision,
// and a fingerprint of its dinners in day order, so runs share a title only
// when they title the same dinner names. A renamed dinner changes the
// names but not the plan revision.
func titleScope(p TitleJob) string {
names, _ := json.Marshal(p.Dinners) // a []string always encodes
return fmt.Sprintf("%s@%d/%x", p.WeekStart, p.Revision, sha256.Sum256(names))
}
// 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, the days that get none, and the
// names of the plan's dinners in day order.
func wanted(ctx context.Context, q *db.Queries, planID string) ([]wantGroup, []SkippedDay, []string, error) {
days, err := q.ListPlanDays(ctx, planID)
if err != nil {
return nil, nil, nil, fmt.Errorf("shopping: read plan days: %w", err)
}
var (
groups []wantGroup
skipped []SkippedDay
names []string
)
for _, day := range days {
d := day.Dinner
names = append(names, d.Name)
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, 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, 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, nil, fmt.Errorf("shopping: recipe %s: %w", recipe.ID, err)
}
g.items = append(g.items, it)
}
groups = append(groups, g)
}
return groups, skipped, names, 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
}
+816
View File
@@ -0,0 +1,816 @@
package shopping_test
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"io"
"log/slog"
"reflect"
"strings"
"sync"
"testing"
"time"
"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/ai/skills"
"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
// runner runs the title jobs, two at a time, once start starts it.
runner *jobs.Runner
// model answers archive_title, which titles the Supply runs; with
// nothing queued it fails, and the lists stay untitled.
model *llm.Fake
// beforeTitle, when set, runs as archive_title is asked for a title.
beforeTitle func()
// beforeModel, when set, runs as the model is sent a request.
beforeModel func(llm.Request)
// log holds what the service logged.
log *syncBuffer
}
// syncBuffer is a bytes.Buffer safe for concurrent use.
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.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)
}
e := &env{st: st, dataDir: dataDir, ids: map[string]string{}, model: &llm.Fake{}, log: &syncBuffer{}}
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
t.Fatal(err)
}
cfg := harness.Config{
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return hookedModel{e}, nil },
Store: st,
Guard: guard,
}
title := func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
if e.beforeTitle != nil {
e.beforeTitle()
}
return skills.TitleArchive(ctx, cfg, scope, in)
}
logger := slog.New(slog.NewTextHandler(e.log, nil))
e.runner, err = jobs.NewRunner(st, logger, 2)
if err != nil {
t.Fatal(err)
}
e.svc, err = shopping.NewService(st, title, e.runner, logger)
if err != nil {
t.Fatal(err)
}
return e
}
// hookedModel is the env's model, running beforeModel first.
type hookedModel struct{ e *env }
func (m hookedModel) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
if m.e.beforeModel != nil {
m.e.beforeModel(req)
}
return m.e.model.Generate(ctx, req)
}
func (m hookedModel) ListModels(ctx context.Context) ([]llm.Model, error) {
return m.e.model.ListModels(ctx)
}
func (m hookedModel) Verify(ctx context.Context) error { return m.e.model.Verify(ctx) }
// start starts the env's job runner, stopping it when the test ends.
func (e *env) start(t *testing.T) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
if err := e.runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
e.runner.Wait()
})
}
// waitTitle waits for g's title job to finish and returns it.
func (e *env) waitTitle(t *testing.T, g shopping.Generated) jobs.Job {
t.Helper()
if g.TitleJobID == nil {
t.Fatal("generation enqueued no title job")
}
deadline := time.Now().Add(10 * time.Second)
for {
job, err := e.runner.Get(t.Context(), *g.TitleJobID)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
return job
}
if time.Now().After(deadline) {
t.Fatalf("title job %s still %s", job.ID, job.Status)
}
time.Sleep(5 * time.Millisecond)
}
}
// 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. The list is still untitled, so it gets another
// title job.
again := e.generate(t)
if again.TitleJobID == nil || g.TitleJobID == nil || *again.TitleJobID == *g.TitleJobID {
t.Errorf("title jobs = %v then %v, want one per generation of the untitled list", deref(g.TitleJobID), deref(again.TitleJobID))
}
again.TitleJobID = g.TitleJobID
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"})
}
+313
View File
@@ -0,0 +1,313 @@
package shopping_test
import (
"database/sql"
"encoding/json"
"fmt"
"slices"
"strings"
"sync"
"testing"
"time"
"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/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
)
// titled is archive_title's reply giving title.
func titled(title string) llm.GenerateResult {
js := fmt.Sprintf(`{"title":%q}`, title)
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
}
// titleWeek stores three dinners, one with no recipe, and plans them on
// the week's first three days; its job runner is started.
func titleWeek(t *testing.T) *env {
t.Helper()
e := newEnv(t)
e.start(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.dinner(t, "Fish Tacos")
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
e.plan(t, "Pad Thai", "Fish Tacos", "Beef Chili")
return e
}
// storedTitle returns the week's list's stored title; nil when NULL.
func (e *env) storedTitle(t *testing.T) *string {
t.Helper()
var title sql.NullString
if err := e.st.Reader.QueryRow(`SELECT title FROM shopping_lists WHERE week_start = ?`, week.String()).Scan(&title); err != nil {
t.Fatal(err)
}
if !title.Valid {
return nil
}
return &title.String
}
// runStatuses returns the status of every archive_title run recorded in
// ai_runs, oldest first.
func (e *env) runStatuses(t *testing.T) []string {
t.Helper()
rows, err := e.st.Reader.Query(`SELECT status FROM ai_runs WHERE skill = 'archive_title' ORDER BY created_at, rowid`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
t.Fatal(err)
}
out = append(out, s)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return out
}
func wantTitle(t *testing.T, what string, got *string, want string) {
t.Helper()
if got == nil || *got != want {
t.Errorf("%s = %v, want %q", what, deref(got), want)
}
}
func wantUntitled(t *testing.T, what string, got *string) {
t.Helper()
if got != nil {
t.Errorf("%s = %q, want none", what, *got)
}
}
func deref(s *string) any {
if s == nil {
return nil
}
return *s
}
// wantJob fails t unless job ended with status and, when failed, code.
func wantJob(t *testing.T, job jobs.Job, status jobs.Status, code string) {
t.Helper()
if job.Status != status || (code != "" && (job.Error == nil || *job.Error != code)) {
t.Errorf("title job = %s %v, want %s %s", job.Status, deref(job.Error), status, code)
}
}
// TestGenerateTitlesNewList: a new list is returned untitled with a title
// job, which titles it by archive_title from the plan's dinners in day
// order, a dinner with no group included, and stores the title.
func TestGenerateTitlesNewList(t *testing.T) {
e := titleWeek(t)
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
g := e.generate(t)
wantUntitled(t, "returned title", g.List.Title)
job := e.waitTitle(t, g)
wantJob(t, job, jobs.StatusSucceeded, "")
if want := `{"list_id":"` + g.List.ID + `","title":"Noodles, Tacos and Chili"}`; string(job.Result) != want {
t.Errorf("job result = %s, want %s", job.Result, want)
}
wantTitle(t, "stored title", e.storedTitle(t), "Noodles, Tacos and Chili")
got, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
wantTitle(t, "title read back", got.Title, "Noodles, Tacos and Chili")
reqs := e.model.Requests()
if len(reqs) != 1 {
t.Fatalf("%d model calls, want 1", len(reqs))
}
want := "Title the week whose dinners are, in day order from Monday:\n- Pad Thai\n- Fish Tacos\n- Beef Chili"
if user := reqs[0].Messages[0].Content; user != want {
t.Errorf("user message = %q, want %q", user, want)
}
if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusOK) {
t.Errorf("archive_title runs = %q, want one ok", s)
}
}
// TestGenerateTitleTooLongRepaired: a title over 48 characters is repaired
// through the repair turn, and the repaired title is stored, not a
// truncation of the first.
func TestGenerateTitleTooLongRepaired(t *testing.T) {
e := titleWeek(t)
long := "Pad Thai Monday, Fish Taco Tuesday and Chili Wednesday"
e.model.QueueGenerate(titled(long), titled("Noodles, Tacos and Chili"))
wantJob(t, e.waitTitle(t, e.generate(t)), jobs.StatusSucceeded, "")
wantTitle(t, "stored title", e.storedTitle(t), "Noodles, Tacos and Chili")
reqs := e.model.Requests()
if len(reqs) != 2 || !strings.Contains(reqs[1].Messages[2].Content, "has 54 characters; shorten it to at most 48") {
t.Fatalf("model calls = %+v, want a repair turn asking for a shorter title", reqs)
}
if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusRepaired) {
t.Errorf("archive_title runs = %q, want one repaired", s)
}
}
// TestGenerateTitleFailureKeepsList: archive_title giving an emoji twice
// fails, and the list is still made, whole and untitled, with the failure
// logged, recorded in ai_runs and failing the title job.
func TestGenerateTitleFailureKeepsList(t *testing.T) {
e := titleWeek(t)
e.model.QueueGenerate(titled("Taco Week 🌮"), titled("Taco Week 🌮"))
g, err := e.svc.Generate(t.Context(), week)
if err != nil {
t.Fatalf("Generate = %v, want the list made though its title failed", err)
}
want := []string{
"0 2026-10-12 Pad Thai: 0 rice noodles [8 oz] needed",
"1 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)
if len(g.Skipped) != 1 || g.Skipped[0].DinnerName != "Fish Tacos" {
t.Errorf("skipped = %+v, want Fish Tacos", g.Skipped)
}
wantJob(t, e.waitTitle(t, g), jobs.StatusFailed, shopping.ReasonTitleFailed)
wantUntitled(t, "returned title", g.List.Title)
wantUntitled(t, "stored title", e.storedTitle(t))
if g.List.Status != shopping.StatusCurrent {
t.Errorf("status = %q, want current", g.List.Status)
}
log := e.log.String()
for _, part := range []string{"level=WARN", "Supply run left untitled", "week=2026-10-12", "has an emoji (U+1F32E)"} {
if !strings.Contains(log, part) {
t.Errorf("log lacks %q:\n%s", part, log)
}
}
if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusBadOutput) {
t.Errorf("archive_title runs = %q, want one bad_output", s)
}
}
// TestGenerateRetriesTitle: a list left untitled is titled by the week's
// next generation, Generate or GeneratePushed; once titled, a remake keeps
// its title and asks for none.
func TestGenerateRetriesTitle(t *testing.T) {
e := titleWeek(t)
e.model.QueueGenerate(llm.GenerateResult{Err: fmt.Errorf("%w: model down", llm.ErrProvider)})
first := e.generate(t)
wantJob(t, e.waitTitle(t, first), jobs.StatusFailed, shopping.ReasonTitleFailed)
wantUntitled(t, "first title", e.storedTitle(t))
if !strings.Contains(e.log.String(), "model down") {
t.Errorf("log lacks the failure:\n%s", e.log.String())
}
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
plan, err := db.New(e.st.Reader).GetWeekPlan(t.Context(), week.String())
if err != nil {
t.Fatal(err)
}
second, err := e.svc.GeneratePushed(t.Context(), week, plan.Revision)
if err != nil {
t.Fatal(err)
}
if second.List.ID != first.List.ID {
t.Fatalf("second generation made list %s, want list %s again", second.List.ID, first.List.ID)
}
wantJob(t, e.waitTitle(t, second), jobs.StatusSucceeded, "")
wantTitle(t, "retried title", e.storedTitle(t), "Noodles, Tacos and Chili")
e.swap(t, "2026-10-12", "Beef Chili")
third := e.generate(t)
wantTitle(t, "title after a remake", third.List.Title, "Noodles, Tacos and Chili")
if third.TitleJobID != nil {
t.Errorf("a remake of a titled list enqueued title job %s", *third.TitleJobID)
}
if n := len(e.model.Requests()); n != 2 {
t.Errorf("%d model calls, want 2: a titled list is not titled again", n)
}
}
// TestGenerateTitleNotStoredOnFrozenList: a list frozen into the Supply
// archive while its title is written keeps no title, and the failure is
// logged.
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 {
t.Error(err)
}
}
wantJob(t, e.waitTitle(t, e.generate(t)), jobs.StatusFailed, shopping.ReasonTitleFailed)
wantUntitled(t, "stored 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())
}
}
// TestGenerateTitleOverlapAcrossRename: a dinner renamed between two
// generations of an untitled list changes the names to title but not the
// plan revision. The two title jobs, running at once, each send the model
// their own names; neither is given the other's title.
func TestGenerateTitleOverlapAcrossRename(t *testing.T) {
e := titleWeek(t)
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"), titled("Drunken Noodles Week"))
// Each model call waits for the other, so the runs overlap.
var arrived sync.WaitGroup
arrived.Add(2)
e.beforeModel = func(llm.Request) {
arrived.Done()
done := make(chan struct{})
go func() { arrived.Wait(); close(done) }()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Error("the title runs did not overlap: one model call never came")
}
}
revision := func() int64 {
p, err := db.New(e.st.Reader).GetWeekPlan(t.Context(), week.String())
if err != nil {
t.Fatal(err)
}
return p.Revision
}
before := revision()
first := e.generate(t)
if _, err := e.st.Writer.Exec(`UPDATE dinners SET name = 'Drunken Noodles', normalized_name = 'drunken noodles' WHERE id = ?`, e.ids["Pad Thai"]); err != nil {
t.Fatal(err)
}
second := e.generate(t)
if revision() != before {
t.Fatalf("plan revision moved from %d to %d: the rename must leave it", before, revision())
}
wantJob(t, e.waitTitle(t, first), jobs.StatusSucceeded, "")
wantJob(t, e.waitTitle(t, second), jobs.StatusSucceeded, "")
var users []string
for _, r := range e.model.Requests() {
users = append(users, r.Messages[0].Content)
}
slices.Sort(users)
want := []string{
"Title the week whose dinners are, in day order from Monday:\n- Drunken Noodles\n- Fish Tacos\n- Beef Chili",
"Title the week whose dinners are, in day order from Monday:\n- Pad Thai\n- Fish Tacos\n- Beef Chili",
}
if !slices.Equal(users, want) {
t.Errorf("model was sent %q, want each generation's own names %q", users, want)
}
if s := e.runStatuses(t); len(s) != 2 {
t.Errorf("archive_title runs = %q, want two", s)
}
if got := e.storedTitle(t); got == nil || (*got != "Noodles, Tacos and Chili" && *got != "Drunken Noodles Week") {
t.Errorf("stored title = %v, want one of the two titles", deref(got))
}
}
+132 -4
View File
@@ -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" {
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)
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,0016_shopping_list_untitled", 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" {
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)
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,0016_shopping_list_untitled", got)
}
if !tableExists(t, s, "dinners") {
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)
}
}
// 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{
"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,
"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"},
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,
}},
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":
r["list_id"] = rowID("shopping_lists", 0)
r["dinner_id"] = rowID("dinners", 0)
r["date"] = fmt.Sprintf("2026-01-%02d", i+5)
r["position"] = i
case "shopping_items":
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;
+90
View File
@@ -0,0 +1,90 @@
-- 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 = ?;
-- name: SetShoppingListTitle :execrows
-- Titles list id, unless it has a title already or is frozen into the
-- Supply archive.
UPDATE shopping_lists
SET title = ?, updated_at = ?
WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL;