Make the Supply run from the plan revision Push to Calendar pushed
A plan edit landing between a successful Push and the Generate that followed it made the Supply run from the newer plan while the push answer reported supply_run generated, so the list could hold a dinner Calendar never showed (MQ-133-01). Push now returns the revision it pushed, and the push hook calls shopping.GeneratePushed, which checks inside its transaction that the week's plan is still at that revision (week_plans.revision, as the draft path does) and otherwise fails with ErrPlanChanged, leaving the Supply run as it was. The push answer then reports supply_run failed with error plan_changed (retryable: push again), logged as a warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,11 @@ const (
|
||||
// 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")
|
||||
@@ -165,6 +170,26 @@ type Generated struct {
|
||||
// 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)
|
||||
}
|
||||
@@ -178,6 +203,9 @@ func (s *Service) Generate(ctx context.Context, weekStart store.LocalDate) (Gene
|
||||
if err != nil {
|
||||
return fmt.Errorf("shopping: read plan: %w", err)
|
||||
}
|
||||
if err := check(plan); err != nil {
|
||||
return err
|
||||
}
|
||||
want, skipped, err := wanted(ctx, q, plan.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -561,6 +561,33 @@ func TestGenerateRefusesFrozenList(t *testing.T) {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user