Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
765a36fa97 | ||
|
|
a7a56623e5 |
@@ -204,22 +204,6 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := sched.Register(planSvc.RotationCheckSchedule()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sched.Register(shopSvc.ChatAutosendSchedule()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sched.Register(shopSvc.RolloverSchedule()); err != nil {
|
||||
return err
|
||||
}
|
||||
purge, err := harness.PurgeSchedule(db, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sched.Register(purge); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, runner, photos, frontend.Assets())
|
||||
if err != nil {
|
||||
|
||||
+17
-40
@@ -484,13 +484,15 @@ automatic** (D-021).
|
||||
`changed`, and answers 200 with the plan. A date outside the week is 400 `invalid_date`, an unknown dinner or
|
||||
a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and
|
||||
a dinner already on another day of the week 409 `dinner_already_planned`.
|
||||
- Rotation check (`plan.Service`, D-021). The `rotation_check` schedule
|
||||
calls `MarkTried` for the household-local day just past (see Scheduler).
|
||||
`MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any
|
||||
status, to `tried`, opens a `pending` `rotation_reviews` row for it and
|
||||
emits its `rotation_review`, in one transaction;
|
||||
a dinner already checked for that date is left as it is and not
|
||||
notified again, so it is idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the
|
||||
- Rotation check (`plan.Service`, D-021). This release provides the
|
||||
`MarkTried` service method and the rotation review read and decide APIs
|
||||
below. The `rotation_check` schedule that is to call `MarkTried` for the
|
||||
household-local day just past is planned (Feature 20, the scheduler) and
|
||||
not wired yet: nothing in production calls `MarkTried`, so no Rotation
|
||||
check opens automatically. `MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any
|
||||
status, to `tried` and opens a `pending` `rotation_reviews` row for it;
|
||||
a dinner already checked for that date is left as it is, so it is
|
||||
idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the
|
||||
pending checks whose dinner is still tried, oldest `plan_date` first,
|
||||
each `{dinner: {id, name, photo}, plan_date}`. `POST
|
||||
/api/v1/dinners/{id}/rotation-review` takes `{decision}`: `keep` makes
|
||||
@@ -535,8 +537,8 @@ automatic** (D-021).
|
||||
received.
|
||||
`harness.Purge` deletes rows older than `harness.RunRetention` (30 days),
|
||||
clearing the `suggestion_batches` and `week_plans` references first. The
|
||||
daily `ai_runs_purge` schedule (`harness.PurgeSchedule`) calls it with
|
||||
its fire time less `RunRetention`.
|
||||
harness does not call it: the scheduler's daily `ai_runs` purge
|
||||
(Feature 20) must call `harness.Purge` with `now - RunRetention`.
|
||||
- **Concurrency guards** (`harness.Guard`, one per process): `harness.Run`
|
||||
takes a scope key, and concurrent runs of one skill with one scope share a
|
||||
single in-flight run and its result, error included (single-flight); runs
|
||||
@@ -651,8 +653,7 @@ job failed, the freeze finds the list still untitled and makes its own
|
||||
`archive_title` attempt before freezing. A job that starts during a freeze
|
||||
waits for it, then finds the list titled (that title stands) or frozen
|
||||
untitled (the job fails `title_failed`), and stores nothing. The
|
||||
`week_rollover` schedule (`shopping.Service.RolloverSchedule`) calls
|
||||
`Freeze` for last week, judging the week ended by its fire time.
|
||||
`week_rollover` schedule is to call `Freeze` for last week.
|
||||
|
||||
## Recipe grounding
|
||||
|
||||
@@ -780,11 +781,11 @@ purge are fixed and not configurable.
|
||||
| --- | --- | --- |
|
||||
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests (`suggest.Service.WeeklySchedule`). |
|
||||
| `plan_draft` | Friday 09:00 | AI drafts next week's plan (`plan.Service.DraftSchedule`). |
|
||||
| `shopping_chat_autosend` | Saturday 08:00 | Sends the coming week's Supply run to Google Chat (`shopping.Service.ChatAutosendSchedule`). |
|
||||
| `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. |
|
||||
| `day_of_reminder` | Daily 16:30, and 15 minutes before a crock-pot start_by earlier than that | Tonight's dinner reminder and the crock-pot start reminder (`plan.Service.ReminderSchedules`). |
|
||||
| `rotation_check` | Daily 09:00 | Marks yesterday's Quest log dinners tried and asks "Keep it in rotation?" (`plan.Service.RotationCheckSchedule`). |
|
||||
| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive (`shopping.Service.RolloverSchedule`). |
|
||||
| `ai_runs_purge` | Daily 03:00 (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (`harness.PurgeSchedule`). |
|
||||
| `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. |
|
||||
| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive. |
|
||||
| `ai_runs` purge | Daily (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (Feature 20). |
|
||||
|
||||
Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
|
||||
@@ -839,26 +840,6 @@ comes at once, so a crock-pot dinner swapped in for tonight is still
|
||||
reminded 15 minutes before its `start_by`, not at the next hourly timer
|
||||
(`maxWait`); a lookup that fails is logged and repeated every
|
||||
`ClaimRetry`.
|
||||
`shopping.Service.ChatAutosendSchedule()` is `shopping_chat_autosend`,
|
||||
`WeeklyForNextWeek(chat_autosend_dow, chat_autosend_time)`, so its period
|
||||
names the coming week. Its run calls `Autosend(period.Week)`, which posts
|
||||
with `SendToChat(week, false)`, the household's own send, only when a
|
||||
webhook is stored, `list_ready`'s Chat channel is on
|
||||
(`notify.Service.ChannelOn`) and the week has a Supply run neither sent
|
||||
nor with a send unconfirmed; otherwise it posts nothing and logs why,
|
||||
with the key `list_ready:chat:{weekStart}`. It never resends; a send that
|
||||
fails is the period's error. `shopping.Service.RolloverSchedule()` is
|
||||
`week_rollover`, `Weekly(Monday, 00:05)`; its run freezes the week before
|
||||
the fire date's, and a week with no Supply run freezes nothing and logs
|
||||
it. `plan.Service.RotationCheckSchedule()` is `rotation_check`,
|
||||
`Daily(review_time)`, claimed as `rotation_check:YYYY-MM-DD` of the fire
|
||||
date (`Schedule.PeriodKey`). Its run calls `MarkTried` for the day before
|
||||
the fire date, which emits one `rotation_review` per Rotation check it
|
||||
opens; it never makes a dinner a Regular, and a day with no Quest log
|
||||
dinner planned opens none and logs the count.
|
||||
`harness.PurgeSchedule(store, logger)` is `ai_runs_purge`,
|
||||
`Daily(03:00)`, deleting rows created more than 30 days before the fire
|
||||
time.
|
||||
Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
`SetClock` is visible to tests.
|
||||
|
||||
@@ -903,10 +884,6 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
{time}" with the dinner's name, only for a crock-pot dinner whose
|
||||
`start_by` is earlier than `reminder_time`. The `day_of_reminder`
|
||||
schedule calls it 15 minutes before `start_by`.
|
||||
- `rotation_review:{dinnerId}:{planDate}` (URL `/`, where the Home card
|
||||
asks) through `plan.Service.MarkTried(planDate)`, "How was {dinner}?
|
||||
Keep it in rotation?", once per Rotation check it opens. The
|
||||
`rotation_check` schedule calls it at `review_time` for yesterday.
|
||||
- Delivery goes through `notification_outbox`.
|
||||
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
|
||||
webhook.
|
||||
@@ -1163,7 +1140,7 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
request. 404 when the week has no Supply run, 409
|
||||
`archived` when it is in the Supply archive, 400 `invalid_request` for a
|
||||
bad body. `shopping.SendToChat(weekStart, resend)` is what
|
||||
`shopping_chat_autosend` calls, through `Autosend`.
|
||||
`shopping_chat_autosend` calls (Feature 20).
|
||||
- `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required
|
||||
boolean; it sets the flag, it does not flip it) and answers `{id,
|
||||
needed, needed_count, total_count}` with the list's counts after the
|
||||
|
||||
@@ -34,8 +34,8 @@ const (
|
||||
StatusProviderError RunStatus = "provider_error"
|
||||
)
|
||||
|
||||
// RunRetention is how long ai_runs rows are kept. The daily purge,
|
||||
// PurgeSchedule, calls Purge with its fire time less RunRetention.
|
||||
// RunRetention is how long ai_runs rows are kept. The daily purge calls
|
||||
// Purge with time.Now().Add(-RunRetention).
|
||||
const RunRetention = 30 * 24 * time.Hour
|
||||
|
||||
// runSaveTimeout bounds writing the ai_runs row of a run that has failed,
|
||||
@@ -46,6 +46,9 @@ var runSaveTimeout = 5 * time.Second
|
||||
// Purge deletes the ai_runs rows created before olderThan and returns how
|
||||
// many it deleted. The suggestion_batches and week_plans references to
|
||||
// those rows are cleared first, in the same transaction.
|
||||
//
|
||||
// Nothing in this package calls Purge: the scheduler's daily ai_runs purge
|
||||
// (Feature 20) must call it with time.Now().Add(-RunRetention).
|
||||
func Purge(ctx context.Context, st *store.Store, olderThan time.Time) (int64, error) {
|
||||
if olderThan.IsZero() {
|
||||
return 0, errors.New("harness: purge ai_runs: zero cutoff")
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package harness
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// PurgeTime is when, every day in the household zone, ai_runs_purge runs.
|
||||
// It is fixed, not a setting.
|
||||
const PurgeTime = "03:00"
|
||||
|
||||
// PurgeSchedule is the ai_runs_purge schedule over st: every day at
|
||||
// PurgeTime it deletes the ai_runs rows created more than RunRetention
|
||||
// before the fire time with Purge, its period scheduler.Daily keyed
|
||||
// ai_runs_purge:YYYY-MM-DD. A purge caught up late deletes no row younger
|
||||
// than RunRetention; one that fails is the period's error and is not
|
||||
// retried, and the next day's purge deletes what it left.
|
||||
func PurgeSchedule(st *store.Store, logger *slog.Logger) (scheduler.Schedule, error) {
|
||||
if st == nil {
|
||||
return scheduler.Schedule{}, errors.New("harness: purge schedule: nil store")
|
||||
}
|
||||
if logger == nil {
|
||||
return scheduler.Schedule{}, errors.New("harness: purge schedule: nil logger")
|
||||
}
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.AIRunsPurge,
|
||||
Rule: func(household.Settings) scheduler.Rule {
|
||||
return scheduler.Daily(PurgeTime)
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
cutoff := p.FireAt.Add(-RunRetention)
|
||||
n, err := Purge(ctx, st, cutoff)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.InfoContext(ctx, "ai_runs purged", "period", p.Key, "deleted", n, "created_before", cutoff.UTC().Format(time.RFC3339))
|
||||
return nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -308,7 +308,7 @@ func TestSendThreadsByWeek(t *testing.T) {
|
||||
ListReady(week, 19, 7),
|
||||
DayOfReminder(mustDate(t, "2026-10-18"), "01J9DINNER", "Tacos"),
|
||||
NewSuggestions("01J9BATCH", week, []string{"Pho"}),
|
||||
RotationReview("01J9DINNER", "Pho", mustDate(t, "2026-10-14")),
|
||||
RotationReview("01J9DINNER", "Pho", week),
|
||||
}
|
||||
for _, ev := range events {
|
||||
if err := svc.Send(ctx, notify.Message{ID: "n1", EventKey: ev.Key, Channel: notify.ChannelChat, Payload: payloadOf(ev), Attempt: 1}); err != nil {
|
||||
|
||||
@@ -107,17 +107,17 @@ func NewSuggestions(id string, week store.LocalDate, names []string) notify.Even
|
||||
}
|
||||
}
|
||||
|
||||
// RotationReview is the rotation_review event, keyed
|
||||
// rotation_review:{dinnerID}:{planDate}, of the Rotation check of the
|
||||
// dinner dinnerID, named name and tried on planDate: "How was Pho? Keep it
|
||||
// in rotation?". It opens Home, where the Rotation check card asks.
|
||||
func RotationReview(dinnerID, name string, planDate store.LocalDate) notify.Event {
|
||||
// RotationReview is the rotation_review event asking whether the dinner
|
||||
// dinnerID, named name and tried in the week starting on week, becomes a
|
||||
// Regular.
|
||||
func RotationReview(dinnerID, name string, week store.LocalDate) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.RotationReview,
|
||||
Key: string(notify.RotationReview) + ":" + dinnerID + ":" + planDate.String(),
|
||||
Title: "How was " + name + "? Keep it in rotation?",
|
||||
Key: string(notify.RotationReview) + ":" + dinnerID,
|
||||
Title: "Keep it in rotation?",
|
||||
Body: "You tried " + name + ". Make it a Regular?",
|
||||
URL: "/",
|
||||
Week: household.WeekStart(planDate),
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -137,13 +137,13 @@ func TestMessages(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "rotation review",
|
||||
ev: RotationReview("01J9DINNER", "Lemon orzo", mustDate(t, "2026-10-14")),
|
||||
key: "rotation_review:01J9DINNER:2026-10-14",
|
||||
ev: RotationReview("01J9DINNER", "Lemon orzo", week),
|
||||
key: "rotation_review:01J9DINNER",
|
||||
url: "/",
|
||||
week: "2026-10-12",
|
||||
title: "How was Lemon orzo? Keep it in rotation?",
|
||||
body: "",
|
||||
chatT: "How was Lemon orzo? Keep it in rotation? Open MealQuest →",
|
||||
title: "Keep it in rotation?",
|
||||
body: "You tried Lemon orzo. Make it a Regular?",
|
||||
chatT: "Keep it in rotation? You tried Lemon orzo. Make it a Regular? Open MealQuest →",
|
||||
},
|
||||
} {
|
||||
ev := tc.ev
|
||||
|
||||
@@ -27,7 +27,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -239,19 +238,6 @@ func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels fun
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChannelOn reports whether the preference of event type ev switches
|
||||
// channel ch on. A type with no preference row is an error.
|
||||
func (s *Service) ChannelOn(ctx context.Context, ev EventType, ch Channel) (bool, error) {
|
||||
pref, err := db.New(s.st.Reader).GetNotificationPref(ctx, string(ev))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, fmt.Errorf("notify: no notification preference for %s", ev)
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("notify: read notification preference for %s: %w", ev, err)
|
||||
}
|
||||
return slices.Contains(enabledChannels(pref), ch), nil
|
||||
}
|
||||
|
||||
// enabledChannels returns the channels pref switches on.
|
||||
func enabledChannels(pref db.NotificationPref) []Channel {
|
||||
var chs []Channel
|
||||
|
||||
@@ -6,11 +6,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"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/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
@@ -82,40 +79,12 @@ type Decided struct {
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
}
|
||||
|
||||
// RotationCheckSchedule is the rotation_check schedule: every day at the
|
||||
// household's review_time it runs MarkTried for the day before, so each
|
||||
// Quest log dinner planned yesterday is tried and the household is asked
|
||||
// "Keep it in rotation?". Its period is the fire date, claimed as
|
||||
// rotation_check:YYYY-MM-DD. It never makes a dinner a Regular.
|
||||
func (s *Service) RotationCheckSchedule() scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.RotationCheck,
|
||||
Rule: func(settings household.Settings) scheduler.Rule {
|
||||
return scheduler.Daily(settings.ReviewTime)
|
||||
},
|
||||
PeriodKey: func(p scheduler.Period) string {
|
||||
return scheduler.RotationCheck + ":" + p.Key
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
yesterday := addDays(p.Date, -1)
|
||||
n, err := s.MarkTried(ctx, yesterday)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.InfoContext(ctx, "Rotation check: Quest log dinners marked tried",
|
||||
"period", p.Key, "plan_date", yesterday.String(), "tried", n)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// MarkTried moves every dinner in the Quest log planned on date, the
|
||||
// household-local day just past, on a plan of any status, to tried, opens
|
||||
// a pending Rotation check of it for date and emits its rotation_review
|
||||
// (chat.RotationReview), all in one transaction. It is idempotent: a
|
||||
// dinner already checked for date is left as it is and not notified of
|
||||
// again, so a later run never moves one returned to the Quest log ("We
|
||||
// didn't make it") again. It returns how many dinners it moved.
|
||||
// household-local day just past, on a plan of any status, to tried and
|
||||
// opens a pending Rotation check of it for date. It is idempotent: a
|
||||
// dinner already checked for date is left as it is, so a later run never
|
||||
// moves one returned to the Quest log ("We didn't make it") again. It
|
||||
// returns how many dinners it moved.
|
||||
func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, error) {
|
||||
var n int
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
@@ -126,10 +95,6 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
|
||||
}
|
||||
now := store.Now().String()
|
||||
for _, id := range ids {
|
||||
d, err := q.GetDinner(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: read dinner %s: %w", id, err)
|
||||
}
|
||||
moved, err := q.MarkDinnerTried(ctx, db.MarkDinnerTriedParams{UpdatedAt: now, ID: id})
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: mark %s tried: %w", id, err)
|
||||
@@ -142,9 +107,6 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
|
||||
}); err != nil {
|
||||
return fmt.Errorf("plan: open Rotation check of %s for %s: %w", id, date, err)
|
||||
}
|
||||
if err := s.notifier.EmitTx(ctx, tx, chat.RotationReview(id, d.Name, date)); err != nil {
|
||||
return fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
}
|
||||
n = len(ids)
|
||||
return nil
|
||||
@@ -152,9 +114,6 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n > 0 {
|
||||
s.notifier.Wake()
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
)
|
||||
|
||||
// The tests in this file run plan's rotation_check schedule on the fake
|
||||
// clock against the real plan service and notification outbox. The plan of
|
||||
// the week of Monday Oct 12 (w42) has poolNames, all Regulars, Monday to
|
||||
// Sunday; a test that needs a Quest-log dinner swaps Lemon Orzo in for
|
||||
// Monday. The household's review time is the seeded 09:00, in Chicago.
|
||||
|
||||
// orzoDay is the Monday Lemon Orzo is planned on.
|
||||
var orzoDay = mustLocalDate("2026-10-12")
|
||||
|
||||
// orzoWeek plans w42 with the Quest-log dinner Lemon Orzo on Monday in
|
||||
// place of Beef Chili, and returns its id.
|
||||
func (p *planEnv) orzoWeek() string {
|
||||
p.e.t.Helper()
|
||||
id := p.questLog("Lemon Orzo", 30)
|
||||
p.planWeek(w42)
|
||||
if _, err := p.svc.Swap(context.Background(), w42, orzoDay.String(), id); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// rotationReviews returns the keys of the rotation_review notifications
|
||||
// stored, oldest first. The seeded preference sends rotation_review to push
|
||||
// only, so there is one row per event.
|
||||
func (e *env) rotationReviews() []string {
|
||||
e.t.Helper()
|
||||
var keys []string
|
||||
for _, r := range notifytest.Rows(e.t, e.st, notify.RotationReview) {
|
||||
if r.Channel != notify.ChannelPush {
|
||||
e.t.Fatalf("rotation_review row %+v, want push only", r)
|
||||
}
|
||||
keys = append(keys, r.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// status returns the status of the dinner name.
|
||||
func (p *planEnv) status(name string) string {
|
||||
p.e.t.Helper()
|
||||
var s string
|
||||
if err := p.e.st.Reader.QueryRow(`SELECT status FROM dinners WHERE id = ?`, p.ids[name]).Scan(&s); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// pendingReviews returns the plan dates of the pending Rotation checks of
|
||||
// the dinner name.
|
||||
func (p *planEnv) pendingReviews(name string) []string {
|
||||
p.e.t.Helper()
|
||||
rows, err := p.e.st.Reader.Query(`SELECT plan_date FROM rotation_reviews WHERE dinner_id = ? AND status = 'pending' ORDER BY plan_date`, p.ids[name])
|
||||
if err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var d string
|
||||
if err := rows.Scan(&d); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wantOrzoTried fails unless Lemon Orzo, id, is tried with one pending
|
||||
// Rotation check for orzoDay and exactly one rotation_review asking about
|
||||
// it, which opens Home.
|
||||
func (p *planEnv) wantOrzoTried(id string) {
|
||||
p.e.t.Helper()
|
||||
if got := p.status("Lemon Orzo"); got != "tried" {
|
||||
p.e.t.Errorf("Lemon Orzo status = %s, want tried", got)
|
||||
}
|
||||
wantKeys(p.e.t, "Lemon Orzo pending Rotation checks", p.pendingReviews("Lemon Orzo"), []string{orzoDay.String()})
|
||||
key := "rotation_review:" + id + ":" + orzoDay.String()
|
||||
wantKeys(p.e.t, "rotation_review", p.e.rotationReviews(), []string{key})
|
||||
got := notifytest.Rows(p.e.t, p.e.st, notify.RotationReview)[0].Payload
|
||||
want := notify.Payload{Event: notify.RotationReview, Title: "How was Lemon Orzo? Keep it in rotation?", URL: "/", Tag: key, Week: w42.String()}
|
||||
if got != want {
|
||||
p.e.t.Errorf("rotation_review = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRotationCheckMarksQuestLogDinnerTried: at review_time 09:00 the day
|
||||
// after a Quest-log dinner was planned, the schedule, period
|
||||
// rotation_check:YYYY-MM-DD, marks it tried, opens its Rotation check and
|
||||
// emits one rotation_review. It never makes the dinner a Regular.
|
||||
func TestRotationCheckMarksQuestLogDinnerTried(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
id := p.orzoWeek()
|
||||
|
||||
// Tuesday 08:00: the catch-up of Monday 09:00 checks Sunday, unplanned.
|
||||
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
|
||||
r.runUntil(at(2026, 10, 13, 8, 59))
|
||||
wantKeys(t, "before 09:00", e.rotationReviews(), nil)
|
||||
if got := p.status("Lemon Orzo"); got != "quest_log" {
|
||||
t.Fatalf("Lemon Orzo status before 09:00 = %s, want quest_log", got)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 13, 9, 0))
|
||||
p.wantOrzoTried(id)
|
||||
|
||||
// Days later, still tried: only the household's keep makes a Regular.
|
||||
r.runUntil(at(2026, 10, 16, 12, 0))
|
||||
p.wantOrzoTried(id)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{
|
||||
"rotation_check:2026-10-12", "rotation_check:2026-10-13", "rotation_check:2026-10-14",
|
||||
"rotation_check:2026-10-15", "rotation_check:2026-10-16",
|
||||
})
|
||||
}
|
||||
|
||||
// TestRotationCheckLeavesRegulars: a day planning only Regulars marks
|
||||
// nothing tried, opens no Rotation check and emits nothing.
|
||||
func TestRotationCheckLeavesRegulars(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.planWeek(w42)
|
||||
|
||||
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
|
||||
r.runUntil(at(2026, 10, 13, 9, 0))
|
||||
wantKeys(t, "rotation_review", e.rotationReviews(), nil)
|
||||
if got := p.status("Beef Chili"); got != "regular" {
|
||||
t.Errorf("Beef Chili status = %s, want regular", got)
|
||||
}
|
||||
wantKeys(t, "Beef Chili pending Rotation checks", p.pendingReviews("Beef Chili"), nil)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
|
||||
}
|
||||
|
||||
// TestRotationCheckNoDuplicateSameDay: a restart the same day finds the
|
||||
// period claimed, and MarkTried run again for the same day finds the
|
||||
// Rotation check open, so neither opens a second check or notification.
|
||||
func TestRotationCheckNoDuplicateSameDay(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.pool()
|
||||
p := e.plans(ids)
|
||||
id := p.orzoWeek()
|
||||
|
||||
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
|
||||
r.runUntil(at(2026, 10, 13, 9, 0))
|
||||
p.wantOrzoTried(id)
|
||||
r.stop()
|
||||
p.stop()
|
||||
|
||||
p2 := e.plans(ids)
|
||||
r2 := e.start(at(2026, 10, 13, 9, 30), p2.svc.RotationCheckSchedule())
|
||||
r2.runUntil(at(2026, 10, 13, 23, 59))
|
||||
p2.wantOrzoTried(id)
|
||||
|
||||
if n, err := p2.svc.MarkTried(context.Background(), orzoDay); err != nil || n != 0 {
|
||||
t.Fatalf("MarkTried(%s) again = %d, %v, want 0", orzoDay, n, err)
|
||||
}
|
||||
p2.wantOrzoTried(id)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
|
||||
}
|
||||
|
||||
// TestRotationCheckCatchUpAtBoot: started 23 hours after a missed 09:00,
|
||||
// the scheduler runs that day's Rotation check once at boot.
|
||||
func TestRotationCheckCatchUpAtBoot(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
id := p.orzoWeek()
|
||||
|
||||
e.start(at(2026, 10, 14, 8, 0), p.svc.RotationCheckSchedule())
|
||||
p.wantOrzoTried(id)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-13"})
|
||||
}
|
||||
|
||||
// TestRotationCheckFollowsReviewTimeWithoutRestart: a review_time change
|
||||
// takes effect in the running scheduler. Moved from 09:00 to 10:30, the
|
||||
// Rotation check does not run at 09:00 and runs at 10:30.
|
||||
func TestRotationCheckFollowsReviewTimeWithoutRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
id := p.orzoWeek()
|
||||
|
||||
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
|
||||
e.update(household.SettingsPatch{ReviewTime: ptr("10:30")})
|
||||
r.changed()
|
||||
r.runUntil(at(2026, 10, 13, 10, 29))
|
||||
wantKeys(t, "before 10:30", e.rotationReviews(), nil)
|
||||
if got := p.status("Lemon Orzo"); got != "quest_log" {
|
||||
t.Fatalf("Lemon Orzo status before 10:30 = %s, want quest_log", got)
|
||||
}
|
||||
r.runUntil(at(2026, 10, 13, 10, 30))
|
||||
p.wantOrzoTried(id)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
|
||||
}
|
||||
@@ -209,11 +209,6 @@ type Schedule struct {
|
||||
// colon ("start:2026-10-14"), so two schedules of one Name claim
|
||||
// different periods. Name and Key together are unique.
|
||||
Key string
|
||||
// PeriodKey, when set, gives the period key claimed for p in place of
|
||||
// the rule's, such as "list_ready:chat:2026-10-19" for the week p.Week;
|
||||
// p.Key is the rule's key. It must give each period its own key. A
|
||||
// schedule sets Key or PeriodKey, not both.
|
||||
PeriodKey func(p Period) string
|
||||
// Rule returns when the schedule fires under settings. It is called
|
||||
// again after every settings change.
|
||||
Rule func(settings household.Settings) Rule
|
||||
@@ -298,9 +293,6 @@ func (s *Scheduler) Register(sch Schedule) error {
|
||||
if strings.Contains(sch.Key, ":") {
|
||||
return fmt.Errorf("scheduler: register %s: key has a colon", sch.label())
|
||||
}
|
||||
if sch.Key != "" && sch.PeriodKey != nil {
|
||||
return fmt.Errorf("scheduler: register %s: both key and period key", sch.label())
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.started {
|
||||
@@ -570,17 +562,14 @@ func (s *Scheduler) retry(ctx context.Context, targets []target, now time.Time)
|
||||
func (s *Scheduler) handle(ctx context.Context, t target, now time.Time) bool {
|
||||
it, at, date := t.it, t.at, t.date
|
||||
period := Period{Key: it.rule.PeriodKey(date), FireAt: at, Date: date}
|
||||
if it.sch.Key != "" {
|
||||
period.Key = it.sch.Key + ":" + period.Key
|
||||
}
|
||||
if it.rule.NextWeek {
|
||||
period.Week = household.WeekStart(addDays(date, 7))
|
||||
} else {
|
||||
period.Week = household.WeekStart(date)
|
||||
}
|
||||
switch {
|
||||
case it.sch.Key != "":
|
||||
period.Key = it.sch.Key + ":" + period.Key
|
||||
case it.sch.PeriodKey != nil:
|
||||
period.Key = it.sch.PeriodKey(period)
|
||||
}
|
||||
log := s.logger.With("schedule", it.sch.Name, "period", period.Key, "fire_at", at.Format(time.RFC3339))
|
||||
|
||||
claimed, err := s.claim(ctx, it.sch.Name, period.Key, now)
|
||||
|
||||
@@ -722,7 +722,6 @@ func TestRegister(t *testing.T) {
|
||||
{Name: "hourly", Rule: reminder(&rec).Rule, Run: rec.run},
|
||||
{Name: scheduler.DayOfReminder, Run: rec.run},
|
||||
{Name: scheduler.DayOfReminder, Rule: reminder(&rec).Rule},
|
||||
{Name: scheduler.DayOfReminder, Key: "start", PeriodKey: func(p scheduler.Period) string { return p.Key }, Rule: reminder(&rec).Rule, Run: rec.run},
|
||||
}
|
||||
for _, sch := range bad {
|
||||
if err := s.Register(sch); err == nil {
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"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/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// The tests in this file run shopping's shopping_chat_autosend and
|
||||
// week_rollover schedules, and harness's ai_runs_purge, on the fake clock
|
||||
// against the real shopping service, Google Chat client (posting to a fake
|
||||
// webhook), notification preferences and ai_runs purge. The seeded
|
||||
// autosend is Saturday 08:00 in Chicago.
|
||||
|
||||
// w41 is the Monday of 2026-W41.
|
||||
var w41 = mustLocalDate("2026-10-05")
|
||||
|
||||
// shopEnv is a shopping service over a scheduler env's store. Its job
|
||||
// runner never starts, so Generate's title jobs stay queued and every
|
||||
// Supply run stays untitled until Freeze titles it, which fails.
|
||||
type shopEnv struct {
|
||||
e *env
|
||||
svc *shopping.Service
|
||||
chat *chattest.Chat
|
||||
cs *chat.Service
|
||||
plans *planEnv
|
||||
logs *syncBuffer
|
||||
}
|
||||
|
||||
// shopping builds a shopping service over e, with Regulars to plan from.
|
||||
func (e *env) shopping() *shopEnv {
|
||||
e.t.Helper()
|
||||
s := &shopEnv{e: e, chat: chattest.New(e.t), plans: e.plans(e.pool()), logs: &syncBuffer{}}
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
s.cs, err = chat.NewService(e.st, box, publicURL, s.chat.Transport())
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(s.logs, nil))
|
||||
runner, err := jobs.NewRunner(e.st, logger, 1)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
noTitle := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||
return skills.ArchiveTitle{}, errors.New("no AI in this test")
|
||||
}
|
||||
s.svc, err = shopping.NewService(e.st, noTitle, runner, notifytest.New(e.t, e.st), s.cs, logger)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// list plans the week starting weekStart and generates its Supply run.
|
||||
func (s *shopEnv) list(weekStart store.LocalDate) {
|
||||
s.e.t.Helper()
|
||||
s.plans.planWeek(weekStart)
|
||||
if _, err := s.svc.Generate(context.Background(), weekStart); err != nil {
|
||||
s.e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// webhook stores the fake Google Chat webhook.
|
||||
func (s *shopEnv) webhook() {
|
||||
s.e.t.Helper()
|
||||
if _, err := s.cs.Put(context.Background(), chattest.Webhook, "Family"); err != nil {
|
||||
s.e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// get returns the Supply run of the week starting weekStart.
|
||||
func (s *shopEnv) get(weekStart store.LocalDate) shopping.List {
|
||||
s.e.t.Helper()
|
||||
l, err := s.svc.Get(context.Background(), weekStart)
|
||||
if err != nil {
|
||||
s.e.t.Fatal(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// wantLogged fails unless the service logged want and logged no error.
|
||||
func (s *shopEnv) wantLogged(want string) {
|
||||
s.e.t.Helper()
|
||||
if logs := s.logs.String(); !strings.Contains(logs, want) || strings.Contains(logs, "level=ERROR") {
|
||||
s.e.t.Errorf("logs = %s, want %q and no error", logs, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatAutosendSendsComingWeek: at the seeded Saturday 08:00 the
|
||||
// schedule posts the coming week's Supply run, W43's, to Google Chat
|
||||
// through SendToChat, in that week's thread, and records sent_to_chat_at;
|
||||
// its period is claimed in schedule_runs as list_ready:chat:2026-10-19,
|
||||
// the coming week's start. It posts once.
|
||||
func TestChatAutosendSendsComingWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
|
||||
// Saturday 07:00: the previous Saturday's period is past the
|
||||
// catch-up window, so it is recorded as missed.
|
||||
r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule())
|
||||
r.runUntil(at(2026, 10, 17, 7, 59))
|
||||
if n := len(s.chat.Posts()); n != 0 || s.get(w43).SentToChatAt != nil {
|
||||
t.Fatalf("before 08:00: %d posts, sent_to_chat_at %v, want none", n, s.get(w43).SentToChatAt)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 17, 8, 0))
|
||||
posts := s.chat.Posts()
|
||||
if len(posts) != 1 {
|
||||
t.Fatalf("at 08:00: %d posts, want 1", len(posts))
|
||||
}
|
||||
if !strings.HasPrefix(posts[0].Text(), "Supply run · Week 43 · Oct 19 – 25") {
|
||||
t.Errorf("posted %q, want the W43 Supply run", posts[0].Text())
|
||||
}
|
||||
if got := posts[0].Query.Get("threadKey"); got != chat.ThreadKey(w43) {
|
||||
t.Errorf("posted in thread %q, want %q", got, chat.ThreadKey(w43))
|
||||
}
|
||||
if s.get(w43).SentToChatAt == nil {
|
||||
t.Error("sent_to_chat_at not recorded")
|
||||
}
|
||||
s.wantLogged("Supply run sent to Google Chat")
|
||||
s.wantLogged("period=list_ready:chat:2026-10-19")
|
||||
|
||||
r.runUntil(at(2026, 10, 24, 7, 0))
|
||||
if n := len(s.chat.Posts()); n != 1 {
|
||||
t.Errorf("a week on: %d posts, want 1", n)
|
||||
}
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"})
|
||||
}
|
||||
|
||||
// TestChatAutosendFollowsSettings: chat_autosend_dow and
|
||||
// chat_autosend_time move the send without a restart, here to Friday
|
||||
// 18:00, still for the coming week.
|
||||
func TestChatAutosendFollowsSettings(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
|
||||
r := e.start(at(2026, 10, 16, 12, 0), s.svc.ChatAutosendSchedule())
|
||||
e.update(household.SettingsPatch{ChatAutosendDow: ptr(5), ChatAutosendTime: ptr("18:00")})
|
||||
r.changed()
|
||||
r.runUntil(at(2026, 10, 16, 17, 59))
|
||||
if n := len(s.chat.Posts()); n != 0 {
|
||||
t.Fatalf("before Friday 18:00: %d posts, want 0", n)
|
||||
}
|
||||
r.runUntil(at(2026, 10, 16, 18, 0))
|
||||
if n := len(s.chat.Posts()); n != 1 || s.get(w43).SentToChatAt == nil {
|
||||
t.Fatalf("at Friday 18:00: %d posts, sent_to_chat_at %v, want 1 and set", n, s.get(w43).SentToChatAt)
|
||||
}
|
||||
r.runUntil(at(2026, 10, 17, 9, 0))
|
||||
if n := len(s.chat.Posts()); n != 1 {
|
||||
t.Errorf("Saturday: %d posts, want still 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatAutosendSkips: the schedule posts nothing, records no send and
|
||||
// fails nothing when the coming week's Supply run was already sent, when
|
||||
// the week has no Supply run, when list_ready is switched off for Google
|
||||
// Chat, and when no webhook is stored. Each period is still claimed and
|
||||
// the reason logged.
|
||||
func TestChatAutosendSkips(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(t *testing.T, s *shopEnv)
|
||||
// posts is how many posts the setup itself made.
|
||||
posts int
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "already sent",
|
||||
setup: func(t *testing.T, s *shopEnv) {
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
if _, err := s.svc.SendToChat(context.Background(), w43, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
posts: 1,
|
||||
reason: shopping.AutosendAlreadySent,
|
||||
},
|
||||
{
|
||||
name: "no list",
|
||||
setup: func(t *testing.T, s *shopEnv) {
|
||||
s.list(w42)
|
||||
s.webhook()
|
||||
},
|
||||
reason: shopping.AutosendNoList,
|
||||
},
|
||||
{
|
||||
name: "chat off for list_ready",
|
||||
setup: func(t *testing.T, s *shopEnv) {
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
if _, err := s.e.st.Writer.Exec(`UPDATE notification_prefs SET chat = 0 WHERE event = 'list_ready'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
reason: shopping.AutosendChatOff,
|
||||
},
|
||||
{
|
||||
name: "no webhook",
|
||||
setup: func(t *testing.T, s *shopEnv) {
|
||||
s.list(w43)
|
||||
},
|
||||
reason: shopping.AutosendNotConfigured,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
c.setup(t, s)
|
||||
var sentBefore *string
|
||||
if l, err := s.svc.Get(context.Background(), w43); err == nil {
|
||||
sentBefore = l.SentToChatAt
|
||||
}
|
||||
|
||||
r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule())
|
||||
r.runUntil(at(2026, 10, 17, 9, 0))
|
||||
if n := len(s.chat.Posts()); n != c.posts {
|
||||
t.Errorf("posts = %d, want %d", n, c.posts)
|
||||
}
|
||||
if l, err := s.svc.Get(context.Background(), w43); err == nil && !equalPtr(l.SentToChatAt, sentBefore) {
|
||||
t.Errorf("sent_to_chat_at = %v, want %v", l.SentToChatAt, sentBefore)
|
||||
}
|
||||
s.wantLogged("Supply run not sent to Google Chat: " + c.reason)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatAutosendFailureSurfaces: a send Google Chat refuses is the
|
||||
// period's error, logged as such; the period stays claimed and is not
|
||||
// retried, and sent_to_chat_at is not recorded.
|
||||
func TestChatAutosendFailureSurfaces(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
s.chat.SetStatus(500)
|
||||
|
||||
var logs syncBuffer
|
||||
sched, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(&logs, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := newFakeClock(at(2026, 10, 17, 7, 0))
|
||||
scheduler.SetClock(sched, clock.Now, clock.After)
|
||||
if err := sched.Register(s.svc.ChatAutosendSchedule()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
sched.Wait()
|
||||
})
|
||||
if err := sched.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := &running{t: t, clock: clock, stop: cancel}
|
||||
r.idle()
|
||||
r.runUntil(at(2026, 10, 17, 9, 0))
|
||||
|
||||
if n := len(s.chat.Posts()); n != 1 {
|
||||
t.Errorf("posts = %d, want the one refused", n)
|
||||
}
|
||||
if s.get(w43).SentToChatAt != nil {
|
||||
t.Error("sent_to_chat_at recorded for a refused send")
|
||||
}
|
||||
if got := logs.String(); !strings.Contains(got, "level=ERROR") || !strings.Contains(got, "scheduler: run failed") || !strings.Contains(got, "schedule=shopping_chat_autosend") {
|
||||
t.Errorf("scheduler logs = %s, want the refused send logged as a failed run", got)
|
||||
}
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"})
|
||||
}
|
||||
|
||||
func equalPtr(a, b *string) bool {
|
||||
return (a == nil && b == nil) || (a != nil && b != nil && *a == *b)
|
||||
}
|
||||
|
||||
// TestWeekRolloverFreezesPreviousWeek: at Monday 00:05 the schedule
|
||||
// freezes exactly the week that has just ended into the Supply archive,
|
||||
// not the week before it nor the new week; the next Monday freezes the
|
||||
// next week; a Monday whose previous week has no Supply run freezes
|
||||
// nothing and fails nothing. Its periods are week_rollover:YYYY-Www of the
|
||||
// Monday.
|
||||
func TestWeekRolloverFreezesPreviousWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
s.list(w41)
|
||||
s.list(w42)
|
||||
s.list(w43)
|
||||
|
||||
status := func() []string {
|
||||
return []string{s.get(w41).Status, s.get(w42).Status, s.get(w43).Status}
|
||||
}
|
||||
cur, arc := shopping.StatusCurrent, shopping.StatusArchived
|
||||
|
||||
// Sunday 23:00: last Monday's rollover is past the catch-up window and
|
||||
// recorded as missed, so W41 is never frozen by it.
|
||||
r := e.start(at(2026, 10, 18, 23, 0), s.svc.RolloverSchedule())
|
||||
r.runUntil(at(2026, 10, 19, 0, 4))
|
||||
wantKeys(t, "before Monday 00:05", status(), []string{cur, cur, cur})
|
||||
|
||||
r.runUntil(at(2026, 10, 19, 0, 5))
|
||||
wantKeys(t, "at Monday 00:05", status(), []string{cur, arc, cur})
|
||||
if s.get(w42).Title != nil {
|
||||
t.Errorf("W42 title = %q, want frozen untitled", *s.get(w42).Title)
|
||||
}
|
||||
if _, err := s.svc.Generate(context.Background(), w42); !errors.Is(err, shopping.ErrFrozen) {
|
||||
t.Errorf("generate W42 after the rollover: %v, want ErrFrozen", err)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 26, 0, 5))
|
||||
wantKeys(t, "next Monday", status(), []string{cur, arc, arc})
|
||||
|
||||
r.runUntil(at(2026, 11, 2, 0, 5))
|
||||
s.wantLogged("week rollover froze nothing: the week has no Supply run")
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.WeekRollover), []string{"2026-W42", "2026-W43", "2026-W44", "2026-W45"})
|
||||
}
|
||||
|
||||
// aiRun stores an ai_runs row created at created and returns its id.
|
||||
func (e *env) aiRun(created time.Time) string {
|
||||
e.t.Helper()
|
||||
id := store.NewID()
|
||||
ts := store.Timestamp{Time: created.UTC()}.String()
|
||||
if err := db.New(e.st.Writer).CreateAIRun(context.Background(), db.CreateAIRunParams{
|
||||
ID: id, Skill: "draft_week", SkillVersion: "1", Provider: "anthropic", Model: "claude-test",
|
||||
Temperature: 0.7, Status: "ok", RequestRedacted: "{}", ResponseRedacted: sql.NullString{String: "{}", Valid: true},
|
||||
CreatedAt: ts, UpdatedAt: ts,
|
||||
}); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// aiRuns returns the ids of the ai_runs rows, oldest first.
|
||||
func (e *env) aiRuns() []string {
|
||||
e.t.Helper()
|
||||
rows, err := e.st.Reader.Query(`SELECT id FROM ai_runs ORDER BY created_at`)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// TestAIRunsPurgeDaily: every day at 03:00 the schedule deletes the
|
||||
// ai_runs rows created more than 30 days before, and no younger row; its
|
||||
// periods are ai_runs_purge:YYYY-MM-DD.
|
||||
func TestAIRunsPurgeDaily(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.aiRun(at(2026, 9, 10, 12, 0)) // older than Sep 11 03:00
|
||||
edge := e.aiRun(at(2026, 9, 12, 2, 59))
|
||||
young := e.aiRun(at(2026, 9, 12, 3, 1))
|
||||
recent := e.aiRun(at(2026, 10, 11, 12, 0))
|
||||
|
||||
sch, err := harness.PurgeSchedule(e.st, slog.New(slog.NewTextHandler(&syncBuffer{}, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Monday 02:00: the catch-up of Sunday 03:00 purges before Sep 11 03:00.
|
||||
r := e.start(at(2026, 10, 12, 2, 0), sch)
|
||||
wantKeys(t, "after the catch-up", e.aiRuns(), []string{edge, young, recent})
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 2, 59))
|
||||
wantKeys(t, "before 03:00", e.aiRuns(), []string{edge, young, recent})
|
||||
r.runUntil(at(2026, 10, 12, 3, 0))
|
||||
wantKeys(t, "at 03:00", e.aiRuns(), []string{young, recent})
|
||||
r.runUntil(at(2026, 10, 13, 3, 0))
|
||||
wantKeys(t, "next day", e.aiRuns(), []string{recent})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.AIRunsPurge), []string{"2026-10-11", "2026-10-12", "2026-10-13"})
|
||||
}
|
||||
|
||||
// TestPurgeScheduleRejectsNil: the purge schedule needs a store and a
|
||||
// logger.
|
||||
func TestPurgeScheduleRejectsNil(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
if _, err := harness.PurgeSchedule(nil, slog.Default()); err == nil {
|
||||
t.Error("nil store accepted")
|
||||
}
|
||||
if _, err := harness.PurgeSchedule(e.st, nil); err == nil {
|
||||
t.Error("nil logger accepted")
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
@@ -40,17 +39,11 @@ const (
|
||||
// only when no job is running it. A job that runs after the freeze finds
|
||||
// the list titled, or frozen untitled, and stores nothing.
|
||||
func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error {
|
||||
return s.freeze(ctx, weekStart, s.now())
|
||||
}
|
||||
|
||||
// freeze is Freeze with the week ended by instant now rather than by the
|
||||
// service's clock.
|
||||
func (s *Service) freeze(ctx context.Context, weekStart store.LocalDate, now time.Time) error {
|
||||
if _, err := household.WeekDays(weekStart); err != nil {
|
||||
return fmt.Errorf("shopping: freeze: %w", err)
|
||||
}
|
||||
q := db.New(s.st.Writer)
|
||||
thisWeek, err := weekAt(ctx, q, now)
|
||||
thisWeek, err := s.thisWeek(ctx, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -80,9 +73,9 @@ func (s *Service) freeze(ctx context.Context, weekStart store.LocalDate, now tim
|
||||
}
|
||||
}
|
||||
// A list frozen meanwhile is left as it is: the freeze is done.
|
||||
stamp := store.Now().String()
|
||||
now := store.Now().String()
|
||||
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
|
||||
FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID,
|
||||
FrozenAt: sql.NullString{String: now, Valid: true}, UpdatedAt: now, ID: list.ID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
|
||||
}
|
||||
@@ -110,12 +103,6 @@ func freezeTitleJob(ctx context.Context, q *db.Queries, list db.ShoppingList) (T
|
||||
// thisWeek returns the Monday of the household's current week, in its
|
||||
// time zone, by the service's clock.
|
||||
func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate, error) {
|
||||
return weekAt(ctx, q, s.now())
|
||||
}
|
||||
|
||||
// weekAt returns the Monday of the household's week at instant t, in its
|
||||
// time zone.
|
||||
func weekAt(ctx context.Context, q *db.Queries, t time.Time) (store.LocalDate, error) {
|
||||
settings, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, fmt.Errorf("shopping: read household settings: %w", err)
|
||||
@@ -124,7 +111,7 @@ func weekAt(ctx context.Context, q *db.Queries, t time.Time) (store.LocalDate, e
|
||||
if err != nil {
|
||||
return store.LocalDate{}, fmt.Errorf("shopping: household settings: %w", err)
|
||||
}
|
||||
return household.WeekStart(household.Today(t, zone)), nil
|
||||
return household.WeekStart(household.Today(s.now(), zone)), nil
|
||||
}
|
||||
|
||||
// Period is one heading of the Supply archive and its posts, newest first.
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
package shopping
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// RolloverTime is when, every Monday in the household zone, week_rollover
|
||||
// freezes the week that has just ended. It is fixed, not a setting.
|
||||
const RolloverTime = "00:05"
|
||||
|
||||
// Reasons Autosend posts nothing.
|
||||
const (
|
||||
AutosendNotConfigured = "no Google Chat webhook is configured"
|
||||
AutosendChatOff = "list_ready is switched off for Google Chat"
|
||||
AutosendNoList = "the week has no Supply run"
|
||||
AutosendAlreadySent = "the Supply run was already sent to Google Chat"
|
||||
AutosendUnconfirmed = "a send of the Supply run to Google Chat is unconfirmed"
|
||||
)
|
||||
|
||||
// AutosendKey identifies the automatic send of the Supply run of the week
|
||||
// starting weekStart, list_ready:chat:YYYY-MM-DD: it is the
|
||||
// shopping_chat_autosend period key claimed in schedule_runs. The send
|
||||
// itself is recorded in the list's sent_to_chat_at.
|
||||
func AutosendKey(weekStart store.LocalDate) string {
|
||||
return string(notify.ListReady) + ":" + string(notify.ChannelChat) + ":" + weekStart.String()
|
||||
}
|
||||
|
||||
// ChatAutosendSchedule is the shopping_chat_autosend schedule: at the
|
||||
// household's chat_autosend_dow and chat_autosend_time it sends the Supply
|
||||
// run of the coming week, the week after the fire date's, to Google Chat
|
||||
// with Autosend. Its period is that week, scheduler.WeeklyForNextWeek's,
|
||||
// claimed under AutosendKey of the week's Monday. A send that fails is the
|
||||
// period's error and is not retried; the household can still send it from
|
||||
// the Supply run.
|
||||
func (s *Service) ChatAutosendSchedule() scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.ShoppingChatAutosend,
|
||||
Rule: func(settings household.Settings) scheduler.Rule {
|
||||
return scheduler.WeeklyForNextWeek(time.Weekday(settings.ChatAutosendDow), settings.ChatAutosendTime)
|
||||
},
|
||||
PeriodKey: func(p scheduler.Period) string {
|
||||
return AutosendKey(p.Week)
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
skipped, err := s.Autosend(ctx, p.Week)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log := s.logger.With("period", p.Key, "week_start", p.Week.String())
|
||||
if skipped != "" {
|
||||
log.InfoContext(ctx, "Supply run not sent to Google Chat: "+skipped)
|
||||
return nil
|
||||
}
|
||||
log.InfoContext(ctx, "Supply run sent to Google Chat")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Autosend sends the Supply run of the week starting weekStart to Google
|
||||
// Chat with SendToChat, as the household's send does, when a webhook is
|
||||
// stored, list_ready's Google Chat channel is switched on, and the week has
|
||||
// a Supply run not yet sent and with no send unconfirmed. Otherwise it
|
||||
// posts nothing and returns which of those reasons stopped it; it never
|
||||
// resends. A send that fails is SendToChat's error.
|
||||
func (s *Service) Autosend(ctx context.Context, weekStart store.LocalDate) (skipped string, err error) {
|
||||
settings, err := s.chat.Get(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shopping: autosend: %w", err)
|
||||
}
|
||||
if settings.URLMasked == nil {
|
||||
return AutosendNotConfigured, nil
|
||||
}
|
||||
on, err := s.notifier.ChannelOn(ctx, notify.ListReady, notify.ChannelChat)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shopping: autosend: %w", err)
|
||||
}
|
||||
if !on {
|
||||
return AutosendChatOff, nil
|
||||
}
|
||||
l, err := s.Get(ctx, weekStart)
|
||||
if errors.Is(err, ErrListNotFound) {
|
||||
return AutosendNoList, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch {
|
||||
case l.SentToChatAt != nil:
|
||||
return AutosendAlreadySent, nil
|
||||
case l.ChatSendUnconfirmedAt != nil:
|
||||
return AutosendUnconfirmed, nil
|
||||
}
|
||||
if _, err := s.SendToChat(ctx, weekStart, false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// RolloverSchedule is the week_rollover schedule: every Monday at
|
||||
// RolloverTime it freezes the Supply run of the week before the fire
|
||||
// date's into the Supply archive with Freeze, the week ending by the fire
|
||||
// time. Its period scheduler.Weekly keys week_rollover:YYYY-Www for the
|
||||
// fire date's week. A week with no Supply run has nothing to freeze, and
|
||||
// the period says so; a freeze that fails is the period's error and is not
|
||||
// retried.
|
||||
func (s *Service) RolloverSchedule() scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.WeekRollover,
|
||||
Rule: func(household.Settings) scheduler.Rule {
|
||||
return scheduler.Weekly(time.Monday, RolloverTime)
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
y, m, d := p.Week.Date()
|
||||
last := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
|
||||
err := s.freeze(ctx, last, p.FireAt)
|
||||
if errors.Is(err, ErrListNotFound) {
|
||||
s.logger.InfoContext(ctx, "week rollover froze nothing: the week has no Supply run",
|
||||
"period", p.Key, "week_start", last.String())
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user