Files
MealQuest/internal/server/shopping_test.go
T
dev_agentandClaude Opus 5.5 6aa5113488
CI / ci (push) Successful in 4m51s
CI / ci (pull_request) Successful in 4m49s
Add the Supply run API, item toggles and the Push to Calendar hook
GET /api/v1/shopping/{weekStart} reads the week's Supply run with its
week label, counts and groups by plan day; POST
/api/v1/shopping/{weekStart}/generate makes or remakes it through
shopping.Generate; PATCH /api/v1/shopping/items/{id} sets an item's
needed flag and answers with the list's new counts, 409 archived once
the list is in the Supply archive and 404 for an unknown item.

A successful Push to Calendar now makes the pushed week's Supply run.
Generation runs after the push is recorded and never fails or undoes
it: the 200 answer reports supply_run generated, archived or failed,
and a failure is logged as an error.

Maestro task d076480c-6c59-4c57-9023-6941badb4520.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 04:28:16 -04:00

467 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"database/sql"
"encoding/json"
"net/http"
"reflect"
"slices"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const shoppingPath = "/api/v1/shopping/"
// shopIng is a recipe ingredient as recipe grounding stores it: an empty
// qty or unit is NULL.
type shopIng struct {
name, qty, unit string
}
// groundDay grounds the dinner planned on date of the plans env's week in
// a fetched recipe listing ings.
func (e plansEnv) groundDay(t *testing.T, date string, ings ...shopIng) {
t.Helper()
ctx := t.Context()
q := db.New(e.st.Writer)
now := store.Now().String()
r, err := q.UpsertRecipe(ctx, db.UpsertRecipeParams{
ID: store.NewID(), SourceUrl: "https://recipes.example/" + date, Title: "Recipe of " + date,
RawJsonld: "{}", FetchedAt: now, CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
for i, in := range ings {
if err := q.CreateRecipeIngredient(ctx, db.CreateRecipeIngredientParams{
ID: store.NewID(), RecipeID: r.ID, Position: int64(i),
RawText: strings.TrimSpace(in.qty + " " + in.unit + " " + in.name),
Name: sql.NullString{String: in.name, Valid: true},
Qty: sql.NullString{String: in.qty, Valid: in.qty != ""},
Unit: sql.NullString{String: in.unit, Valid: in.unit != ""},
CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
}
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE dinners SET recipe_id = ?, grounding_status = 'grounded'
WHERE id = (SELECT dinner_id FROM plan_days WHERE date = ?)`, r.ID, date); err != nil {
t.Fatal(err)
}
}
// groundWeek grounds Monday's Beef Chili and Tuesday's Regular: five items,
// one with no quantity.
func (e plansEnv) groundWeek(t *testing.T) {
t.Helper()
e.groundDay(t, "2026-10-12", shopIng{"ground beef", "1", "lb"}, shopIng{"kidney beans", "2", "cans"}, shopIng{"onion", "1", ""})
e.groundDay(t, "2026-10-13", shopIng{"spaghetti", "500", "g"}, shopIng{"basil", "", ""})
}
// archive freezes the week's Supply run into the Supply archive.
func (e plansEnv) archive(t *testing.T) {
t.Helper()
if _, err := e.st.Writer.ExecContext(t.Context(),
`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
t.Fatal(err)
}
}
// supplyRunJSON is a Supply run answer decoded with every field the API
// promises.
type supplyRunJSON struct {
WeekStart string `json:"weekStart"`
Label string `json:"label"`
Title *string `json:"title"`
Status string `json:"status"`
SentToChatAt *string `json:"sent_to_chat_at"`
NeededCount int `json:"needed_count"`
TotalCount int `json:"total_count"`
Groups []struct {
Dinner struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"dinner"`
Date string `json:"date"`
Day string `json:"day"`
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Qty *string `json:"qty"`
Needed bool `json:"needed"`
} `json:"items"`
} `json:"groups"`
}
// render is l one item a line as "day date dinner: name [qty] needed|have
// it".
func (l supplyRunJSON) render() []string {
var out []string
for _, g := range l.Groups {
for _, it := range g.Items {
qty := "null"
if it.Qty != nil {
qty = *it.Qty
}
mark := "needed"
if !it.Needed {
mark = "have it"
}
out = append(out, g.Day+" "+g.Date+" "+g.Dinner.Name+": "+it.Name+" ["+qty+"] "+mark)
}
}
return out
}
// itemID is the id of the item named name.
func (l supplyRunJSON) itemID(t *testing.T, name string) string {
t.Helper()
for _, g := range l.Groups {
for _, it := range g.Items {
if it.Name == name {
return it.ID
}
}
}
t.Fatalf("no item %q", name)
return ""
}
// callShopping makes a request and decodes a 200 answer into out.
func callShopping(t *testing.T, e plansEnv, method, target, body string, out any) (int, map[string]any) {
t.Helper()
rec, got := callDinners(t, e.h, method, target, body)
if rec.Code == http.StatusOK && out != nil {
if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil {
t.Fatal(err)
}
}
return rec.Code, got
}
func keys(m map[string]any) []string {
var out []string
for k := range m {
out = append(out, k)
}
slices.Sort(out)
return out
}
var weekLines = []string{
"Monday 2026-10-12 Beef Chili: ground beef [1 lb] needed",
"Monday 2026-10-12 Beef Chili: kidney beans [2 cans] needed",
"Monday 2026-10-12 Beef Chili: onion [1] needed",
"Tuesday 2026-10-13 Regular 2026-10-13: spaghetti [500 g] needed",
"Tuesday 2026-10-13 Regular 2026-10-13: basil [null] needed",
}
// TestSupplyRunShape generates the week's Supply run and reads it back:
// both answers carry exactly the promised fields.
func TestSupplyRunShape(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", nil); status != http.StatusNotFound || errorCode(body) != "not_found" {
t.Fatalf("GET before generating = %d %v, want 404 not_found", status, body)
}
var made supplyRunJSON
status, raw := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &made)
if status != http.StatusOK {
t.Fatalf("generate = %d %v", status, raw)
}
wantTop := []string{"groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "total_count", "weekStart"}
if got := keys(raw); !reflect.DeepEqual(got, wantTop) {
t.Errorf("generate fields = %v, want %v", got, wantTop)
}
if skipped, _ := raw["skipped"].([]any); len(skipped) != 5 {
t.Errorf("skipped = %v, want the five days without a recipe", raw["skipped"])
}
var got supplyRunJSON
status, raw = callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &got)
if status != http.StatusOK {
t.Fatalf("GET = %d %v", status, raw)
}
if k := keys(raw); !reflect.DeepEqual(k, slices.DeleteFunc(slices.Clone(wantTop), func(s string) bool { return s == "skipped" })) {
t.Errorf("GET fields = %v", k)
}
group := raw["groups"].([]any)[0].(map[string]any)
if k := keys(group); !reflect.DeepEqual(k, []string{"date", "day", "dinner", "items"}) {
t.Errorf("group fields = %v", k)
}
if k := keys(group["dinner"].(map[string]any)); !reflect.DeepEqual(k, []string{"id", "name"}) {
t.Errorf("dinner fields = %v", k)
}
if k := keys(group["items"].([]any)[0].(map[string]any)); !reflect.DeepEqual(k, []string{"id", "name", "needed", "qty"}) {
t.Errorf("item fields = %v", k)
}
if !reflect.DeepEqual(got, made) {
t.Errorf("GET = %+v, want what generate made %+v", got, made)
}
if got.WeekStart != "2026-10-12" || got.Label != "Week 42 · Oct 12 – 18" || got.Title != nil ||
got.Status != "current" || got.SentToChatAt != nil || got.NeededCount != 5 || got.TotalCount != 5 {
t.Errorf("GET = %+v", got)
}
if !reflect.DeepEqual(got.render(), weekLines) {
t.Errorf("items =\n %s\nwant\n %s", strings.Join(got.render(), "\n "), strings.Join(weekLines, "\n "))
}
if got.Groups[0].Dinner.ID == "" || got.Groups[0].Items[0].ID == "" {
t.Errorf("ids missing: %+v", got.Groups[0])
}
}
// TestShoppingItemToggle marks items "have it" and needed again: each
// answer carries the item and the new counts, and the list read back
// agrees.
func TestShoppingItemToggle(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
var l supplyRunJSON
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l); status != http.StatusOK {
t.Fatalf("generate = %d %v", status, body)
}
onion, basil := l.itemID(t, "onion"), l.itemID(t, "basil")
for _, step := range []struct {
id string
needed bool
neededCount int
wantAfterGet string
}{
{onion, false, 4, "Monday 2026-10-12 Beef Chili: onion [1] have it"},
{onion, false, 4, "Monday 2026-10-12 Beef Chili: onion [1] have it"}, // sets, never flips
{basil, false, 3, "Tuesday 2026-10-13 Regular 2026-10-13: basil [null] have it"},
{onion, true, 4, "Monday 2026-10-12 Beef Chili: onion [1] needed"},
} {
body := `{"needed":false}`
if step.needed {
body = `{"needed":true}`
}
status, got := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+step.id, body, nil)
want := map[string]any{"id": step.id, "needed": step.needed, "needed_count": float64(step.neededCount), "total_count": float64(5)}
if status != http.StatusOK || !reflect.DeepEqual(got, want) {
t.Fatalf("PATCH %s = %d %v, want %v", body, status, got, want)
}
var after supplyRunJSON
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &after)
if after.NeededCount != step.neededCount || after.TotalCount != 5 || !slices.Contains(after.render(), step.wantAfterGet) {
t.Errorf("GET after PATCH %s = %d/%d %v", body, after.NeededCount, after.TotalCount, after.render())
}
}
}
func TestShoppingItemToggleErrors(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
var l supplyRunJSON
callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l)
onion := l.itemID(t, "onion")
for _, tc := range []struct {
name, id, body string
status int
code string
}{
{"no needed", onion, `{}`, http.StatusBadRequest, "invalid_request"},
{"needed null", onion, `{"needed":null}`, http.StatusBadRequest, "invalid_request"},
{"needed not a bool", onion, `{"needed":"no"}`, http.StatusBadRequest, "invalid_request"},
{"unknown field", onion, `{"needed":false,"qty":"2"}`, http.StatusBadRequest, "invalid_request"},
{"unknown item", store.NewID(), `{"needed":false}`, http.StatusNotFound, "not_found"},
} {
status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+tc.id, tc.body, nil)
if status != tc.status || errorCode(body) != tc.code {
t.Errorf("%s: PATCH = %d %v, want %d %s", tc.name, status, body, tc.status, tc.code)
}
}
e.archive(t)
status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+onion, `{"needed":false}`, nil)
if status != http.StatusConflict || errorCode(body) != "archived" {
t.Errorf("PATCH of an archived list = %d %v, want 409 archived", status, body)
}
var after supplyRunJSON
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &after)
if after.Status != "archived" || !reflect.DeepEqual(after.render(), weekLines) || after.NeededCount != 5 {
t.Errorf("archived list after PATCH = %s %v, want unchanged", after.Status, after.render())
}
}
// TestSupplyRunRegenerate remakes the list after a swap: the swapped-out
// dinner's group goes, and what the household marked "have it" stays.
func TestSupplyRunRegenerate(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
var l supplyRunJSON
callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l)
onion := l.itemID(t, "onion")
callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+onion, `{"needed":false}`, nil)
if _, err := e.svc.Swap(t.Context(), mustLocalDate(t, "2026-10-12"), "2026-10-13", e.questLog); err != nil {
t.Fatal(err)
}
var again supplyRunJSON
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &again); status != http.StatusOK {
t.Fatalf("generate again = %d %v", status, body)
}
want := []string{
"Monday 2026-10-12 Beef Chili: ground beef [1 lb] needed",
"Monday 2026-10-12 Beef Chili: kidney beans [2 cans] needed",
"Monday 2026-10-12 Beef Chili: onion [1] have it",
}
if !reflect.DeepEqual(again.render(), want) || again.NeededCount != 2 || again.TotalCount != 3 {
t.Errorf("remade = %d/%d %v, want 2/3 %v", again.NeededCount, again.TotalCount, again.render(), want)
}
if again.itemID(t, "onion") != onion {
t.Error("onion has a new id after the remake")
}
}
func TestSupplyRunErrors(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
for _, tc := range []struct {
name, method, path string
status int
code string
}{
{"GET not a Monday", http.MethodGet, "2026-10-13", http.StatusBadRequest, "invalid_week"},
{"GET no list", http.MethodGet, "2026-10-19", http.StatusNotFound, "not_found"},
{"generate not a Monday", http.MethodPost, "2026-10-13/generate", http.StatusBadRequest, "invalid_week"},
{"generate no plan", http.MethodPost, "2026-10-19/generate", http.StatusNotFound, "not_found"},
} {
status, body := callShopping(t, e, tc.method, shoppingPath+tc.path, "", nil)
if status != tc.status || errorCode(body) != tc.code {
t.Errorf("%s = %d %v, want %d %s", tc.name, status, body, tc.status, tc.code)
}
}
callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
e.archive(t)
status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
if status != http.StatusConflict || errorCode(body) != "archived" {
t.Errorf("generate of an archived list = %d %v, want 409 archived", status, body)
}
}
// TestPushPlanMakesSupplyRun: a Push to Calendar makes the week's Supply
// run from the plan pushed.
func TestPushPlanMakesSupplyRun(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
e.connectCalendar(t, true)
rec, body := e.push(t, "2026-10-12")
if rec.Code != http.StatusOK || body["status"] != "synced" {
t.Fatalf("push = %d %s", rec.Code, rec.Body)
}
if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) {
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
}
var l supplyRunJSON
if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l); status != http.StatusOK {
t.Fatalf("GET after push = %d %v, want the Supply run", status, got)
}
if !reflect.DeepEqual(l.render(), weekLines) {
t.Errorf("Supply run after push = %v", l.render())
}
}
// TestFailedPushMakesNoSupplyRun: a push Calendar refused makes no Supply
// run.
func TestFailedPushMakesNoSupplyRun(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
if rec, _ := e.push(t, "2026-10-12"); rec.Code != http.StatusConflict {
t.Fatalf("push unconnected = %d %s", rec.Code, rec.Body)
}
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", nil); status != http.StatusNotFound {
t.Errorf("GET after a failed push = %d %v, want 404", status, body)
}
}
// TestPushPlanSupplyRunFails: when the Supply run cannot be made after the
// push, the push stands (200, synced, on Calendar), the answer says the
// Supply run failed, the failure is logged as an error, and no Supply run
// is claimed.
func TestPushPlanSupplyRunFails(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE recipe_ingredients SET name = NULL WHERE name = 'onion'`); err != nil {
t.Fatal(err)
}
e.connectCalendar(t, true)
rec, body := e.push(t, "2026-10-12")
if rec.Code != http.StatusOK || body["status"] != "synced" {
t.Fatalf("push = %d %s, want 200 synced", rec.Code, rec.Body)
}
want := map[string]any{"status": "failed", "error": map[string]any{
"code": "supply_run_failed",
"message": "the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run",
"retryable": true,
}}
if !reflect.DeepEqual(body["supply_run"], want) {
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
}
if n := len(e.google.Events(pushCalendar.ID)); n != 7 {
t.Errorf("calendar has %d events, want 7", n)
}
p, err := e.svc.Get(t.Context(), mustLocalDate(t, "2026-10-12"))
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusSynced {
t.Errorf("plan = %s after the push, want synced", p.Status)
}
if !strings.Contains(e.logs.String(), `"level":"ERROR","msg":"generate Supply run after Push to Calendar"`) ||
!strings.Contains(e.logs.String(), "has no normalized name") {
t.Errorf("logs = %s, want the failure logged as an error", e.logs)
}
if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", nil); status != http.StatusNotFound {
t.Errorf("GET after the failure = %d %v, want 404", status, got)
}
}
// TestPushPlanLeavesArchivedSupplyRun: pushing a week whose Supply run is
// in the Supply archive leaves it as it was and says so.
func TestPushPlanLeavesArchivedSupplyRun(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
e.archive(t)
if _, err := e.svc.Swap(t.Context(), mustLocalDate(t, "2026-10-12"), "2026-10-13", e.questLog); err != nil {
t.Fatal(err)
}
e.connectCalendar(t, true)
rec, body := e.push(t, "2026-10-12")
if rec.Code != http.StatusOK || body["status"] != "synced" {
t.Fatalf("push = %d %s", rec.Code, rec.Body)
}
run, _ := body["supply_run"].(map[string]any)
if apiErr, _ := run["error"].(map[string]any); run["status"] != "archived" || apiErr["code"] != "archived" {
t.Errorf("supply_run = %v, want archived", body["supply_run"])
}
var l supplyRunJSON
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
if !reflect.DeepEqual(l.render(), weekLines) {
t.Errorf("archived Supply run after push = %v, want unchanged", l.render())
}
}