Register the weekly suggestions schedule (Feature 20)
suggest.Service.WeeklySchedule fires at the household suggestions_dow and suggestions_time (default Sunday 08:00) with period key suggestions_weekly:YYYY-Www and starts a scheduled batch through the same service and job as the API, via the new StartScheduledBatch(week). StartBatch now refuses the scheduled trigger. new_suggestions is still emitted only where Feature 19 put it, in the transaction that stores a batch's Side quests. A scheduled batch is now keyed new_suggestions:YYYY-Www by the week carried in its job, so a batch caught up late keeps its own week's key; a manual batch stays keyed by its id. The title counts the new Side quests. A batch whose AI call fails stores nothing and sends nothing; the failure is in ai_runs. Fake-clock tests in internal/scheduler cover one batch and one notification per week, settings-driven fire time, AI failure, restart, late catch-up and a batch already under way. Maestro task 459aab71-5c3d-4853-9855-236a3602b1e0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+57
-20
@@ -22,6 +22,9 @@ import (
|
||||
// BatchJob is the KindSuggestionBatch payload: the batch to run.
|
||||
type BatchJob struct {
|
||||
BatchID string `json:"batch_id"`
|
||||
// Week is the YYYY-MM-DD Monday of the ISO week a scheduled batch is
|
||||
// for, which keys its new_suggestions; empty for any other trigger.
|
||||
Week string `json:"week,omitempty"`
|
||||
}
|
||||
|
||||
// Batch stages, as the job's result reports them.
|
||||
@@ -57,8 +60,8 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
// shutdown, the job is left running and the batch with it, for the next
|
||||
// start to fail.
|
||||
//
|
||||
// A scheduled or manual batch emits new_suggestions, keyed by the batch,
|
||||
// in the transaction that first adds Side quests to the deck, so the
|
||||
// A scheduled or manual batch emits new_suggestions (see noticeOf) in the
|
||||
// transaction that first adds Side quests to the deck, so the
|
||||
// household is told of every Side quest stored, whether or not the batch
|
||||
// then ends done: a later round, or recording the batch's end, may fail.
|
||||
// A replenish batch, which tops up the deck as the household swipes, never
|
||||
@@ -78,7 +81,7 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
|
||||
if n == 0 {
|
||||
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID)
|
||||
}
|
||||
res, err := s.fill(ctx, p.BatchID, batch.Trigger != TriggerReplenish, publish)
|
||||
res, err := s.fill(ctx, batch.Trigger, p, publish)
|
||||
if err == nil {
|
||||
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
|
||||
return batchResult{}, err
|
||||
@@ -98,10 +101,11 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
|
||||
return batchResult{}, errors.Join(err, finishErr)
|
||||
}
|
||||
|
||||
// fill does runBatch's work, returning a *jobs.Failure for a failure the
|
||||
// household may see. When notifies is true, store emits new_suggestions
|
||||
// with the Side quests it adds.
|
||||
func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publish func(batchResult) error) (batchResult, error) {
|
||||
// fill does runBatch's work for job of a batch with trigger, returning a
|
||||
// *jobs.Failure for a failure the household may see. Unless the batch is a
|
||||
// replenish one, store emits new_suggestions with the Side quests it adds.
|
||||
func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publish func(batchResult) error) (batchResult, error) {
|
||||
batchID := job.BatchID
|
||||
res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
|
||||
if err := publish(res); err != nil {
|
||||
return batchResult{}, err
|
||||
@@ -110,6 +114,10 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
|
||||
if err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
note, err := noticeOf(trigger, job, in.Context.Today)
|
||||
if err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
out, err := s.suggest(ctx, batchID, in)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
@@ -122,11 +130,6 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
|
||||
if err := publish(res); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
var week *store.LocalDate
|
||||
if notifies {
|
||||
w := household.WeekStart(in.Context.Today)
|
||||
week = &w
|
||||
}
|
||||
left := out.Suggestions
|
||||
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 {
|
||||
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))]
|
||||
@@ -136,7 +139,7 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
|
||||
return batchResult{}, err
|
||||
}
|
||||
res.Dropped += len(round) - len(grounded)
|
||||
ids, err := s.store(ctx, batchID, week, grounded)
|
||||
ids, err := s.store(ctx, batchID, note, grounded)
|
||||
if err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
@@ -150,6 +153,40 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// notice is how a batch's new_suggestions is keyed: by id, as
|
||||
// new_suggestions:{id}, for the week starting on week.
|
||||
type notice struct {
|
||||
id string
|
||||
week store.LocalDate
|
||||
}
|
||||
|
||||
// noticeOf returns the notice of job, run for a batch with trigger on
|
||||
// household-local date today, or nil for a replenish batch, which never
|
||||
// notifies. A scheduled batch is keyed by the ISO week it was scheduled
|
||||
// for ("2026-W41"), not the week it happens to run in, so each week's
|
||||
// schedule tells the household once even when its batch runs late; a
|
||||
// manual batch is keyed by its batch id, for the week of today. A job
|
||||
// whose week does not fit its trigger is an error.
|
||||
func noticeOf(trigger string, job BatchJob, today store.LocalDate) (*notice, error) {
|
||||
if trigger != TriggerScheduled {
|
||||
if job.Week != "" {
|
||||
return nil, fmt.Errorf("suggest: %s batch %s has a week", trigger, job.BatchID)
|
||||
}
|
||||
if trigger == TriggerReplenish {
|
||||
return nil, nil
|
||||
}
|
||||
return ¬ice{id: job.BatchID, week: household.WeekStart(today)}, nil
|
||||
}
|
||||
week, err := store.ParseLocalDate(job.Week)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: scheduled batch %s week: %w", job.BatchID, err)
|
||||
}
|
||||
if household.WeekStart(week) != week {
|
||||
return nil, fmt.Errorf("suggest: scheduled batch %s week %s is not a Monday", job.BatchID, week)
|
||||
}
|
||||
return ¬ice{id: household.PeriodKey(week), week: week}, nil
|
||||
}
|
||||
|
||||
// request reads what suggest_side_quests is given: the household context,
|
||||
// the deck's names and every passed name (ListPassedNames). A household with no Regulars
|
||||
// fails with ReasonNoRegulars.
|
||||
@@ -224,13 +261,13 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
|
||||
// or archived. Passed names are read in the same transaction as the store,
|
||||
// so a Pass or drop committed before it is never missed.
|
||||
//
|
||||
// When week is not nil and a Side quest is stored, new_suggestions:{batchID}
|
||||
// naming those stored is emitted in the same transaction, for the week
|
||||
// starting week, so the Side quests and their notification commit together
|
||||
// When note is not nil and a Side quest is stored, new_suggestions keyed by
|
||||
// note, counting and naming those stored, is emitted in the same
|
||||
// transaction, so the Side quests and their notification commit together
|
||||
// or not at all: a notification that cannot be stored fails the store, and
|
||||
// the batch with it. The event's key makes the first round to store Side
|
||||
// quests the one that notifies; a later round's emit stores nothing.
|
||||
func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDate, quests []groundedQuest) ([]string, error) {
|
||||
func (s *Service) store(ctx context.Context, batchID string, note *notice, quests []groundedQuest) ([]string, error) {
|
||||
ids := []string{}
|
||||
var stored []string
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
@@ -298,15 +335,15 @@ func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDa
|
||||
ids = append(ids, id)
|
||||
stored = append(stored, d.Name)
|
||||
}
|
||||
if week == nil || len(stored) == 0 {
|
||||
if note == nil || len(stored) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(batchID, *week, stored))
|
||||
return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(note.id, note.week, stored))
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if week != nil && len(stored) > 0 {
|
||||
if note != nil && len(stored) > 0 {
|
||||
s.notifier.Wake()
|
||||
}
|
||||
return ids, nil
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package suggest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
)
|
||||
|
||||
// WeeklySchedule is the suggestions_weekly schedule: at the household's
|
||||
// suggestions_dow and suggestions_time it starts a scheduled batch for the
|
||||
// ISO week of the fire date, its period scheduler.Weekly keys
|
||||
// suggestions_weekly:YYYY-Www. The batch tells the household of the Side
|
||||
// quests it adds with new_suggestions:YYYY-Www for that week, once; a batch
|
||||
// that fails before adding any, as when the AI fails, sends nothing. When a
|
||||
// batch is already pending or running the period starts none, and says so.
|
||||
func (s *Service) WeeklySchedule() scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.SuggestionsWeekly,
|
||||
Rule: func(settings household.Settings) scheduler.Rule {
|
||||
return scheduler.Weekly(time.Weekday(settings.SuggestionsDow), settings.SuggestionsTime)
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
b, err := s.StartScheduledBatch(ctx, p.Week)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !b.Created {
|
||||
s.logger.WarnContext(ctx, "scheduled suggestion batch not started: a batch is already under way",
|
||||
"period", p.Key, "batch_id", b.ID, "trigger", b.Trigger)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"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/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
@@ -154,17 +155,35 @@ type Batch struct {
|
||||
Created bool `json:"-"`
|
||||
}
|
||||
|
||||
// StartBatch creates a batch with trigger and enqueues the
|
||||
// StartBatch creates a manual or replenish batch and enqueues the
|
||||
// KindSuggestionBatch job that runs it, unless a batch is already pending
|
||||
// or running, which it returns instead with Created false.
|
||||
// or running, which it returns instead with Created false. A scheduled
|
||||
// batch is started by StartScheduledBatch.
|
||||
func (s *Service) StartBatch(ctx context.Context, trigger string) (Batch, error) {
|
||||
if trigger == TriggerScheduled {
|
||||
return Batch{}, errors.New("suggest: a scheduled batch is started by StartScheduledBatch")
|
||||
}
|
||||
if !slices.Contains(triggers, trigger) {
|
||||
return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", trigger)
|
||||
}
|
||||
return s.start(ctx, trigger, "")
|
||||
}
|
||||
|
||||
// StartScheduledBatch is StartBatch for a scheduled batch for the ISO week
|
||||
// starting on Monday week, which keys its new_suggestions (see noticeOf).
|
||||
func (s *Service) StartScheduledBatch(ctx context.Context, week store.LocalDate) (Batch, error) {
|
||||
if week.IsZero() || household.WeekStart(week) != week {
|
||||
return Batch{}, fmt.Errorf("suggest: scheduled batch week %s is not a Monday", week)
|
||||
}
|
||||
return s.start(ctx, TriggerScheduled, week.String())
|
||||
}
|
||||
|
||||
// start does StartBatch's work for a batch with trigger and the job week.
|
||||
func (s *Service) start(ctx context.Context, trigger, week string) (Batch, error) {
|
||||
var b Batch
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
var err error
|
||||
b, err = s.startBatch(ctx, tx, trigger)
|
||||
b, err = s.startBatch(ctx, tx, trigger, week)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
@@ -228,10 +247,11 @@ func failedBatch(ctx context.Context, q *db.Queries) (*Batch, error) {
|
||||
return &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
|
||||
}
|
||||
|
||||
// startBatch does StartBatch's work in tx: the batch and the job that runs
|
||||
// it are committed together, so a batch is never seen without its job. The
|
||||
// caller wakes the job runner once tx has committed and Created is true.
|
||||
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) {
|
||||
// startBatch does StartBatch's work in tx, for a batch with trigger whose
|
||||
// job has week (BatchJob.Week): the batch and the job that runs it are
|
||||
// committed together, so a batch is never seen without its job. The caller
|
||||
// wakes the job runner once tx has committed and Created is true.
|
||||
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger, week string) (Batch, error) {
|
||||
q := db.New(tx)
|
||||
cur, err := s.activeBatch(ctx, q)
|
||||
if err != nil {
|
||||
@@ -250,7 +270,7 @@ func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (B
|
||||
if err != nil {
|
||||
return Batch{}, fmt.Errorf("suggest: create batch: %w", err)
|
||||
}
|
||||
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID})
|
||||
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID, Week: week})
|
||||
if err != nil {
|
||||
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err)
|
||||
}
|
||||
@@ -428,7 +448,7 @@ func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, update
|
||||
if left >= ReplenishBelow {
|
||||
return nil
|
||||
}
|
||||
b, err := s.startBatch(ctx, tx, TriggerReplenish)
|
||||
b, err := s.startBatch(ctx, tx, TriggerReplenish, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -300,6 +300,45 @@ func (e *env) runBatch(t *testing.T) batchResult {
|
||||
return res
|
||||
}
|
||||
|
||||
// scheduledWeek is the week the tests' scheduled batches are for: the
|
||||
// Monday of 2026-W41.
|
||||
var scheduledWeek = mustDate("2026-10-05")
|
||||
|
||||
func mustDate(s string) store.LocalDate {
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func mustZone(t *testing.T, name string) *time.Location {
|
||||
t.Helper()
|
||||
loc, err := household.LoadZone(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
// startBatch starts a batch with trigger, a scheduled one for
|
||||
// scheduledWeek, and returns it with the key its new_suggestions has.
|
||||
func (e *env) startBatch(t *testing.T, trigger string) (suggest.Batch, string) {
|
||||
t.Helper()
|
||||
if trigger == suggest.TriggerScheduled {
|
||||
b, err := e.svc.StartScheduledBatch(t.Context(), scheduledWeek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b, "new_suggestions:2026-W41"
|
||||
}
|
||||
b, err := e.svc.StartBatch(t.Context(), trigger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b, "new_suggestions:" + b.ID
|
||||
}
|
||||
|
||||
func (e *env) batchStatus(t *testing.T, id string) string {
|
||||
t.Helper()
|
||||
var status string
|
||||
@@ -404,8 +443,9 @@ func TestBatchStoresGroundedSideQuests(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestBatchEmitsNewSuggestions: a scheduled or manual batch that adds Side
|
||||
// quests stores new_suggestions keyed by the batch, naming them; a
|
||||
// replenish batch, or one that adds none, stores nothing.
|
||||
// quests stores new_suggestions counting and naming them, keyed by the
|
||||
// scheduled ISO week or the manual batch, for that week or the current
|
||||
// one; a replenish batch, or one that adds none, stores nothing.
|
||||
func TestBatchEmitsNewSuggestions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
trigger string
|
||||
@@ -428,10 +468,7 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
|
||||
sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"),
|
||||
sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"),
|
||||
)
|
||||
b, err := e.svc.StartBatch(t.Context(), tc.trigger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, key := e.startBatch(t, tc.trigger)
|
||||
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("batch job %s", job.Status)
|
||||
}
|
||||
@@ -446,9 +483,13 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
|
||||
t.Fatalf("new_suggestions rows = %+v, want one on push", rows)
|
||||
}
|
||||
r := rows[0]
|
||||
if r.Key != "new_suggestions:"+b.ID || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" ||
|
||||
r.Payload.Title != "New Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week == "" {
|
||||
t.Errorf("new_suggestions row = %+v, want key new_suggestions:%s naming the Side quests, /discover", r, b.ID)
|
||||
week := household.WeekStart(store.LocalDateOf(time.Now().In(mustZone(t, "America/New_York")))).String()
|
||||
if tc.trigger == suggest.TriggerScheduled {
|
||||
week = scheduledWeek.String()
|
||||
}
|
||||
if r.Key != key || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" ||
|
||||
r.Payload.Title != "2 new Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week != week {
|
||||
t.Errorf("new_suggestions row = %+v, want key %s counting and naming the Side quests, week %s, /discover", r, key, week)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -490,10 +531,7 @@ func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
b, err := e.svc.StartBatch(t.Context(), tc.trigger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, key := e.startBatch(t, tc.trigger)
|
||||
job := e.waitJob(t, b.JobID)
|
||||
want, deck := jobs.StatusSucceeded, suggest.BatchSize
|
||||
if tc.lastFails {
|
||||
@@ -512,9 +550,9 @@ func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID ||
|
||||
if len(rows) != 1 || rows[0].Key != key || rows[0].Payload.Title != "4 new Side quests are in" ||
|
||||
rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 1 more" {
|
||||
t.Errorf("new_suggestions rows = %+v, want one keyed by batch %s naming the first round", rows, b.ID)
|
||||
t.Errorf("new_suggestions rows = %+v, want one keyed %s counting and naming the first round", rows, key)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1299,7 +1337,7 @@ func TestBatchJobFailsWhenRecordingItsEndFails(t *testing.T) {
|
||||
t.Errorf("batch status = %s with its job failed, want failed", got)
|
||||
}
|
||||
// Its Side quests stay in the deck, and the household was told of them.
|
||||
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || rows[0].Payload.Body != "Chili Verde" {
|
||||
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || rows[0].Payload.Title != "1 new Side quest is in" || rows[0].Payload.Body != "Chili Verde" {
|
||||
t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want one naming Chili Verde", rows)
|
||||
}
|
||||
}
|
||||
@@ -1474,6 +1512,47 @@ func TestStartBatchRefusesUnknownTrigger(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestScheduledBatchNeedsItsWeek: a scheduled batch is started only by
|
||||
// StartScheduledBatch, for a Monday.
|
||||
func TestScheduledBatchNeedsItsWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
if _, err := e.svc.StartBatch(t.Context(), suggest.TriggerScheduled); err == nil {
|
||||
t.Error("StartBatch started a scheduled batch with no week")
|
||||
}
|
||||
for _, week := range []store.LocalDate{{}, mustDate("2026-10-06")} {
|
||||
if _, err := e.svc.StartScheduledBatch(t.Context(), week); err == nil {
|
||||
t.Errorf("StartScheduledBatch(%q) accepted a week that is not a Monday", week)
|
||||
}
|
||||
}
|
||||
if n := e.activeBatches(t); n != 0 {
|
||||
t.Errorf("%d active batches, want none", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScheduledBatchKeyedByItsWeekWhenRunLate: a scheduled batch's
|
||||
// new_suggestions is keyed by the week it was scheduled for, not the week
|
||||
// it runs in, so a batch caught up after its week ended still tells the
|
||||
// household of that week's Side quests and leaves the next week's key free.
|
||||
func TestScheduledBatchKeyedByItsWeekWhenRunLate(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.recipe(t, "Chili Verde")
|
||||
e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"))
|
||||
// A week long past: the batch runs today, weeks later.
|
||||
b, err := e.svc.StartScheduledBatch(t.Context(), mustDate("2026-01-05"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("batch job %s", job.Status)
|
||||
}
|
||||
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
|
||||
if len(rows) != 1 || rows[0].Key != "new_suggestions:2026-W02" || rows[0].Payload.Week != "2026-01-05" ||
|
||||
rows[0].Payload.Title != "1 new Side quest is in" {
|
||||
t.Errorf("new_suggestions rows = %+v, want one keyed new_suggestions:2026-W02 for 2026-01-05", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// noCalendar is a plan.Calendar for tests that never push.
|
||||
type noCalendar struct{}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user