Keep dinner names frozen in Supply archive posts
CI / ci (push) Successful in 8m17s

Freezing a Supply run now records each group's dinner name in
shopping_groups.frozen_dinner_name, in the same transaction as the
freeze. A frozen list and its Supply archive post read that name, so
renaming a dinner after its week is frozen no longer rewrites the
archive; a current list still shows its dinners' names live.
Migration 0018 adds the column and fills it for lists frozen before it.

Task: 22f80eb6-cdee-4377-80ea-9b28f15a866f

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 09:18:54 -04:00
co-authored by Claude Opus 5.5
parent 791b321b71
commit a72ef1cbdd
16 changed files with 234 additions and 38 deletions
+1 -1
View File
@@ -107,7 +107,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc,
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestDinnersOn(t, st), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+6 -1
View File
@@ -167,7 +167,12 @@ func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
// with its own unstarted job runner and a grounder that finds nothing.
func newTestDinners(t *testing.T) *dinners.Service {
t.Helper()
st := newTestStore(t)
return newTestDinnersOn(t, newTestStore(t))
}
// newTestDinnersOn is newTestDinners over st.
func newTestDinnersOn(t *testing.T, st *store.Store) *dinners.Service {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
+68 -1
View File
@@ -75,7 +75,7 @@ func (e plansEnv) groundWeek(t *testing.T) {
func (e plansEnv) archive(t *testing.T) {
t.Helper()
if _, err := e.st.Writer.ExecContext(t.Context(),
`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
t.Fatal(err)
}
}
@@ -852,6 +852,73 @@ func TestSupplyArchive(t *testing.T) {
}
}
// TestSupplyArchiveKeepsFrozenNames freezes last week's Supply run, then
// renames its dinner and this week's through the dinners API: the frozen
// post and list keep the name the dinner had when frozen, while this
// week's list, not frozen, shows the new name.
func TestSupplyArchiveKeepsFrozenNames(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM plan_days; DELETE FROM week_plans`); err != nil {
t.Fatal(err)
}
zone, err := household.LoadZone("America/New_York") // the env's household zone
if err != nil {
t.Fatal(err)
}
thisWeek := household.WeekStart(household.Today(time.Now(), zone))
y, m, d := thisWeek.Date()
lastWeek := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
last := e.weekSupplyRun(t, lastWeek, "Beef Stew", shopIng{"stewing beef", "2", "lb"})
current := e.weekSupplyRun(t, thisWeek, "Lemon Chicken", shopIng{"lemon", "1", ""})
if err := e.shop.Freeze(t.Context(), lastWeek); err != nil {
t.Fatal(err)
}
for id, name := range map[string]string{
last.Groups[0].Dinner.ID: "Sunday Beef Stew",
current.Groups[0].Dinner.ID: "Lemon Garlic Chicken",
} {
if status, body := callShopping(t, e, http.MethodPatch, dinnersPath+"/"+id, `{"name":"`+name+`"}`, nil); status != http.StatusOK {
t.Fatalf("PATCH dinner %s = %d %v", id, status, body)
}
}
var got archiveJSON
if status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got); status != http.StatusOK {
t.Fatalf("GET archive = %d %v", status, raw)
}
var posts []string
for _, p := range got.Periods {
for _, post := range p.Posts {
posts = append(posts, fmt.Sprintf("%s %v current=%t", post.WeekStart, post.Dinners, post.Current))
}
}
want := []string{
thisWeek.String() + " [Lemon Garlic Chicken] current=true",
lastWeek.String() + " [Beef Stew] current=false",
}
if !reflect.DeepEqual(posts, want) {
t.Errorf("archive after rename =\n %s\nwant\n %s", strings.Join(posts, "\n "), strings.Join(want, "\n "))
}
for _, tc := range []struct {
week store.LocalDate
want string
}{
{lastWeek, "Monday " + lastWeek.String() + " Beef Stew: stewing beef [2 lb] needed"},
{thisWeek, "Monday " + thisWeek.String() + " Lemon Garlic Chicken: lemon [1] needed"},
} {
var l supplyRunJSON
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+tc.week.String(), "", &l); status != http.StatusOK {
t.Fatalf("GET %s = %d %v", tc.week, status, body)
}
if got := l.render(); !reflect.DeepEqual(got, []string{tc.want}) {
t.Errorf("list %s after rename = %v, want [%s]", tc.week, got, tc.want)
}
}
}
// TestSupplyRunSendChat posts the week's Supply run to Google Chat once,
// records sent_to_chat_at, answers a second send with the status as it
// is, and posts again only with resend.