Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
085456b65e | ||
|
|
0f7f8014f8 | ||
|
|
0dccf87b06 |
+1
-1
@@ -158,7 +158,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, sched.Wake, cfg.PublicURL, sched.Now, logger)
|
||||
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, sched.Wake, cfg.PublicURL, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+3
-10
@@ -1120,10 +1120,7 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
- The week's Supply run (Feature 17) is made from a pushed plan: after
|
||||
a successful `Push`, the handler runs `shopping.GeneratePushed` for
|
||||
the week at the plan revision `Push` pushed, and the 200 answer
|
||||
carries `supply_run: {status, error, title_job_id}`. `title_job_id` is
|
||||
the `title_supply_run` job titling a generated list, as for `POST
|
||||
/shopping/{weekStart}/generate`; null when the list already has its
|
||||
title or was not generated. `status` is `generated`;
|
||||
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
|
||||
@@ -1146,12 +1143,8 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
when the week has no Supply run, 400 `invalid_week`.
|
||||
- `POST /api/v1/shopping/{weekStart}/generate` makes or remakes the list
|
||||
through `shopping.Generate` and answers with the same shape plus
|
||||
`skipped: [{date, dinner_id, dinner_name, reason}]` and
|
||||
`title_job_id`, the `title_supply_run` job titling the list, which
|
||||
`GET /api/v1/jobs/{id}` reports on (failed `title_failed` when the title
|
||||
could not be written); null when the list already has its title. 404
|
||||
when the week has no plan, 409 `archived` when its list is in the Supply
|
||||
archive.
|
||||
`skipped: [{date, dinner_id, dinner_name, reason}]`. 404 when the week
|
||||
has no plan, 409 `archived` when its list is in the Supply archive.
|
||||
- `POST /api/v1/shopping/{weekStart}/send-chat` (CSRF-checked) takes
|
||||
`{}` or `{"resend": true}` and posts the Supply run to Google Chat
|
||||
through `chat.PostSupplyRun`, in the week's thread: "Supply run · " and
|
||||
|
||||
@@ -94,7 +94,7 @@ func newEnv(t *testing.T) *env {
|
||||
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, time.Now, logger)
|
||||
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ func (s *Service) revision(ctx context.Context, weekStart store.LocalDate) (int6
|
||||
// the household context, the pool, last week's dinners and the dinner
|
||||
// time.
|
||||
func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skills.WeekRequest, error) {
|
||||
hctx, err := harness.BuildContext(ctx, s.st, s.now(), ContextBudget)
|
||||
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
|
||||
if err != nil {
|
||||
return skills.WeekRequest{}, fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
|
||||
+5
-10
@@ -149,19 +149,16 @@ type Service struct {
|
||||
// publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links
|
||||
// to its dinner.
|
||||
publicURL *url.URL
|
||||
// now is the clock today is read from: the scheduler's.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService returns a service over st that drafts with draft, pushes to
|
||||
// cal with links under publicURL, tells the household through notifier,
|
||||
// which must be over st, calls wake (the scheduler's Wake) after every
|
||||
// committed change to a plan's days, reads today from now (the scheduler's
|
||||
// Now) and registers the KindDraftWeek job on runner, which must not have
|
||||
// started yet. The kind is service-only:
|
||||
// committed change to a plan's days, and registers the KindDraftWeek job on
|
||||
// runner, which must not have started yet. The kind is service-only:
|
||||
// StartDraft and StartScheduledDraft, which keep one draft in flight per
|
||||
// week, are the only way to enqueue it.
|
||||
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, wake func(), publicURL *url.URL, now func() time.Time, logger *slog.Logger) (*Service, error) {
|
||||
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, wake func(), publicURL *url.URL, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("plan: nil store")
|
||||
@@ -177,12 +174,10 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calenda
|
||||
return nil, errors.New("plan: nil wake")
|
||||
case publicURL == nil:
|
||||
return nil, errors.New("plan: nil public URL")
|
||||
case now == nil:
|
||||
return nil, errors.New("plan: nil clock")
|
||||
case logger == nil:
|
||||
return nil, errors.New("plan: nil logger")
|
||||
}
|
||||
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, wake: wake, logger: logger, publicURL: publicURL, now: now}
|
||||
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, wake: wake, logger: logger, publicURL: publicURL}
|
||||
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -506,7 +501,7 @@ func (s *Service) Tonight(ctx context.Context) (week, today store.LocalDate, err
|
||||
if err != nil {
|
||||
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: household settings: %w", err)
|
||||
}
|
||||
today = household.Today(s.now(), zone)
|
||||
today = household.Today(time.Now(), zone)
|
||||
week = household.WeekStart(today)
|
||||
_, err = q.GetWeekPlan(ctx, week.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
|
||||
@@ -146,7 +146,7 @@ func newEnv(t *testing.T) *env {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), func() {}, publicURL, time.Now, logger)
|
||||
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), func() {}, publicURL, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -86,21 +85,7 @@ func (c noCalendar) PushEvents(context.Context, []gcal.Event, func() error) erro
|
||||
// pass the fake clock's waits do not expect.
|
||||
func (e *env) plans(ids map[string]string) *planEnv {
|
||||
e.t.Helper()
|
||||
return e.newPlans(ids, func() {}, time.Now)
|
||||
}
|
||||
|
||||
// scheduledPlans is plans whose today is read from the clock of the
|
||||
// scheduler start last started, as in the server, so a draft's plan_ready
|
||||
// title follows the fake clock. It drafts only once a scheduler started.
|
||||
func (e *env) scheduledPlans(ids map[string]string) *planEnv {
|
||||
e.t.Helper()
|
||||
return e.newPlans(ids, func() {}, func() time.Time {
|
||||
s := e.sched.Load()
|
||||
if s == nil {
|
||||
panic("plan service read the clock before a scheduler started")
|
||||
}
|
||||
return s.Now()
|
||||
})
|
||||
return e.newPlans(ids, func() {})
|
||||
}
|
||||
|
||||
// wakingPlans is plans whose changes wake the scheduler, as in the server.
|
||||
@@ -108,12 +93,11 @@ func (e *env) scheduledPlans(ids map[string]string) *planEnv {
|
||||
// the wake's pass with changed.
|
||||
func (e *env) wakingPlans(ids map[string]string) *planEnv {
|
||||
e.t.Helper()
|
||||
return e.newPlans(ids, e.wake, time.Now)
|
||||
return e.newPlans(ids, e.wake)
|
||||
}
|
||||
|
||||
// newPlans is plans whose service calls wake after a plan change and reads
|
||||
// today from now.
|
||||
func (e *env) newPlans(ids map[string]string, wake func(), now func() time.Time) *planEnv {
|
||||
// newPlans is plans whose service calls wake after a plan change.
|
||||
func (e *env) newPlans(ids map[string]string, wake func()) *planEnv {
|
||||
e.t.Helper()
|
||||
p := &planEnv{e: e, model: &llm.Fake{}, ids: ids, logs: &syncBuffer{}}
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
@@ -134,7 +118,7 @@ func (e *env) newPlans(ids map[string]string, wake func(), now func() time.Time)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), wake, publicURL, now, logger)
|
||||
p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), wake, publicURL, logger)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
@@ -251,41 +235,6 @@ func (e *env) planReady() []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
// readyRow is a stored plan_ready notification as these tests check it.
|
||||
type readyRow struct {
|
||||
Key string
|
||||
Channel notify.Channel
|
||||
Title string
|
||||
}
|
||||
|
||||
// planReadyRows returns every plan_ready notification stored, oldest
|
||||
// first, each week's Google Chat row before its push row.
|
||||
func (e *env) planReadyRows() []readyRow {
|
||||
e.t.Helper()
|
||||
var out []readyRow
|
||||
for _, r := range notifytest.Rows(e.t, e.st, notify.PlanReady) {
|
||||
out = append(out, readyRow{Key: r.Key, Channel: r.Channel, Title: r.Payload.Title})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nextWeekReady is the plan_ready rows of a draft of the week starting
|
||||
// week, drafted the week before: one to Google Chat and one to push.
|
||||
func nextWeekReady(week string) []readyRow {
|
||||
const title = "Next week's plan is ready"
|
||||
key := string(notify.PlanReady) + ":" + week
|
||||
return []readyRow{{key, notify.ChannelChat, title}, {key, notify.ChannelPush, title}}
|
||||
}
|
||||
|
||||
// wantReady fails unless the plan_ready rows are got.
|
||||
func wantReady(t *testing.T, what string, got []readyRow, want ...[]readyRow) {
|
||||
t.Helper()
|
||||
all := slices.Concat(want...)
|
||||
if !slices.Equal(got, all) {
|
||||
t.Fatalf("%s: plan_ready rows = %+v, want %+v", what, got, all)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
succeeded = draftJob{Status: string(jobs.StatusSucceeded)}
|
||||
aiFailed = draftJob{Status: string(jobs.StatusFailed), Error: plan.ReasonAIFailed}
|
||||
@@ -308,7 +257,7 @@ func mustLocalDate(s string) store.LocalDate {
|
||||
// by its Monday once.
|
||||
func TestPlanDraftDraftsNextWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.scheduledPlans(e.pool())
|
||||
p := e.plans(e.pool())
|
||||
p.reply(w43)
|
||||
p.reply(w44)
|
||||
|
||||
@@ -322,7 +271,6 @@ func TestPlanDraftDraftsNextWeek(t *testing.T) {
|
||||
wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded)
|
||||
wantKeys(t, "plans at Friday 09:00", e.weekPlans(), []string{"2026-10-19"})
|
||||
wantKeys(t, "plan_ready of W43", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantReady(t, "plan_ready rows of W43", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
||||
got, err := p.svc.Get(context.Background(), w43)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -335,7 +283,6 @@ func TestPlanDraftDraftsNextWeek(t *testing.T) {
|
||||
wantDrafts(t, "W44", e.drafts(), succeeded, succeeded)
|
||||
wantKeys(t, "plans", e.weekPlans(), []string{"2026-10-19", "2026-10-26"})
|
||||
wantKeys(t, "plan_ready of W43 and W44", e.planReady(), []string{"plan_ready:2026-10-19", "plan_ready:2026-10-26"})
|
||||
wantReady(t, "plan_ready rows of W43 and W44", e.planReadyRows(), nextWeekReady("2026-10-19"), nextWeekReady("2026-10-26"))
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43", "2026-W44"})
|
||||
}
|
||||
|
||||
@@ -344,25 +291,23 @@ func TestPlanDraftDraftsNextWeek(t *testing.T) {
|
||||
// notifies no one and says so.
|
||||
func TestPlanDraftLeavesAnExistingPlan(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.scheduledPlans(e.pool())
|
||||
p := e.plans(e.pool())
|
||||
p.reply(w43)
|
||||
p.reply(w43) // a second draft would succeed too
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
if _, err := p.svc.StartDraft(context.Background(), w43, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantDrafts(t, "manual draft", e.drafts(), succeeded)
|
||||
wantKeys(t, "plan_ready of the manual draft", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantReady(t, "plan_ready rows of the manual draft", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
||||
before, err := p.svc.Get(context.Background(), w43)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "with a plan", e.drafts(), succeeded)
|
||||
wantKeys(t, "plan_ready with a plan", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantReady(t, "plan_ready rows with a plan", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
after, err := p.svc.Get(context.Background(), w43)
|
||||
if err != nil {
|
||||
@@ -382,25 +327,23 @@ func TestPlanDraftLeavesAnExistingPlan(t *testing.T) {
|
||||
func TestPlanDraftNoDuplicateAfterRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.pool()
|
||||
p := e.scheduledPlans(ids)
|
||||
p := e.plans(ids)
|
||||
p.reply(w43)
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "first process", e.drafts(), succeeded)
|
||||
wantReady(t, "plan_ready rows of the first process", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
||||
r.stop()
|
||||
p.stop()
|
||||
|
||||
// The restarted process has a new job runner, plan service and
|
||||
// scheduler; it would answer a second draft if one were started.
|
||||
p2 := e.scheduledPlans(ids)
|
||||
p2 := e.plans(ids)
|
||||
p2.reply(w43)
|
||||
r2 := e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
|
||||
r2.runUntil(at(2026, 10, 23, 8, 59))
|
||||
wantDrafts(t, "after restart", e.drafts(), succeeded)
|
||||
wantKeys(t, "plans after restart", e.weekPlans(), []string{"2026-10-19"})
|
||||
wantKeys(t, "plan_ready after restart", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantReady(t, "plan_ready rows after restart", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
}
|
||||
|
||||
@@ -411,7 +354,7 @@ func TestPlanDraftNoDuplicateAfterRestart(t *testing.T) {
|
||||
func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.pool()
|
||||
p := e.scheduledPlans(ids)
|
||||
p := e.plans(ids)
|
||||
p.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
|
||||
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
@@ -419,7 +362,6 @@ func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
|
||||
wantDrafts(t, "after the AI failed", e.drafts(), aiFailed)
|
||||
wantKeys(t, "plans after the AI failed", e.weekPlans(), nil)
|
||||
wantKeys(t, "plan_ready after the AI failed", e.planReady(), nil)
|
||||
wantReady(t, "plan_ready rows after the AI failed", e.planReadyRows())
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
|
||||
var status, msg string
|
||||
@@ -435,12 +377,11 @@ func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
|
||||
|
||||
r.stop()
|
||||
p.stop()
|
||||
p2 := e.scheduledPlans(ids)
|
||||
p2 := e.plans(ids)
|
||||
p2.reply(w43)
|
||||
e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
|
||||
wantDrafts(t, "after restart", e.drafts(), aiFailed)
|
||||
wantKeys(t, "plan_ready after restart", e.planReady(), nil)
|
||||
wantReady(t, "plan_ready rows after restart", e.planReadyRows())
|
||||
}
|
||||
|
||||
// TestPlanDraftFollowsSettingsWithoutRestart: a plan_draft_dow and
|
||||
@@ -449,7 +390,7 @@ func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
|
||||
// week.
|
||||
func TestPlanDraftFollowsSettingsWithoutRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.scheduledPlans(e.pool())
|
||||
p := e.plans(e.pool())
|
||||
p.reply(w43)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 12, 0), p.svc.DraftSchedule()) // Monday
|
||||
@@ -460,7 +401,6 @@ func TestPlanDraftFollowsSettingsWithoutRestart(t *testing.T) {
|
||||
r.runUntil(at(2026, 10, 14, 18, 30))
|
||||
wantDrafts(t, "at Wednesday 18:30", e.drafts(), succeeded)
|
||||
wantKeys(t, "plan_ready", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantReady(t, "plan_ready rows", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
||||
|
||||
// Friday 09:00, the old fire time, drafts nothing more.
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
|
||||
@@ -378,11 +378,6 @@ func (s *Scheduler) Wake() {
|
||||
s.signal()
|
||||
}
|
||||
|
||||
// Now is the time on the scheduler's clock, by which its schedules fire.
|
||||
func (s *Scheduler) Now() time.Time {
|
||||
return s.now()
|
||||
}
|
||||
|
||||
// signal leaves one token in wake for the loop.
|
||||
func (s *Scheduler) signal() {
|
||||
select {
|
||||
|
||||
@@ -73,7 +73,7 @@ func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *reci
|
||||
t.Error("draft_week ran: an idea action must never draft a plan")
|
||||
return skills.Week{}, context.Canceled
|
||||
}
|
||||
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, time.Now, logger)
|
||||
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -111,13 +111,10 @@ type pushedPlan struct {
|
||||
// plan Calendar does not show and pushing again makes it; or
|
||||
// supply_run_failed when it could not be made, and POST
|
||||
// /shopping/{weekStart}/generate makes it again. error is null when status
|
||||
// is generated. title_job_id is the job titling a generated Supply run,
|
||||
// which GET /jobs/{id} reports on; null when it already has its title or
|
||||
// was not generated.
|
||||
// is generated.
|
||||
type pushedSupplyRun struct {
|
||||
Status string `json:"status"`
|
||||
Error *apiError `json:"error"`
|
||||
TitleJobID *string `json:"title_job_id"`
|
||||
Status string `json:"status"`
|
||||
Error *apiError `json:"error"`
|
||||
}
|
||||
|
||||
// postPlanPush pushes the week's plan to the chosen Google calendar
|
||||
@@ -146,9 +143,7 @@ func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service
|
||||
// gone.
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
run := pushedSupplyRun{Status: "generated"}
|
||||
if g, err := shop.GeneratePushed(ctx, week, revision); err == nil {
|
||||
run.TitleJobID = g.TitleJobID
|
||||
} else if errors.Is(err, shopping.ErrFrozen) {
|
||||
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) {
|
||||
@@ -158,7 +153,7 @@ func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service
|
||||
Message: "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again",
|
||||
Retryable: true,
|
||||
}}
|
||||
} else {
|
||||
} 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",
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
@@ -94,7 +93,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier := notifytest.New(t, st)
|
||||
svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, func() {}, pub, time.Now, logger)
|
||||
svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, func() {}, pub, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/oauth2"
|
||||
@@ -241,7 +240,7 @@ func newTestPlan(t *testing.T) *plan.Service {
|
||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, time.Now, logger)
|
||||
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -304,7 +303,7 @@ func newTestHomeOn(t *testing.T, st *store.Store) *home.Service {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, time.Now, logger)
|
||||
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -60,13 +60,10 @@ type supplyRunItem struct {
|
||||
}
|
||||
|
||||
// generatedSupplyRun is the POST /shopping/{weekStart}/generate answer:
|
||||
// the Supply run as made, the plan days with nothing to shop for, and the
|
||||
// title_job_id of the job titling it, which GET /jobs/{id} reports on;
|
||||
// null when the Supply run already has its title.
|
||||
// the Supply run as made, and the plan days with nothing to shop for.
|
||||
type generatedSupplyRun struct {
|
||||
supplyRun
|
||||
Skipped []shopping.SkippedDay `json:"skipped"`
|
||||
TitleJobID *string `json:"title_job_id"`
|
||||
Skipped []shopping.SkippedDay `json:"skipped"`
|
||||
}
|
||||
|
||||
// sendChatRequest is the POST /shopping/{weekStart}/send-chat body:
|
||||
@@ -111,7 +108,7 @@ func getSupplyRun(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// postSupplyRunGenerate makes, or remakes, the week's Supply run from its
|
||||
// plan and answers with it, the plan days it skipped and its title job.
|
||||
// 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)
|
||||
@@ -132,7 +129,7 @@ func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.Hand
|
||||
if skipped == nil {
|
||||
skipped = []shopping.SkippedDay{}
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped, TitleJobID: g.TitleJobID})
|
||||
writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ func TestSupplyRunShape(t *testing.T) {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("generate = %d %v", status, raw)
|
||||
}
|
||||
wantTop := []string{"chat_send_unconfirmed_at", "groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "title_job_id", "total_count", "weekStart"}
|
||||
wantTop := []string{"chat_send_unconfirmed_at", "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)
|
||||
}
|
||||
@@ -199,7 +199,7 @@ func TestSupplyRunShape(t *testing.T) {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("GET = %d %v", status, raw)
|
||||
}
|
||||
if k := keys(raw); !reflect.DeepEqual(k, slices.DeleteFunc(slices.Clone(wantTop), func(s string) bool { return s == "skipped" || s == "title_job_id" })) {
|
||||
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)
|
||||
@@ -376,12 +376,8 @@ func TestPushPlanMakesSupplyRun(t *testing.T) {
|
||||
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 k := keys(run); run["status"] != "generated" || run["error"] != nil || !reflect.DeepEqual(k, []string{"error", "status", "title_job_id"}) {
|
||||
t.Errorf("supply_run = %v, want generated with no error", run)
|
||||
}
|
||||
if job := titleJob(t, e, run["title_job_id"]); job["status"] != "queued" {
|
||||
t.Errorf("title job = %v, want queued: no runner in this test", job)
|
||||
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 {
|
||||
@@ -471,11 +467,9 @@ func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
|
||||
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 run["status"] != "generated" || run["error"] != nil {
|
||||
t.Errorf("supply_run = %v, want generated with no error", run)
|
||||
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)
|
||||
}
|
||||
titleJobID, _ := run["title_job_id"].(string)
|
||||
held.waitEntered(t)
|
||||
var l supplyRunJSON
|
||||
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
|
||||
@@ -484,7 +478,6 @@ func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
|
||||
}
|
||||
|
||||
close(held.release)
|
||||
wantTitleJobSucceeded(t, e, titleJobID)
|
||||
if got := e.waitTitled(t); got != "Chili and Friends" {
|
||||
t.Errorf("title = %q, want the title job's", got)
|
||||
}
|
||||
@@ -500,8 +493,7 @@ func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
|
||||
e.startRunner(t)
|
||||
|
||||
var l supplyRunJSON
|
||||
status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l)
|
||||
if status != http.StatusOK {
|
||||
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 {
|
||||
@@ -510,66 +502,12 @@ func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
|
||||
if !reflect.DeepEqual(l.render(), weekLines) {
|
||||
t.Errorf("generated = %v, want %v", l.render(), weekLines)
|
||||
}
|
||||
titleJobID, _ := body["title_job_id"].(string)
|
||||
held.waitEntered(t)
|
||||
|
||||
close(held.release)
|
||||
wantTitleJobSucceeded(t, e, titleJobID)
|
||||
if got := e.waitTitled(t); got != "Chili and Friends" {
|
||||
t.Errorf("title = %q, want the title job's", got)
|
||||
}
|
||||
if _, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil); body["title_job_id"] != nil {
|
||||
t.Errorf("remake title_job_id = %v, want null: the Supply run has its title", body["title_job_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupplyRunGenerateTitleJobFails: a title job that cannot title the
|
||||
// Supply run is reported failed with title_failed by GET /jobs/{id}, and
|
||||
// the week's next generation answers with a new title job.
|
||||
func TestSupplyRunGenerateTitleJobFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
e.groundWeek(t)
|
||||
e.startRunner(t)
|
||||
|
||||
_, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
|
||||
first := titleJob(t, e, body["title_job_id"])
|
||||
job := waitJob(t, e.h, first["id"].(string))
|
||||
if job["status"] != "failed" || job["error"] != shopping.ReasonTitleFailed {
|
||||
t.Errorf("title job = %v, want failed %s", job, shopping.ReasonTitleFailed)
|
||||
}
|
||||
|
||||
_, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
|
||||
if second := titleJob(t, e, body["title_job_id"]); second["id"] == first["id"] {
|
||||
t.Errorf("retry title_job_id = %v, want a new job", second["id"])
|
||||
}
|
||||
}
|
||||
|
||||
// titleJob returns the title_supply_run job id, as GET /jobs/{id} reports
|
||||
// it; id must be a job id string.
|
||||
func titleJob(t *testing.T, e plansEnv, id any) map[string]any {
|
||||
t.Helper()
|
||||
s, ok := id.(string)
|
||||
if !ok || s == "" {
|
||||
t.Fatalf("title_job_id = %v, want a job id", id)
|
||||
}
|
||||
rec, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+s, "")
|
||||
if rec.Code != http.StatusOK || job["kind"] != shopping.KindTitleSupplyRun {
|
||||
t.Fatalf("GET title job = %d %s, want a %s job", rec.Code, rec.Body, shopping.KindTitleSupplyRun)
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
// wantTitleJobSucceeded waits for title job id to finish and fails t
|
||||
// unless GET /jobs/{id} reports it succeeded with the title written.
|
||||
func wantTitleJobSucceeded(t *testing.T, e plansEnv, id string) {
|
||||
t.Helper()
|
||||
titleJob(t, e, id)
|
||||
job := waitJob(t, e.h, id)
|
||||
result, _ := job["result"].(map[string]any)
|
||||
if job["status"] != "succeeded" || result["title"] != "Chili and Friends" {
|
||||
t.Errorf("title job = %v, want succeeded with the title", job)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedPushMakesNoSupplyRun: a push Calendar refused makes no Supply
|
||||
@@ -608,7 +546,7 @@ func TestPushPlanSupplyRunFails(t *testing.T) {
|
||||
"code": "supply_run_failed",
|
||||
"message": "the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run",
|
||||
"retryable": true,
|
||||
}, "title_job_id": nil}
|
||||
}}
|
||||
if !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
}
|
||||
@@ -661,7 +599,7 @@ func TestPushPlanEditedBeforeSupplyRun(t *testing.T) {
|
||||
"code": "plan_changed",
|
||||
"message": "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again",
|
||||
"retryable": true,
|
||||
}, "title_job_id": nil}
|
||||
}}
|
||||
if !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
}
|
||||
@@ -694,7 +632,7 @@ func TestPushPlanLeavesArchivedSupplyRun(t *testing.T) {
|
||||
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
run, _ := body["supply_run"].(map[string]any)
|
||||
if apiErr, _ := run["error"].(map[string]any); run["status"] != "archived" || apiErr["code"] != "archived" || run["title_job_id"] != nil {
|
||||
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
|
||||
|
||||
@@ -311,38 +311,3 @@ func TestGenerateTitleOverlapAcrossRename(t *testing.T) {
|
||||
t.Errorf("stored title = %v, want one of the two titles", deref(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitleNotStoredOnReplacedList: a list replaced by a new
|
||||
// untitled list of the same week once archive_title has answered, before
|
||||
// its title is stored, keeps the replacement untitled; the old list's
|
||||
// title job fails, and the failure is logged.
|
||||
func TestGenerateTitleNotStoredOnReplacedList(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||||
e.afterTitle = func() {
|
||||
for _, stmt := range []string{
|
||||
`DELETE FROM shopping_items WHERE group_id IN (SELECT id FROM shopping_groups WHERE list_id IN (SELECT id FROM shopping_lists WHERE week_start = ?1))`,
|
||||
`DELETE FROM shopping_groups WHERE list_id IN (SELECT id FROM shopping_lists WHERE week_start = ?1)`,
|
||||
`UPDATE shopping_lists SET id = 'replacement' WHERE week_start = ?1`,
|
||||
} {
|
||||
if _, err := e.st.Writer.Exec(stmt, week.String()); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g := e.generate(t)
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusFailed, shopping.ReasonTitleFailed)
|
||||
|
||||
var id string
|
||||
if err := e.st.Reader.QueryRow(`SELECT id FROM shopping_lists WHERE week_start = ?`, week.String()).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "replacement" || id == g.List.ID {
|
||||
t.Fatalf("week's list = %s, want the replacement of %s", id, g.List.ID)
|
||||
}
|
||||
wantUntitled(t, "replacement's title", e.storedTitle(t))
|
||||
if !strings.Contains(e.log.String(), "the Supply run was frozen or replaced before its title was stored") {
|
||||
t.Errorf("log lacks the failure:\n%s", e.log.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1157,7 +1157,7 @@ func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) {
|
||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no draft in this test")
|
||||
}
|
||||
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), func() {}, &url.URL{Scheme: "https", Host: "meals.example.com"}, time.Now, logger)
|
||||
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), func() {}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user