Wire plan_ready, list_ready, new_suggestions and Send to Google Chat (Feature 19) (#167)
CI / ci (push) Successful in 7m2s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #167.
This commit is contained in:
2026-10-09 16:42:56 -04:00
committed by approver_agent
parent bbe031ec8d
commit 51e0213e92
45 changed files with 1911 additions and 275 deletions
+72 -8
View File
@@ -53,6 +53,9 @@ var (
// ErrUnreachable is wrapped by the error of a post Google Chat did not
// answer.
ErrUnreachable = errors.New("chat: Google Chat did not answer")
// ErrRefused is matched by the *RefusedError of a post Google Chat
// answered with a status other than 2xx.
ErrRefused = errors.New("chat: Google Chat refused the message")
// ErrWebhookChanged is returned by Test when the webhook was replaced
// while the test message was being sent; nothing is recorded.
ErrWebhookChanged = errors.New("chat: the Google Chat webhook changed during the test")
@@ -78,6 +81,24 @@ type TestResult struct {
Settings
}
// RefusedError is a post Google Chat answered with Status, not 2xx. It
// matches ErrRefused.
type RefusedError struct {
Status int
}
func (e *RefusedError) Error() string {
return fmt.Sprintf("%v with status %d", ErrRefused, e.Status)
}
func (e *RefusedError) Unwrap() error { return ErrRefused }
// Retryable reports whether posting again may succeed: Google Chat was
// busy (429) or failing (5xx), rather than refusing the message.
func (e *RefusedError) Retryable() bool {
return e.Status == http.StatusTooManyRequests || e.Status >= 500
}
// Problem is one invalid field of a webhook update, named by its JSON key.
type Problem struct {
Field string
@@ -362,22 +383,65 @@ func (s *Service) messageOf(p notify.Payload) (message, error) {
if !carded[p.Event] {
return msg, nil
}
ref, err := url.Parse(p.URL)
if err != nil || ref.Scheme != "" || ref.Host != "" || !strings.HasPrefix(p.URL, "/") {
return message{}, fmt.Errorf("payload url %q is not an app path", p.URL)
c, err := s.openCard(string(p.Event), p.Title, p.Body, p.URL)
if err != nil {
return message{}, err
}
msg.CardsV2 = []cardV2{c}
return msg, nil
}
// openCard is a card headed title and subtitle whose "Open" button links
// to the app path under the public URL.
func (s *Service) openCard(id, title, subtitle, path string) (cardV2, error) {
ref, err := url.Parse(path)
if err != nil || ref.Scheme != "" || ref.Host != "" || !strings.HasPrefix(path, "/") {
return cardV2{}, fmt.Errorf("payload url %q is not an app path", path)
}
link := s.publicURL.ResolveReference(ref)
msg.CardsV2 = []cardV2{{
CardID: string(p.Event),
return cardV2{
CardID: id,
Card: card{
Header: cardHeader{Title: p.Title, Subtitle: p.Body},
Header: cardHeader{Title: title, Subtitle: subtitle},
Sections: []cardSection{{Widgets: []cardWidget{{ButtonList: buttonList{Buttons: []button{{
Text: "Open",
OnClick: onClick{OpenLink: openLink{URL: link.String()}},
}}}}}}},
},
}}
return msg, nil
}, nil
}
// PostSupplyRun posts the Supply run of the week starting on week, named
// name, with groups, to the stored webhook in the week's thread: its
// SupplyRunText and a card whose "Open" button links to that week's Supply
// run. No webhook stored is ErrNotConfigured; a post Google Chat does not
// answer wraps ErrUnreachable, and one it answers with a status other than
// 2xx is a *RefusedError.
func (s *Service) PostSupplyRun(ctx context.Context, week store.LocalDate, name string, groups []SupplyRunGroup) error {
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotConfigured
}
if err != nil {
return fmt.Errorf("chat: read chat settings: %w", err)
}
hook, err := s.open(row)
if err != nil {
return err
}
path := "/plan/shopping?" + url.Values{"week": {week.String()}}.Encode()
c, err := s.openCard("supply_run", "Supply run · "+name, "", path)
if err != nil {
return err
}
status, err := s.post(ctx, hook, message{Text: SupplyRunText(name, groups), CardsV2: []cardV2{c}}, ThreadKey(week))
if err != nil {
return err
}
if status < 200 || status >= 300 {
return &RefusedError{Status: status}
}
return nil
}
// post sends msg to hook, in thread when it is not empty, and returns the
+61 -3
View File
@@ -304,7 +304,7 @@ func TestSendThreadsByWeek(t *testing.T) {
}
week := mustDate(t, "2026-10-12")
events := []notify.Event{
PlanReady(week, []string{"Tacos", "Bolognese", "Tikka masala", "A", "B", "C", "D"}),
PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "A", "B", "C", "D"}),
ListReady(week, 19, 7),
DayOfReminder(mustDate(t, "2026-10-18"), "01J9DINNER", "Tacos"),
NewSuggestions("01J9BATCH", week, []string{"Pho"}),
@@ -383,7 +383,7 @@ func TestSendClassifiesStatus(t *testing.T) {
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
t.Fatal(err)
}
m := notify.Message{Payload: payloadOf(PlanReady(mustDate(t, "2026-10-12"), []string{"Tacos"}))}
m := notify.Message{Payload: payloadOf(PlanReady(mustDate(t, "2026-10-12"), mustDate(t, "2026-10-12"), []string{"Tacos"}))}
for _, tc := range []struct {
status int
ok, permanent bool
@@ -464,7 +464,7 @@ func TestOutboxRowsFollowClassification(t *testing.T) {
week := mustDate(t, "2026-10-12")
f.setStatus(http.StatusBadRequest)
planned := PlanReady(week, []string{"Tacos"})
planned := PlanReady(week, week, []string{"Tacos"})
if err := notifier.Emit(ctx, planned); err != nil {
t.Fatal(err)
}
@@ -528,3 +528,61 @@ func (b *syncBuffer) String() string {
defer b.mu.Unlock()
return b.buf.String()
}
func TestPostSupplyRun(t *testing.T) {
t.Parallel()
ctx := context.Background()
f := newFakeChat(t)
svc, _ := newTestService(t, f)
week := mustDate(t, "2026-10-12")
groups := []SupplyRunGroup{{Dinner: "Tacos", Items: []SupplyRunItem{{Name: "tortillas"}}}}
if err := svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups); !errors.Is(err, ErrNotConfigured) {
t.Fatalf("PostSupplyRun without a webhook = %v, want ErrNotConfigured", err)
}
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
t.Fatal(err)
}
if err := svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups); err != nil {
t.Fatal(err)
}
reqs := f.requests()
if len(reqs) != 1 {
t.Fatalf("Chat got %d requests, want 1", len(reqs))
}
r := reqs[0]
if r.Query.Get("threadKey") != "mealquest-week-2026-W42" || r.Query.Get("messageReplyOption") != replyOption {
t.Errorf("threading = %v", r.Query)
}
if want := SupplyRunText("Week 42 · Oct 12 – 18", groups); r.Body["text"] != want {
t.Errorf("text = %q, want %q", r.Body["text"], want)
}
data, _ := json.Marshal(r.Body["cardsV2"])
var cards []cardV2
if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 {
t.Fatalf("cardsV2 = %s, %v", data, err)
}
buttons := cards[0].Card.Sections[0].Widgets[0].ButtonList.Buttons
if cards[0].Card.Header.Title != "Supply run · Week 42 · Oct 12 – 18" || len(buttons) != 1 ||
buttons[0].OnClick.OpenLink.URL != "https://mealquest.example/plan/shopping?week=2026-10-12" {
t.Errorf("card = %s, want an Open button to the week's Supply run", data)
}
f.setStatus(http.StatusForbidden)
err := svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups)
if !errors.Is(err, ErrRefused) || !strings.Contains(err.Error(), "403") {
t.Errorf("PostSupplyRun answered 403 = %v, want ErrRefused with the status", err)
}
if err != nil {
assertNoSecret(t, "refused error", err.Error())
}
f.srv.Close()
err = svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups)
if !errors.Is(err, ErrUnreachable) {
t.Errorf("PostSupplyRun unanswered = %v, want ErrUnreachable", err)
}
if err != nil {
assertNoSecret(t, "unreachable error", err.Error())
}
}
+97
View File
@@ -0,0 +1,97 @@
// Package chattest provides a fake Google Chat incoming webhook for tests
// of the features that post to Google Chat: an httptest server that
// records each post and answers a status the test sets, reached through a
// transport that sends chat.googleapis.com requests to it.
package chattest
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
)
// Webhook is a valid Google Chat incoming webhook URL to store.
const Webhook = "https://chat.googleapis.com/v1/spaces/AAQA1b2C3d4/messages?key=AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI&token=q0Lz3kV9Yw8pXcRtN2bHsJdFgA7uEmKiOlP1nZxQ%3D"
// Post is one message the fake received.
type Post struct {
// Query is the request's query: key, token and any threading.
Query url.Values
// Body is the decoded JSON message.
Body map[string]any
}
// Text is the post's text.
func (p Post) Text() string {
s, _ := p.Body["text"].(string)
return s
}
// Chat is a fake Google Chat endpoint. It answers 200 until SetStatus.
type Chat struct {
srv *httptest.Server
mu sync.Mutex
status int
posts []Post
}
// New starts a fake Google Chat endpoint, closed when the test ends.
func New(t *testing.T) *Chat {
t.Helper()
c := &Chat{status: http.StatusOK}
c.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data, _ := io.ReadAll(r.Body)
var body map[string]any
if err := json.Unmarshal(data, &body); err != nil {
t.Errorf("Google Chat post body %q: %v", data, err)
}
c.mu.Lock()
c.posts = append(c.posts, Post{Query: r.URL.Query(), Body: body})
status := c.status
c.mu.Unlock()
w.WriteHeader(status)
}))
t.Cleanup(c.srv.Close)
return c
}
// Transport sends every request to the fake, as if it were
// chat.googleapis.com.
func (c *Chat) Transport() http.RoundTripper {
return transport{c}
}
type transport struct{ c *Chat }
func (tr transport) RoundTrip(req *http.Request) (*http.Response, error) {
target, err := url.Parse(tr.c.srv.URL)
if err != nil {
return nil, err
}
out := req.Clone(req.Context())
out.URL.Scheme, out.URL.Host = target.Scheme, target.Host
return http.DefaultTransport.RoundTrip(out)
}
// SetStatus makes the fake answer status from now on.
func (c *Chat) SetStatus(status int) {
c.mu.Lock()
defer c.mu.Unlock()
c.status = status
}
// Close stops the fake, so posts get no answer.
func (c *Chat) Close() {
c.srv.Close()
}
// Posts returns the messages received so far, in order.
func (c *Chat) Posts() []Post {
c.mu.Lock()
defer c.mu.Unlock()
return append([]Post(nil), c.posts...)
}
+74 -4
View File
@@ -3,6 +3,7 @@ package chat
import (
"fmt"
"strings"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
@@ -34,13 +35,16 @@ var actions = map[notify.EventType]string{
// button.
var carded = map[notify.EventType]bool{notify.PlanReady: true, notify.ListReady: true}
// PlanReady is the plan_ready event of the week starting on week, naming
// its dinners in plan order.
func PlanReady(week store.LocalDate, dinners []string) notify.Event {
// PlanReady is the plan_ready event of the week starting on week, drafted
// in the week starting on thisWeek, naming its dinners in plan order. Its
// title names the week as seen from thisWeek: "Next week's plan is ready",
// "This week's plan is ready", or "The plan for the week of Oct 26 is
// ready" for any other week.
func PlanReady(week, thisWeek store.LocalDate, dinners []string) notify.Event {
return notify.Event{
Type: notify.PlanReady,
Key: string(notify.PlanReady) + ":" + week.String(),
Title: "This week's plan is ready",
Title: planTitle(week, thisWeek),
Body: nameList(dinners),
URL: "/plan",
Week: week,
@@ -100,6 +104,72 @@ func RotationReview(dinnerID, name string, week store.LocalDate) notify.Event {
}
}
// SupplyRunItem is a Supply run item still needed, as sent to Google Chat;
// Qty is empty when the recipe gives none.
type SupplyRunItem struct {
Name string
Qty string
}
// SupplyRunGroup is one dinner of a Supply run sent to Google Chat, with
// its items still needed.
type SupplyRunGroup struct {
Dinner string
Items []SupplyRunItem
}
// SupplyRunText is the Google Chat message of the Supply run named name
// (its title, or its week label until it has one): each dinner with items
// still needed in bold, its items one per line, and the call to action.
//
// Supply run · Week 42 · Oct 12 – 18
//
// *Tacos*
// • ground beef — 1 lb
// • tortillas
//
// Open the Supply run →
//
// A Supply run with nothing still needed says so.
func SupplyRunText(name string, groups []SupplyRunGroup) string {
var b strings.Builder
b.WriteString("Supply run · " + name)
listed := false
for _, g := range groups {
if len(g.Items) == 0 {
continue
}
listed = true
b.WriteString("\n\n*" + g.Dinner + "*")
for _, it := range g.Items {
b.WriteString("\n• " + it.Name)
if it.Qty != "" {
b.WriteString(" — " + it.Qty)
}
}
}
if !listed {
b.WriteString("\n\nNothing left to buy.")
}
b.WriteString("\n\n" + actions[notify.ListReady] + " →")
return b.String()
}
// planTitle is the plan_ready title of the week starting on week, seen
// from the week starting on thisWeek.
func planTitle(week, thisWeek store.LocalDate) string {
y, m, d := thisWeek.Date()
next := store.LocalDateOf(time.Date(y, m, d+7, 0, 0, 0, 0, time.UTC))
switch week {
case thisWeek:
return "This week's plan is ready"
case next:
return "Next week's plan is ready"
}
_, m, d = week.Date()
return fmt.Sprintf("The plan for the week of %s %d is ready", m.String()[:3], d)
}
// Text is the Google Chat message of p: its title, its body after a colon
// and the event's call to action with an arrow.
func Text(p notify.Payload) string {
+39 -2
View File
@@ -36,7 +36,7 @@ func TestMessages(t *testing.T) {
}{
{
name: "plan ready",
ev: PlanReady(week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}),
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}),
key: "plan_ready:2026-10-12",
url: "/plan",
week: "2026-10-12",
@@ -46,7 +46,7 @@ func TestMessages(t *testing.T) {
},
{
name: "plan ready with three dinners",
ev: PlanReady(week, []string{"Tacos", "Bolognese", "Tikka masala"}),
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala"}),
key: "plan_ready:2026-10-12",
url: "/plan",
week: "2026-10-12",
@@ -54,6 +54,26 @@ func TestMessages(t *testing.T) {
body: "Tacos, Bolognese, Tikka masala",
chatT: "This week's plan is ready: Tacos, Bolognese, Tikka masala. Open the plan →",
},
{
name: "plan ready for next week",
ev: PlanReady(week, mustDate(t, "2026-10-05"), []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo"}),
key: "plan_ready:2026-10-12",
url: "/plan",
week: "2026-10-12",
title: "Next week's plan is ready",
body: "Tacos, Bolognese, Tikka masala + 1 more",
chatT: "Next week's plan is ready: Tacos, Bolognese, Tikka masala + 1 more. Open the plan →",
},
{
name: "plan ready for a later week",
ev: PlanReady(mustDate(t, "2026-10-26"), mustDate(t, "2026-10-05"), []string{"Tacos"}),
key: "plan_ready:2026-10-26",
url: "/plan",
week: "2026-10-26",
title: "The plan for the week of Oct 26 is ready",
body: "Tacos",
chatT: "The plan for the week of Oct 26 is ready: Tacos. Open the plan →",
},
{
name: "list ready",
ev: ListReady(week, 19, 7),
@@ -115,6 +135,23 @@ func TestMessages(t *testing.T) {
}
}
func TestSupplyRunText(t *testing.T) {
t.Parallel()
got := SupplyRunText("Week 42 · Oct 12 – 18", []SupplyRunGroup{
{Dinner: "Tacos", Items: []SupplyRunItem{{Name: "ground beef", Qty: "1 lb"}, {Name: "tortillas"}}},
{Dinner: "Bolognese"},
{Dinner: "Pho", Items: []SupplyRunItem{{Name: "rice noodles", Qty: "200 g"}}},
})
want := "Supply run · Week 42 · Oct 12 – 18\n\n*Tacos*\n• ground beef — 1 lb\n• tortillas\n\n*Pho*\n• rice noodles — 200 g\n\nOpen the Supply run →"
if got != want {
t.Errorf("SupplyRunText =\n%s\nwant\n%s", got, want)
}
got = SupplyRunText("Cozy week", []SupplyRunGroup{{Dinner: "Tacos"}})
if want := "Supply run · Cozy week\n\nNothing left to buy.\n\nOpen the Supply run →"; got != want {
t.Errorf("SupplyRunText with nothing needed = %q, want %q", got, want)
}
}
func TestTextWithoutBody(t *testing.T) {
t.Parallel()
got := Text(notify.Payload{Event: notify.PlanReady, Title: "This week's plan is ready", URL: "/plan"})
+10 -9
View File
@@ -276,15 +276,16 @@ type ShoppingItem struct {
}
type ShoppingList struct {
ID string
WeekStart string
PlanID string
Title sql.NullString
Status string
SentToChatAt sql.NullString
FrozenAt sql.NullString
CreatedAt string
UpdatedAt string
ID string
WeekStart string
PlanID string
Title sql.NullString
Status string
SentToChatAt sql.NullString
FrozenAt sql.NullString
CreatedAt string
UpdatedAt string
ChatSendUnconfirmedAt sql.NullString
}
type Suggestion struct {
+11
View File
@@ -12,6 +12,10 @@ import (
type Querier interface {
// An unknown id returns sql.ErrNoRows.
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
// Records a send of list id to Google Chat begun at
// chat_send_unconfirmed_at, before it is posted, unless the list is frozen
// into the Supply archive.
BeginShoppingListChatSend(ctx context.Context, arg BeginShoppingListChatSendParams) (int64, error)
// Leases the pending notification due longest ago by moving its
// next_attempt_at to lease_until, and returns it. A claimed row is not due
// again until the lease ends, so no other claim takes it while it is being
@@ -26,6 +30,8 @@ type Querier interface {
// sets last_sync_error, keeping the chosen calendar. Affects no row when
// nothing is connected.
ClearIntegrationsGoogleToken(ctx context.Context, arg ClearIntegrationsGoogleTokenParams) (int64, error)
// Clears the send of list id begun, once Google Chat refused it.
ClearShoppingListChatSend(ctx context.Context, arg ClearShoppingListChatSendParams) (int64, error)
// Drops references to the runs created before the cutoff, so the purge
// can delete them.
ClearSuggestionBatchAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
@@ -143,6 +149,8 @@ type Querier interface {
GetShoppingList(ctx context.Context, weekStart string) (ShoppingList, error)
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error)
// Batch id. Returns sql.ErrNoRows when there is none.
GetSuggestionBatch(ctx context.Context, id string) (SuggestionBatch, error)
// The id of the job that runs batch batch_id.
GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error)
// The plan of the week starting week_start. Returns sql.ErrNoRows when
@@ -233,6 +241,9 @@ type Querier interface {
// Records a delivered notification; updated_at is when it was sent. The
// lease must still be the claim's.
MarkNotificationSent(ctx context.Context, arg MarkNotificationSentParams) (int64, error)
// Records list id sent to Google Chat at sent_to_chat_at, its send
// confirmed, unless it is frozen into the Supply archive.
MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error)
// Records an edit of plan id, raising its revision: a synced plan becomes
// changed, any other keeps its status.
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
+71 -3
View File
@@ -10,6 +10,49 @@ import (
"database/sql"
)
const beginShoppingListChatSend = `-- name: BeginShoppingListChatSend :execrows
UPDATE shopping_lists
SET chat_send_unconfirmed_at = ?, updated_at = ?
WHERE id = ? AND status = 'current' AND frozen_at IS NULL
`
type BeginShoppingListChatSendParams struct {
ChatSendUnconfirmedAt sql.NullString
UpdatedAt string
ID string
}
// Records a send of list id to Google Chat begun at
// chat_send_unconfirmed_at, before it is posted, unless the list is frozen
// into the Supply archive.
func (q *Queries) BeginShoppingListChatSend(ctx context.Context, arg BeginShoppingListChatSendParams) (int64, error) {
result, err := q.db.ExecContext(ctx, beginShoppingListChatSend, arg.ChatSendUnconfirmedAt, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const clearShoppingListChatSend = `-- name: ClearShoppingListChatSend :execrows
UPDATE shopping_lists
SET chat_send_unconfirmed_at = NULL, updated_at = ?
WHERE id = ?
`
type ClearShoppingListChatSendParams struct {
UpdatedAt string
ID string
}
// Clears the send of list id begun, once Google Chat refused it.
func (q *Queries) ClearShoppingListChatSend(ctx context.Context, arg ClearShoppingListChatSendParams) (int64, error) {
result, err := q.db.ExecContext(ctx, clearShoppingListChatSend, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const createShoppingGroup = `-- name: CreateShoppingGroup :exec
INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
@@ -135,7 +178,7 @@ func (q *Queries) FreezeShoppingList(ctx context.Context, arg FreezeShoppingList
}
const getShoppingItemList = `-- name: GetShoppingItemList :one
SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at
SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at, shopping_lists.chat_send_unconfirmed_at
FROM shopping_items
JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id
JOIN shopping_lists ON shopping_lists.id = shopping_groups.list_id
@@ -161,12 +204,13 @@ func (q *Queries) GetShoppingItemList(ctx context.Context, id string) (GetShoppi
&i.ShoppingList.FrozenAt,
&i.ShoppingList.CreatedAt,
&i.ShoppingList.UpdatedAt,
&i.ShoppingList.ChatSendUnconfirmedAt,
)
return i, err
}
const getShoppingList = `-- name: GetShoppingList :one
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at, chat_send_unconfirmed_at FROM shopping_lists
WHERE week_start = ?
`
@@ -185,6 +229,7 @@ func (q *Queries) GetShoppingList(ctx context.Context, weekStart string) (Shoppi
&i.FrozenAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ChatSendUnconfirmedAt,
)
return i, err
}
@@ -322,7 +367,7 @@ func (q *Queries) ListShoppingItems(ctx context.Context, listID string) ([]ListS
}
const listShoppingListsThrough = `-- name: ListShoppingListsThrough :many
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at, chat_send_unconfirmed_at FROM shopping_lists
WHERE week_start <= ?
ORDER BY week_start DESC
`
@@ -348,6 +393,7 @@ func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string
&i.FrozenAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ChatSendUnconfirmedAt,
); err != nil {
return nil, err
}
@@ -362,6 +408,28 @@ func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string
return items, nil
}
const markShoppingListSentToChat = `-- name: MarkShoppingListSentToChat :execrows
UPDATE shopping_lists
SET sent_to_chat_at = ?, chat_send_unconfirmed_at = NULL, updated_at = ?
WHERE id = ? AND status = 'current' AND frozen_at IS NULL
`
type MarkShoppingListSentToChatParams struct {
SentToChatAt sql.NullString
UpdatedAt string
ID string
}
// Records list id sent to Google Chat at sent_to_chat_at, its send
// confirmed, unless it is frozen into the Supply archive.
func (q *Queries) MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error) {
result, err := q.db.ExecContext(ctx, markShoppingListSentToChat, arg.SentToChatAt, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const moveShoppingGroup = `-- name: MoveShoppingGroup :exec
UPDATE shopping_groups
SET position = ?, updated_at = ?
+20
View File
@@ -259,6 +259,26 @@ func (q *Queries) GetSuggestion(ctx context.Context, id string) (GetSuggestionRo
return i, err
}
const getSuggestionBatch = `-- name: GetSuggestionBatch :one
SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches
WHERE id = ?
`
// Batch id. Returns sql.ErrNoRows when there is none.
func (q *Queries) GetSuggestionBatch(ctx context.Context, id string) (SuggestionBatch, error) {
row := q.db.QueryRowContext(ctx, getSuggestionBatch, id)
var i SuggestionBatch
err := row.Scan(
&i.ID,
&i.Trigger,
&i.Status,
&i.AiRunID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getSuggestionBatchJobID = `-- name: GetSuggestionBatchJobID :one
SELECT id FROM jobs
WHERE kind = ?1
+40 -34
View File
@@ -6,7 +6,8 @@
// row in notification_outbox keyed by (event key, channel). The event key is
// the caller's idempotency key, so emitting the same event twice stores
// nothing the second time and never touches a row already stored, whatever
// its status.
// its status. EmitTx does the same inside the caller's transaction, so an
// event is stored with the change it is about; the caller then calls Wake.
//
// The worker started by Start delivers due rows through the Sender
// registered for their channel. A delivered row is marked sent. A failed
@@ -142,6 +143,20 @@ func NewService(st *store.Store, logger *slog.Logger) (*Service, error) {
// row with ev.Key is left as it is. An invalid event is ErrInvalidEvent and
// stores nothing; a type with no preference row is an error.
func (s *Service) Emit(ctx context.Context, ev Event) error {
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
return s.EmitTx(ctx, tx, ev)
})
if err != nil {
return err
}
s.Wake()
return nil
}
// EmitTx stores ev's notifications as Emit does, in tx, so they are
// committed with the change the event is about or not at all. The caller
// calls Wake once tx has committed.
func (s *Service) EmitTx(ctx context.Context, tx *sql.Tx, ev Event) error {
if err := validate(ev); err != nil {
return err
}
@@ -154,42 +169,33 @@ func (s *Service) Emit(ctx context.Context, ev Event) error {
return fmt.Errorf("notify: encode payload of %s: %w", ev.Key, err)
}
var queued []Channel
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
pref, err := q.GetNotificationPref(ctx, string(ev.Type))
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("notify: no notification preference for %s", ev.Type)
}
if err != nil {
return fmt.Errorf("notify: read notification preference for %s: %w", ev.Type, err)
}
now := timestamp(s.now())
for _, ch := range enabledChannels(pref) {
n, err := q.InsertNotification(ctx, db.InsertNotificationParams{
ID: store.NewID(),
EventKey: ev.Key,
Channel: string(ch),
Payload: string(data),
NextAttemptAt: sql.NullString{String: now, Valid: true},
Now: now,
})
if err != nil {
return fmt.Errorf("notify: store %s notification %s: %w", ch, ev.Key, err)
}
if n == 1 {
queued = append(queued, ch)
}
}
return nil
})
q := db.New(tx)
pref, err := q.GetNotificationPref(ctx, string(ev.Type))
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("notify: no notification preference for %s", ev.Type)
}
if err != nil {
return err
return fmt.Errorf("notify: read notification preference for %s: %w", ev.Type, err)
}
s.logger.InfoContext(ctx, "notification emitted", "event_key", ev.Key, "queued", queued)
if len(queued) > 0 {
s.wakeWorker()
now := timestamp(s.now())
var queued []Channel
for _, ch := range enabledChannels(pref) {
n, err := q.InsertNotification(ctx, db.InsertNotificationParams{
ID: store.NewID(),
EventKey: ev.Key,
Channel: string(ch),
Payload: string(data),
NextAttemptAt: sql.NullString{String: now, Valid: true},
Now: now,
})
if err != nil {
return fmt.Errorf("notify: store %s notification %s: %w", ch, ev.Key, err)
}
if n == 1 {
queued = append(queued, ch)
}
}
s.logger.InfoContext(ctx, "notification stored in transaction", "event_key", ev.Key, "queued", queued)
return nil
}
+31
View File
@@ -753,3 +753,34 @@ func TestWorkerDeliversEmittedEvent(t *testing.T) {
t.Fatalf("%d deliveries, want 1", n)
}
}
// TestEmitTxCommitsWithTheCallersTransaction: EmitTx's rows are stored only
// when the caller's transaction commits.
func TestEmitTxCommitsWithTheCallersTransaction(t *testing.T) {
ctx := context.Background()
svc, st, _ := newTestService(t)
setPref(t, st, PlanReady, true, false)
const key = "plan_ready:2026-10-12"
rollback := errors.New("roll back")
err := st.WithTx(ctx, func(tx *sql.Tx) error {
if err := svc.EmitTx(ctx, tx, planReady(key)); err != nil {
return err
}
return rollback
})
if !errors.Is(err, rollback) {
t.Fatalf("WithTx = %v", err)
}
if rows := outbox(t, st, key); len(rows) != 0 {
t.Fatalf("rows after rollback = %v, want none", rows)
}
if err := st.WithTx(ctx, func(tx *sql.Tx) error { return svc.EmitTx(ctx, tx, planReady(key)) }); err != nil {
t.Fatal(err)
}
if r := onlyRow(t, st, key, ChannelPush); r.Status != "pending" {
t.Errorf("row after commit = %+v, want pending", r)
}
if err := st.WithTx(ctx, func(tx *sql.Tx) error { return svc.EmitTx(ctx, tx, Event{Type: PlanReady, Key: key}) }); !errors.Is(err, ErrInvalidEvent) {
t.Errorf("EmitTx of an invalid event = %v, want ErrInvalidEvent", err)
}
}
+65
View File
@@ -0,0 +1,65 @@
// Package notifytest builds notify services for tests of the features that
// emit notifications, and reads back what they stored.
package notifytest
import (
"encoding/json"
"io"
"log/slog"
"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/store"
)
// New returns a notify service over st, with st's notification preferences
// seeded, so every event type goes to push and plan_ready and list_ready to
// Google Chat too. Its worker is not started: what is emitted stays pending.
func New(t *testing.T, st *store.Store) *notify.Service {
t.Helper()
if err := household.SeedNotificationPrefs(t.Context(), st); err != nil {
t.Fatal(err)
}
svc, err := notify.NewService(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
return svc
}
// Row is one stored notification.
type Row struct {
Key string
Channel notify.Channel
Payload notify.Payload
}
// Rows returns every notification stored in st whose event is of type ev,
// oldest first.
func Rows(t *testing.T, st *store.Store, ev notify.EventType) []Row {
t.Helper()
rs, err := st.Reader.QueryContext(t.Context(),
`SELECT event_key, channel, payload FROM notification_outbox WHERE event_key LIKE ? ORDER BY created_at, channel`,
string(ev)+":%")
if err != nil {
t.Fatal(err)
}
defer rs.Close()
var out []Row
for rs.Next() {
var r Row
var payload string
if err := rs.Scan(&r.Key, &r.Channel, &payload); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(payload), &r.Payload); err != nil {
t.Fatal(err)
}
out = append(out, r)
}
if err := rs.Err(); err != nil {
t.Fatal(err)
}
return out
}
+3 -2
View File
@@ -104,8 +104,9 @@ func (s *Service) Wait() {
s.wg.Wait()
}
// wakeWorker gives the worker a token to look for due rows.
func (s *Service) wakeWorker() {
// Wake tells the worker to look for due rows, as after EmitTx's
// transaction commits.
func (s *Service) Wake() {
select {
case s.wake <- struct{}{}:
default: // the worker already has a token and will look again
+15 -1
View File
@@ -10,6 +10,7 @@ import (
"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/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
@@ -147,6 +148,12 @@ func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skill
// draft_week was given: a day whose dinner is no longer in it, or is in it
// as a Regular rather than the Quest log or the other way round, leaves
// everything as it is, failing with ReasonPoolChanged.
//
// A stored draft emits plan_ready, keyed by the week, in the same
// transaction, naming the drafted dinners Monday to Sunday. The key is the week's,
// so only the AI's first draft of a week notifies: a redraft (Rebuild week)
// stores no second notification. A manual swap never drafts, so never
// notifies.
func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) {
var planID string
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
@@ -156,8 +163,10 @@ func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.Loca
return fmt.Errorf("plan: list draft pool: %w", err)
}
pool := make(map[string]string, len(rows))
names := make(map[string]string, len(rows))
for _, d := range rows {
pool[d.ID] = d.Status
names[d.ID] = d.Name
}
for _, d := range week.Days {
status, ok := pool[d.DinnerID]
@@ -215,11 +224,16 @@ func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.Loca
return fmt.Errorf("plan: put %s of %s: %w", d.Date, planID, err)
}
}
return nil
planned := make([]string, len(week.Days))
for i, d := range week.Days {
planned[i] = names[d.DinnerID]
}
return s.notifier.EmitTx(ctx, tx, chat.PlanReady(weekStart, thisWeek, planned))
})
if err != nil {
return "", err
}
s.notifier.Wake()
return planID, nil
}
+33 -3
View File
@@ -22,10 +22,12 @@ import (
"time"
"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/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/store"
)
@@ -138,6 +140,7 @@ type Service struct {
st *store.Store
draft Drafter
cal Calendar
notifier *notify.Service
logger *slog.Logger
draftJob *jobs.Kind[DraftJob]
// publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links
@@ -146,11 +149,12 @@ type Service struct {
}
// NewService returns a service over st that drafts with draft, pushes to
// cal with links under publicURL, and registers the KindDraftWeek job on
// cal with links under publicURL, tells the household through notifier,
// which must be over st, and registers the KindDraftWeek job on
// runner, which must not have started yet. The kind is service-only:
// StartDraft, which keeps one draft in flight per week, is the only way to
// enqueue it.
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, publicURL *url.URL, logger *slog.Logger) (*Service, error) {
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, publicURL *url.URL, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("plan: nil store")
@@ -160,12 +164,14 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calenda
return nil, errors.New("plan: nil job runner")
case cal == nil:
return nil, errors.New("plan: nil calendar")
case notifier == nil:
return nil, errors.New("plan: nil notifier")
case publicURL == nil:
return nil, errors.New("plan: nil public URL")
case logger == nil:
return nil, errors.New("plan: nil logger")
}
s := &Service{st: st, draft: draft, cal: cal, logger: logger, publicURL: publicURL}
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, logger: logger, publicURL: publicURL}
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
if err != nil {
return nil, err
@@ -226,6 +232,30 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (p Plan, e
return readPlan(ctx, db.New(tx), weekStart)
}
// EmitDayOfReminder emits day_of_reminder for date, keyed by the date,
// naming the dinner planned on it, and reports whether it did: a date whose
// week has no plan emits nothing. The scheduler calls it at the
// household's reminder_time (Feature 20); emitting a date again stores no
// second notification.
func (s *Service) EmitDayOfReminder(ctx context.Context, date store.LocalDate) (bool, error) {
p, err := s.Get(ctx, household.WeekStart(date))
if errors.Is(err, ErrNotFound) {
return false, nil
}
if err != nil {
return false, err
}
for _, d := range p.Days {
if d.Date == date.String() {
if err := s.notifier.Emit(ctx, chat.DayOfReminder(date, d.Dinner.ID, d.Dinner.Name)); err != nil {
return false, fmt.Errorf("plan: %w", err)
}
return true, nil
}
}
return false, fmt.Errorf("plan: week %s has no day %s", p.WeekStart, date)
}
// readPlan reads the plan of the week starting weekStart through q, with
// each day's start_by from the household's dinner time and zone.
func readPlan(ctx context.Context, q *db.Queries, weekStart store.LocalDate) (Plan, error) {
+83 -1
View File
@@ -20,11 +20,14 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"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/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
"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/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -143,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, publicURL, logger)
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), publicURL, logger)
if err != nil {
t.Fatal(err)
}
@@ -352,6 +355,85 @@ func TestDraftCreatesPlan(t *testing.T) {
}
}
// TestDraftEmitsPlanReady: the AI's first draft of a week stores plan_ready
// keyed by the week, on push and Google Chat, naming the dinners Monday to
// Sunday; a redraft (Rebuild week) and a manual swap store nothing more.
func TestDraftEmitsPlanReady(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("", fullWeek...)
e.mustDraft(t, false)
zone, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatal(err)
}
want := chat.PlanReady(week, household.WeekStart(household.Today(time.Now(), zone)), fullWeek)
rows := notifytest.Rows(t, e.st, notify.PlanReady)
if len(rows) != 2 || rows[0].Channel != notify.ChannelChat || rows[1].Channel != notify.ChannelPush {
t.Fatalf("plan_ready rows = %+v, want one on chat and one on push", rows)
}
for _, r := range rows {
p := r.Payload
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan" || p.Week != "2026-10-12" ||
p.Title != want.Title || p.Body != "Beef Chili, Pot Roast, Pad Thai + 4 more" {
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan", r.Channel, r, want.Title)
}
}
reversed := slices.Clone(fullWeek)
slices.Reverse(reversed)
e.reply("", reversed...)
e.mustDraft(t, false)
e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"]); err != nil {
t.Fatal(err)
}
if again := notifytest.Rows(t, e.st, notify.PlanReady); !slices.Equal(again, rows) {
t.Errorf("plan_ready rows after a redraft and a swap = %+v, want the first draft's only", again)
}
}
// TestFailedDraftEmitsNothing: a draft that stores no plan notifies no one.
func TestFailedDraftEmitsNothing(t *testing.T) {
e := newEnv(t)
e.start(t)
e.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
if job := e.draft(t, false); job.Status != jobs.StatusFailed {
t.Fatalf("job %s, want failed", job.Status)
}
if rows := notifytest.Rows(t, e.st, notify.PlanReady); len(rows) != 0 {
t.Errorf("plan_ready rows = %+v, want none", rows)
}
}
// TestEmitDayOfReminder: the reminder of a planned date names its dinner,
// keyed by the date; a date with no plan emits nothing, and a date emitted
// again stores nothing more.
func TestEmitDayOfReminder(t *testing.T) {
e := newEnv(t)
e.start(t)
if ok, err := e.svc.EmitDayOfReminder(t.Context(), mustDate("2026-10-14")); ok || err != nil {
t.Fatalf("EmitDayOfReminder with no plan = %v, %v, want false", ok, err)
}
e.reply("", fullWeek...)
e.mustDraft(t, false)
for range 2 {
if ok, err := e.svc.EmitDayOfReminder(t.Context(), mustDate("2026-10-14")); !ok || err != nil {
t.Fatalf("EmitDayOfReminder = %v, %v, want true", ok, err)
}
}
rows := notifytest.Rows(t, e.st, notify.DayOfReminder)
if len(rows) != 1 {
t.Fatalf("day_of_reminder rows = %+v, want one on push", rows)
}
r := rows[0]
if r.Key != "day_of_reminder:2026-10-14" || r.Channel != notify.ChannelPush || r.Payload.Title != "Tonight's dinner" ||
r.Payload.Body != "Pad Thai" || r.Payload.URL != "/dinner/"+e.ids["Pad Thai"] || r.Payload.Week != "2026-10-12" {
t.Errorf("day_of_reminder row = %+v", r)
}
}
func TestStartByIsDinnerTimeMinusMinutesForCrockPot(t *testing.T) {
e := newEnv(t)
e.start(t)
+17 -4
View File
@@ -17,11 +17,14 @@ import (
"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/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
"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/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -44,6 +47,10 @@ type plansEnv struct {
// unconnected.
google *gcaltest.Google
gcal *gcal.Service
// chat is the fake Google Chat endpoint chatSvc posts to; no webhook
// is stored until a test stores one.
chat *chattest.Chat
chatSvc *chat.Service
// logs holds every log line the router writes.
logs *bytes.Buffer
}
@@ -85,15 +92,21 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
if err != nil {
t.Fatal(err)
}
svc, err := plan.NewService(st, fail, runner, googleSvc, pub, logger)
notifier := notifytest.New(t, st)
svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, pub, logger)
if err != nil {
t.Fatal(err)
}
shop, err := shopping.NewService(st, title, runner, logger)
fakeChat := chattest.New(t)
chatSvc, err := chat.NewService(st, newTestBox(t), pub, fakeChat.Transport())
if err != nil {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
shop, err := shopping.NewService(st, title, runner, notifier, chatSvc, logger)
if err != nil {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc,
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
@@ -129,7 +142,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
}
}
return plansEnv{h: h, st: st, svc: svc, shop: shop, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
google: google, gcal: googleSvc, logs: logs}
google: google, gcal: googleSvc, chat: fakeChat, chatSvc: chatSvc, logs: logs}
}
func (e plansEnv) setStatus(t *testing.T, status string) {
+1
View File
@@ -163,6 +163,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Get("/shopping/archive", getSupplyArchive(logger, shopSvc))
apiV1.Get("/shopping/{weekStart}", getSupplyRun(logger, shopSvc))
apiV1.Post("/shopping/{weekStart}/generate", postSupplyRunGenerate(logger, shopSvc))
apiV1.Post("/shopping/{weekStart}/send-chat", postSupplyRunSendChat(logger, shopSvc))
apiV1.Patch("/shopping/items/{id}", patchShoppingItem(logger, shopSvc))
apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc))
apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc))
+6 -4
View File
@@ -26,6 +26,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"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/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
@@ -186,7 +187,7 @@ func newTestSuggest(t *testing.T) *suggest.Service {
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
return skills.SideQuests{}, errors.New("no model in this test")
}
svc, err := suggest.NewService(st, g, fail, runner, logger)
svc, err := suggest.NewService(st, g, fail, runner, notifytest.New(t, st), logger)
if err != nil {
t.Fatal(err)
}
@@ -206,7 +207,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), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
if err != nil {
t.Fatal(err)
}
@@ -214,7 +215,8 @@ func newTestPlan(t *testing.T) *plan.Service {
}
// newTestShopping returns a shopping service over a migrated temp
// database, whose Supply runs stay untitled.
// database, whose Supply runs stay untitled and whose posts to Google Chat
// all fail.
func newTestShopping(t *testing.T) *shopping.Service {
t.Helper()
st := newTestStore(t)
@@ -223,7 +225,7 @@ func newTestShopping(t *testing.T) *shopping.Service {
if err != nil {
t.Fatal(err)
}
svc, err := shopping.NewService(st, untitled, runner, logger)
svc, err := shopping.NewService(st, untitled, runner, notifytest.New(t, st), newTestChat(t), logger)
if err != nil {
t.Fatal(err)
}
+68 -7
View File
@@ -9,6 +9,7 @@ import (
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -17,6 +18,9 @@ import (
// maxShoppingItemBody bounds a PATCH /shopping/items/{id} body.
const maxShoppingItemBody = 1 << 10
// maxSendChatBody bounds a POST /shopping/{weekStart}/send-chat body.
const maxSendChatBody = 1 << 10
// supplyRun is a Supply run as the API shows it. label is the week label,
// which stands in for title until the AI writes one.
type supplyRun struct {
@@ -25,6 +29,9 @@ type supplyRun struct {
Title *string `json:"title"`
Status string `json:"status"`
SentToChatAt *string `json:"sent_to_chat_at"`
// ChatSendUnconfirmedAt is when a send to Google Chat whose outcome is
// not known began.
ChatSendUnconfirmedAt *string `json:"chat_send_unconfirmed_at"`
shopping.Counts
Groups []supplyRunGroup `json:"groups"`
}
@@ -59,6 +66,12 @@ type generatedSupplyRun struct {
Skipped []shopping.SkippedDay `json:"skipped"`
}
// sendChatRequest is the POST /shopping/{weekStart}/send-chat body:
// resend posts a week already sent again.
type sendChatRequest struct {
Resend bool `json:"resend"`
}
// shoppingItemPatch is the PATCH /shopping/items/{id} body.
type shoppingItemPatch struct {
Needed *bool `json:"needed"`
@@ -120,6 +133,53 @@ func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.Hand
}
}
// postSupplyRunSendChat posts the week's Supply run to Google Chat and
// answers with it, sent_to_chat_at recorded. Its body is a JSON object,
// {} or {"resend": true}: a week already sent, or with a send
// unconfirmed, is answered as it is, without a second post, unless resend
// is true. No webhook stored is 409 not_configured; a send whose outcome is
// not known (Google Chat did not answer, or its acceptance could not be
// recorded) 502 chat_unconfirmed, not retryable, since retrying could post
// twice; and Google Chat refusing the message 502 chat_refused, retryable
// only for 429 or 5xx.
func postSupplyRunSendChat(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
if !ok {
return
}
body, err := decodePatch[sendChatRequest](http.MaxBytesReader(w, r.Body, maxSendChatBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
l, err := svc.SendToChat(r.Context(), week, body.Resend)
var refused *chat.RefusedError
switch {
case errors.Is(err, chat.ErrNotConfigured):
writeError(w, r, logger, http.StatusConflict, "not_configured", "no Google Chat webhook is configured; add one in Settings")
return
case errors.Is(err, shopping.ErrSendUnconfirmed):
logger.WarnContext(r.Context(), "send Supply run to Google Chat", "error", err)
writeAPIError(w, r, logger, http.StatusBadGateway, apiError{Code: "chat_unconfirmed", Message: shopping.ErrSendUnconfirmed.Error()})
return
case errors.As(err, &refused):
logger.WarnContext(r.Context(), "send Supply run to Google Chat", "error", err)
writeAPIError(w, r, logger, http.StatusBadGateway, apiError{Code: "chat_refused", Message: err.Error(), Retryable: refused.Retryable()})
return
case err != nil:
writeShoppingError(w, r, logger, "send Supply run to Google Chat", err)
return
}
out, err := supplyRunOf(week, l)
if err != nil {
writeShoppingError(w, r, logger, "send Supply run to Google Chat", err)
return
}
writeJSON(w, r, logger, http.StatusOK, out)
}
}
// patchShoppingItem marks a Supply run item needed or "have it" and
// answers with its Supply run's new counts.
func patchShoppingItem(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
@@ -232,13 +292,14 @@ func supplyRunOf(week store.LocalDate, l shopping.List) (supplyRun, error) {
}
}
return supplyRun{
WeekStart: l.WeekStart,
Label: label,
Title: l.Title,
Status: l.Status,
SentToChatAt: l.SentToChatAt,
Counts: l.Counts(),
Groups: groups,
WeekStart: l.WeekStart,
Label: label,
Title: l.Title,
Status: l.Status,
SentToChatAt: l.SentToChatAt,
ChatSendUnconfirmedAt: l.ChatSendUnconfirmedAt,
Counts: l.Counts(),
Groups: groups,
}, nil
}
+132 -9
View File
@@ -15,6 +15,7 @@ import (
"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/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/plan"
@@ -82,14 +83,15 @@ func (e plansEnv) archive(t *testing.T) {
// supplyRunJSON is a Supply run answer decoded with every field the API
// promises.
type supplyRunJSON struct {
WeekStart string `json:"weekStart"`
Label string `json:"label"`
Title *string `json:"title"`
Status string `json:"status"`
SentToChatAt *string `json:"sent_to_chat_at"`
NeededCount int `json:"needed_count"`
TotalCount int `json:"total_count"`
Groups []struct {
WeekStart string `json:"weekStart"`
Label string `json:"label"`
Title *string `json:"title"`
Status string `json:"status"`
SentToChatAt *string `json:"sent_to_chat_at"`
ChatSendUnconfirmedAt *string `json:"chat_send_unconfirmed_at"`
NeededCount int `json:"needed_count"`
TotalCount int `json:"total_count"`
Groups []struct {
Dinner struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -184,7 +186,7 @@ func TestSupplyRunShape(t *testing.T) {
if status != http.StatusOK {
t.Fatalf("generate = %d %v", status, raw)
}
wantTop := []string{"groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "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)
}
@@ -787,3 +789,124 @@ func TestSupplyArchive(t *testing.T) {
t.Errorf("frozen list = %s %d/%d %v, want archived 2/3 %v", frozen.Status, frozen.NeededCount, frozen.TotalCount, frozen.render(), wantLines)
}
}
// TestSupplyRunSendChat posts the week's Supply run to Google Chat once,
// records sent_to_chat_at, answers a second send with the status as it
// is, and posts again only with resend.
func TestSupplyRunSendChat(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
var l supplyRunJSON
callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l)
callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+l.itemID(t, "onion"), `{"needed":false}`, nil)
send := shoppingPath + "2026-10-12/send-chat"
if status, body := callShopping(t, e, http.MethodPost, send, `{}`, nil); status != http.StatusConflict || errorCode(body) != "not_configured" {
t.Fatalf("send with no webhook = %d %v, want 409 not_configured", status, body)
}
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
t.Fatal(err)
}
var sent supplyRunJSON
if status, body := callShopping(t, e, http.MethodPost, send, `{}`, &sent); status != http.StatusOK {
t.Fatalf("send = %d %v", status, body)
}
if sent.SentToChatAt == nil || sent.WeekStart != "2026-10-12" || !reflect.DeepEqual(sent.render(), []string{
"Monday 2026-10-12 Beef Chili: ground beef [1 lb] needed",
"Monday 2026-10-12 Beef Chili: kidney beans [2 cans] needed",
"Monday 2026-10-12 Beef Chili: onion [1] have it",
"Tuesday 2026-10-13 Regular 2026-10-13: spaghetti [500 g] needed",
"Tuesday 2026-10-13 Regular 2026-10-13: basil [null] needed",
}) {
t.Errorf("send answer = %+v, want the Supply run with sent_to_chat_at", sent)
}
posts := e.chat.Posts()
if len(posts) != 1 {
t.Fatalf("Google Chat got %d posts, want 1", len(posts))
}
want := "Supply run · Week 42 · Oct 12 – 18\n\n*Beef Chili*\n• ground beef — 1 lb\n• kidney beans — 2 cans\n\n" +
"*Regular 2026-10-13*\n• spaghetti — 500 g\n• basil\n\nOpen the Supply run →"
if posts[0].Text() != want {
t.Errorf("posted text = %q, want %q", posts[0].Text(), want)
}
var again supplyRunJSON
if status, body := callShopping(t, e, http.MethodPost, send, `{}`, &again); status != http.StatusOK || again.SentToChatAt == nil || *again.SentToChatAt != *sent.SentToChatAt {
t.Errorf("send again = %d %v, sent_to_chat_at %v, want 200 and %s", status, body, again.SentToChatAt, *sent.SentToChatAt)
}
if n := len(e.chat.Posts()); n != 1 {
t.Errorf("send again without resend posted: %d posts, want 1", n)
}
var got supplyRunJSON
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &got)
if got.SentToChatAt == nil || *got.SentToChatAt != *sent.SentToChatAt {
t.Errorf("GET sent_to_chat_at = %v, want %s", got.SentToChatAt, *sent.SentToChatAt)
}
var resent supplyRunJSON
if status, body := callShopping(t, e, http.MethodPost, send, `{"resend":true}`, &resent); status != http.StatusOK || resent.SentToChatAt == nil {
t.Fatalf("resend = %d %v", status, body)
}
if n := len(e.chat.Posts()); n != 2 {
t.Errorf("resend: %d posts, want 2", n)
}
}
func TestSupplyRunSendChatErrors(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name, path, body string
status int
code string
}{
{"no body", "2026-10-12/send-chat", "", http.StatusBadRequest, "invalid_request"},
{"resend not a boolean", "2026-10-12/send-chat", `{"resend":"yes"}`, http.StatusBadRequest, "invalid_request"},
{"unknown field", "2026-10-12/send-chat", `{"force":true}`, http.StatusBadRequest, "invalid_request"},
{"not a Monday", "2026-10-13/send-chat", `{}`, http.StatusBadRequest, "invalid_week"},
{"no list", "2026-10-19/send-chat", `{}`, http.StatusNotFound, "not_found"},
} {
status, body := callShopping(t, e, http.MethodPost, shoppingPath+tc.path, tc.body, nil)
if status != tc.status || errorCode(body) != tc.code {
t.Errorf("%s = %d %v, want %d %s", tc.name, status, body, tc.status, tc.code)
}
}
e.chat.SetStatus(http.StatusInternalServerError)
status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil)
if status != http.StatusBadGateway || errorCode(body) != "chat_refused" || body["error"].(map[string]any)["retryable"] != true {
t.Errorf("send Google Chat refuses = %d %v, want 502 chat_refused, retryable", status, body)
}
e.chat.SetStatus(http.StatusNotFound)
status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil)
if status != http.StatusBadGateway || errorCode(body) != "chat_refused" || body["error"].(map[string]any)["retryable"] != false {
t.Errorf("send Google Chat answers 404 = %d %v, want 502 chat_refused, not retryable", status, body)
}
var l supplyRunJSON
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
if l.SentToChatAt != nil || l.ChatSendUnconfirmedAt != nil {
t.Errorf("after refused sends sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, want both null", l.SentToChatAt, l.ChatSendUnconfirmedAt)
}
e.chat.Close()
status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil)
if status != http.StatusBadGateway || errorCode(body) != "chat_unconfirmed" || body["error"].(map[string]any)["retryable"] != false {
t.Errorf("send Google Chat does not answer = %d %v, want 502 chat_unconfirmed, not retryable", status, body)
}
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
if l.SentToChatAt != nil || l.ChatSendUnconfirmedAt == nil {
t.Errorf("after an unanswered send sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, want null and set", l.SentToChatAt, l.ChatSendUnconfirmedAt)
}
e.archive(t)
status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{"resend":true}`, nil)
if status != http.StatusConflict || errorCode(body) != "archived" {
t.Errorf("send an archived list = %d %v, want 409 archived", status, body)
}
}
+2 -1
View File
@@ -17,6 +17,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/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
@@ -40,7 +41,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
return skills.SideQuests{}, errors.New("no model in this test")
}
svc, err := suggest.NewService(st, g, fail, runner, logger)
svc, err := suggest.NewService(st, g, fail, runner, notifytest.New(t, st), logger)
if err != nil {
t.Fatal(err)
}
+106
View File
@@ -0,0 +1,106 @@
package shopping
import (
"context"
"database/sql"
"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/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// SendToChat posts the Supply run of the week starting weekStart to Google
// Chat (chat.PostSupplyRun): each dinner with its items still needed, and a
// link to the Supply run. It records sent_to_chat_at and returns the list.
//
// A week is sent once: a Supply run already sent, or with a send
// unconfirmed, is returned as it is, without posting again, unless resend
// is true. A week with no Supply run is ErrListNotFound and one frozen into
// the Supply archive ErrFrozen.
//
// The send is recorded as begun, in chat_send_unconfirmed_at, before it is
// posted, so a post whose outcome is lost is never taken for one never
// made. A post Google Chat refuses, or that is never made, returns
// chat.PostSupplyRun's error and clears it. A post Google Chat does not
// answer, or one it accepts that cannot then be recorded, is
// ErrSendUnconfirmed and leaves it, as does the process stopping mid-send.
func (s *Service) SendToChat(ctx context.Context, weekStart store.LocalDate, resend bool) (List, error) {
s.sending.Lock()
defer s.sending.Unlock()
l, err := s.Get(ctx, weekStart)
if err != nil {
return List{}, err
}
if l.Status != StatusCurrent {
return List{}, ErrFrozen
}
if (l.SentToChatAt != nil || l.ChatSendUnconfirmedAt != nil) && !resend {
return l, nil
}
name, err := household.WeekLabel(weekStart)
if err != nil {
return List{}, fmt.Errorf("shopping: send to Google Chat: %w", err)
}
if l.Title != nil {
name = *l.Title
}
groups := make([]chat.SupplyRunGroup, len(l.Groups))
for i, g := range l.Groups {
groups[i].Dinner = g.DinnerName
for _, it := range g.Items {
if !it.Needed {
continue
}
item := chat.SupplyRunItem{Name: it.Name}
if it.Qty != nil {
item.Qty = *it.Qty
}
groups[i].Items = append(groups[i].Items, item)
}
}
q := db.New(s.st.Writer)
n, err := q.BeginShoppingListChatSend(ctx, db.BeginShoppingListChatSendParams{
ChatSendUnconfirmedAt: sql.NullString{String: store.Now().String(), Valid: true},
UpdatedAt: store.Now().String(),
ID: l.ID,
})
if err != nil {
return List{}, fmt.Errorf("shopping: record send to Google Chat begun: %w", err)
}
if n != 1 {
return List{}, ErrFrozen
}
postErr := s.chat.PostSupplyRun(ctx, weekStart, name, groups)
// The post's outcome is known: record it even if the client has gone.
write, cancel := jobs.FinalWriteContext(ctx)
defer cancel()
if errors.Is(postErr, chat.ErrUnreachable) {
return List{}, fmt.Errorf("%w: %w", ErrSendUnconfirmed, postErr)
}
if postErr != nil {
if _, err := q.ClearShoppingListChatSend(write, db.ClearShoppingListChatSendParams{
UpdatedAt: store.Now().String(),
ID: l.ID,
}); err != nil {
return List{}, errors.Join(postErr, fmt.Errorf("shopping: clear refused send to Google Chat: %w", err))
}
return List{}, postErr
}
now := store.Now().String()
n, err = q.MarkShoppingListSentToChat(write, db.MarkShoppingListSentToChatParams{
SentToChatAt: sql.NullString{String: now, Valid: true},
UpdatedAt: now,
ID: l.ID,
})
if err != nil {
return List{}, fmt.Errorf("%w: record sent to Google Chat: %w", ErrSendUnconfirmed, err)
}
if n != 1 {
return List{}, ErrFrozen
}
return s.Get(write, weekStart)
}
+242
View File
@@ -0,0 +1,242 @@
package shopping_test
import (
"errors"
"net/http"
"slices"
"testing"
"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/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
)
// TestGenerateEmitsListReadyOnce: the week's first Supply run stores
// list_ready keyed by the week, on push and Google Chat; remaking it, and
// making it again after Push to Calendar, stores nothing more.
func TestGenerateEmitsListReadyOnce(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
e.dinner(t, "Pot Roast")
e.plan(t, "Beef Chili", "Pad Thai", "Pot Roast")
if _, err := e.svc.Generate(t.Context(), mustDate("2026-10-19")); !errors.Is(err, shopping.ErrPlanNotFound) {
t.Fatalf("Generate of a week with no plan = %v", err)
}
if rows := notifytest.Rows(t, e.st, notify.ListReady); len(rows) != 0 {
t.Fatalf("list_ready rows after a failed generation = %+v, want none", rows)
}
e.generate(t)
rows := notifytest.Rows(t, e.st, notify.ListReady)
if len(rows) != 2 || rows[0].Channel != notify.ChannelChat || rows[1].Channel != notify.ChannelPush {
t.Fatalf("list_ready rows = %+v, want one on chat and one on push", rows)
}
for _, r := range rows {
p := r.Payload
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping" || p.Week != "2026-10-12" ||
p.Title != "This week's Supply run is ready" || p.Body != "3 items to buy for 2 dinners" {
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping", r.Channel, r)
}
}
e.swap(t, "2026-10-14", "Pad Thai")
e.swap(t, "2026-10-13", "Pot Roast")
e.generate(t)
if _, err := e.svc.GeneratePushed(t.Context(), week, planRevision(t, e)); err != nil {
t.Fatal(err)
}
if again := notifytest.Rows(t, e.st, notify.ListReady); !slices.Equal(again, rows) {
t.Errorf("list_ready rows after remaking = %+v, want the first list's only", again)
}
}
// planRevision is the revision of the env's plan.
func planRevision(t *testing.T, e *env) int64 {
t.Helper()
var rev int64
if err := e.st.Reader.QueryRow(`SELECT revision FROM week_plans WHERE id = ?`, e.planID).Scan(&rev); err != nil {
t.Fatal(err)
}
return rev
}
// TestSendToChat posts the Supply run's needed items by dinner, records
// sent_to_chat_at, sends a week once, and again only with resend.
func TestSendToChat(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
e.plan(t, "Beef Chili", "Pad Thai")
l := e.generate(t).List
e.haveIt(t, l, "Pad Thai", "rice noodles")
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
t.Fatal(err)
}
sent, err := e.svc.SendToChat(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
if sent.SentToChatAt == nil {
t.Fatal("sent_to_chat_at not recorded")
}
posts := e.chat.Posts()
if len(posts) != 1 {
t.Fatalf("Google Chat got %d posts, want 1", len(posts))
}
want := chat.SupplyRunText("Week 42 · Oct 12 – 18", []chat.SupplyRunGroup{
{Dinner: "Beef Chili", Items: []chat.SupplyRunItem{{Name: "ground beef", Qty: "1 lb"}, {Name: "onion", Qty: "1"}}},
{Dinner: "Pad Thai"},
})
if posts[0].Text() != want {
t.Errorf("posted text = %q, want %q", posts[0].Text(), want)
}
if posts[0].Query.Get("threadKey") != chat.ThreadKey(week) {
t.Errorf("posted in thread %q, want %q", posts[0].Query.Get("threadKey"), chat.ThreadKey(week))
}
if got, err := e.svc.Get(t.Context(), week); err != nil || got.SentToChatAt == nil || *got.SentToChatAt != *sent.SentToChatAt {
t.Errorf("stored sent_to_chat_at = %v, %v, want %s", got.SentToChatAt, err, *sent.SentToChatAt)
}
again, err := e.svc.SendToChat(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
if n := len(e.chat.Posts()); n != 1 || *again.SentToChatAt != *sent.SentToChatAt {
t.Errorf("send again without resend: %d posts, sent_to_chat_at %s, want 1 post and %s", n, *again.SentToChatAt, *sent.SentToChatAt)
}
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili and noodles'`); err != nil {
t.Fatal(err)
}
resent, err := e.svc.SendToChat(t.Context(), week, true)
if err != nil {
t.Fatal(err)
}
posts = e.chat.Posts()
if len(posts) != 2 || *resent.SentToChatAt < *sent.SentToChatAt {
t.Fatalf("resend: %d posts, sent_to_chat_at %s, want 2 posts and a later time than %s", len(posts), *resent.SentToChatAt, *sent.SentToChatAt)
}
if want := "Supply run · Chili and noodles"; posts[1].Text()[:len(want)] != want {
t.Errorf("resent text = %q, want it headed by the title", posts[1].Text())
}
}
// TestSendToChatFailuresRecordNothing: a send with no webhook, or one
// Google Chat refuses, leaves sent_to_chat_at and chat_send_unconfirmed_at
// unset; a week with no list, or a frozen one, is refused without posting.
func TestSendToChatFailuresRecordNothing(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Beef Chili")
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, shopping.ErrListNotFound) {
t.Errorf("SendToChat with no list = %v, want ErrListNotFound", err)
}
e.generate(t)
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrNotConfigured) {
t.Errorf("SendToChat with no webhook = %v, want chat.ErrNotConfigured", err)
}
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
t.Fatal(err)
}
e.chat.SetStatus(http.StatusInternalServerError)
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrRefused) {
t.Errorf("SendToChat answered 500 = %v, want chat.ErrRefused", err)
}
if l, err := e.svc.Get(t.Context(), week); err != nil || l.SentToChatAt != nil || l.ChatSendUnconfirmedAt != nil {
t.Errorf("after failed sends sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want both unset", l.SentToChatAt, l.ChatSendUnconfirmedAt, err)
}
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
t.Fatal(err)
}
posts := len(e.chat.Posts())
if _, err := e.svc.SendToChat(t.Context(), week, true); !errors.Is(err, shopping.ErrFrozen) {
t.Errorf("SendToChat of a frozen list = %v, want ErrFrozen", err)
}
if n := len(e.chat.Posts()); n != posts {
t.Errorf("a frozen list was posted")
}
}
// TestSendToChatUnconfirmed: a send whose outcome is not known, because
// Google Chat accepted the post but recording it failed, or did not answer,
// is ErrSendUnconfirmed and leaves the send unconfirmed rather than unsent:
// a send without resend does not post again, and one with resend does,
// confirming it.
func TestSendToChatUnconfirmed(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Beef Chili")
e.generate(t)
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.Exec(`CREATE TRIGGER refuse_sent BEFORE UPDATE OF sent_to_chat_at ON shopping_lists BEGIN SELECT RAISE(ABORT, 'sent refused'); END`); err != nil {
t.Fatal(err)
}
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, shopping.ErrSendUnconfirmed) {
t.Fatalf("SendToChat accepted but not recorded = %v, want ErrSendUnconfirmed", err)
}
if n := len(e.chat.Posts()); n != 1 {
t.Fatalf("Google Chat got %d posts, want 1", n)
}
l, err := e.svc.Get(t.Context(), week)
if err != nil || l.SentToChatAt != nil || l.ChatSendUnconfirmedAt == nil {
t.Fatalf("after an unrecorded send sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want unset and set", l.SentToChatAt, l.ChatSendUnconfirmedAt, err)
}
if _, err := e.st.Writer.Exec(`DROP TRIGGER refuse_sent`); err != nil {
t.Fatal(err)
}
again, err := e.svc.SendToChat(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
if n := len(e.chat.Posts()); n != 1 || again.ChatSendUnconfirmedAt == nil || *again.ChatSendUnconfirmedAt != *l.ChatSendUnconfirmedAt {
t.Errorf("send again without resend: %d posts, chat_send_unconfirmed_at %v, want 1 post and %s", n, again.ChatSendUnconfirmedAt, *l.ChatSendUnconfirmedAt)
}
resent, err := e.svc.SendToChat(t.Context(), week, true)
if err != nil {
t.Fatal(err)
}
if n := len(e.chat.Posts()); n != 2 || resent.SentToChatAt == nil || resent.ChatSendUnconfirmedAt != nil {
t.Errorf("resend: %d posts, sent_to_chat_at %v, chat_send_unconfirmed_at %v, want 2 posts, set and unset", n, resent.SentToChatAt, resent.ChatSendUnconfirmedAt)
}
e.chat.Close()
if _, err := e.svc.SendToChat(t.Context(), week, true); !errors.Is(err, shopping.ErrSendUnconfirmed) || !errors.Is(err, chat.ErrUnreachable) {
t.Errorf("SendToChat unanswered = %v, want ErrSendUnconfirmed and chat.ErrUnreachable", err)
}
if l, err := e.svc.Get(t.Context(), week); err != nil || l.SentToChatAt == nil || *l.SentToChatAt != *resent.SentToChatAt || l.ChatSendUnconfirmedAt == nil {
t.Errorf("after an unanswered resend sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want %s and set", l.SentToChatAt, l.ChatSendUnconfirmedAt, err, *resent.SentToChatAt)
}
}
// TestSendToChatPostsOnlyOnceBegun: a send that cannot be recorded as
// begun is not posted.
func TestSendToChatPostsOnlyOnceBegun(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Beef Chili")
e.generate(t)
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.Exec(`CREATE TRIGGER refuse_begin BEFORE UPDATE OF chat_send_unconfirmed_at ON shopping_lists WHEN NEW.chat_send_unconfirmed_at IS NOT NULL BEGIN SELECT RAISE(ABORT, 'begin refused'); END`); err != nil {
t.Fatal(err)
}
if _, err := e.svc.SendToChat(t.Context(), week, false); err == nil {
t.Fatal("SendToChat not recorded as begun succeeded, want an error")
}
if n := len(e.chat.Posts()); n != 0 {
t.Errorf("Google Chat got %d posts of a send not recorded as begun, want 0", n)
}
}
+49 -13
View File
@@ -27,10 +27,12 @@ import (
"time"
"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/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/store"
)
@@ -80,6 +82,12 @@ var ErrItemNotFound = errors.New("no Supply run has that item")
// the household's time zone.
var ErrWeekNotEnded = errors.New("the week has not ended")
// ErrSendUnconfirmed is a send to Google Chat whose outcome is not known:
// Google Chat did not answer, or accepted the post but recording it failed.
// The post may have been made, so the Supply run keeps the send as
// unconfirmed and is posted again only with resend.
var ErrSendUnconfirmed = errors.New("the shopping list may have reached Google Chat, but that could not be confirmed; check the space before sending again")
// ErrInvalidPeriod is an Archive grouping other than ByWeek, ByMonth and
// ByYear.
var ErrInvalidPeriod = errors.New("by must be week, month or year")
@@ -95,6 +103,8 @@ type Service struct {
st *store.Store
title Titler
titleJob *jobs.Kind[TitleJob]
notifier *notify.Service
chat *chat.Service
logger *slog.Logger
// now is the clock weeks are ended and current on; tests replace it.
now func() time.Time
@@ -104,12 +114,17 @@ type Service struct {
// waits for the title jobs in flight to store their titles or fail,
// and no title job runs archive_title while a freeze is under way.
titling sync.RWMutex
// sending serializes SendToChat, so a week is posted to Google Chat
// once however many sends race.
sending sync.Mutex
}
// NewService returns a service over st that titles Supply runs with title
// in KindTitleSupplyRun jobs, which it registers on runner, and logs to
// logger. runner must not have started yet.
func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
// in KindTitleSupplyRun jobs, which it registers on runner, tells the
// household through notifier, which must be over st, sends Supply runs to
// Google Chat through chatSvc, and logs to logger. runner must not have
// started yet.
func NewService(st *store.Store, title Titler, runner *jobs.Runner, notifier *notify.Service, chatSvc *chat.Service, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("shopping: nil store")
@@ -117,10 +132,14 @@ func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog
return nil, errors.New("shopping: nil titler")
case runner == nil:
return nil, errors.New("shopping: nil job runner")
case notifier == nil:
return nil, errors.New("shopping: nil notifier")
case chatSvc == nil:
return nil, errors.New("shopping: nil chat service")
case logger == nil:
return nil, errors.New("shopping: nil logger")
}
s := &Service{st: st, title: title, logger: logger, now: time.Now}
s := &Service{st: st, title: title, notifier: notifier, chat: chatSvc, logger: logger, now: time.Now}
kind, err := jobs.Register(runner, KindTitleSupplyRun, s.runTitleJob, ReasonTitleFailed)
if err != nil {
return nil, err
@@ -145,7 +164,11 @@ type List struct {
// SentToChatAt is when the list was sent to Google Chat; nil until it
// is.
SentToChatAt *string `json:"sent_to_chat_at"`
Groups []Group `json:"groups"`
// ChatSendUnconfirmedAt is when a send to Google Chat whose outcome is
// not known began: one under way, or one that may or may not have been
// posted. nil when there is none.
ChatSendUnconfirmedAt *string `json:"chat_send_unconfirmed_at"`
Groups []Group `json:"groups"`
}
// Counts are how many items of a Supply run are still needed and how many
@@ -272,6 +295,7 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check
return Generated{}, fmt.Errorf("shopping: generate: %w", err)
}
var out Generated
notified := false
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
plan, err := q.GetWeekPlan(ctx, weekStart.String())
@@ -293,8 +317,9 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("shopping: read list: %w", err)
}
created := err != nil
switch {
case err != nil: // no list yet
case created: // no list yet
list = db.ShoppingList{ID: store.NewID(), WeekStart: weekStart.String(), PlanID: plan.ID, Status: StatusCurrent}
err = q.CreateShoppingList(ctx, db.CreateShoppingListParams{
ID: list.ID, WeekStart: list.WeekStart, PlanID: list.PlanID, CreatedAt: now, UpdatedAt: now,
@@ -313,6 +338,13 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check
return err
}
out = Generated{List: listOf(list, groups), Skipped: skipped}
if created {
ev := chat.ListReady(weekStart, out.List.Counts().Needed, len(groups))
if err := s.notifier.EmitTx(ctx, tx, ev); err != nil {
return fmt.Errorf("shopping: %w", err)
}
notified = true
}
if list.Title.Valid {
return nil
}
@@ -331,6 +363,9 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check
if out.TitleJobID != nil {
s.titleJob.Wake()
}
if notified {
s.notifier.Wake()
}
return out, nil
}
@@ -538,13 +573,14 @@ func frozen(list db.ShoppingList) bool {
// listOf is list with groups.
func listOf(list db.ShoppingList, groups []Group) List {
return List{
ID: list.ID,
WeekStart: list.WeekStart,
PlanID: list.PlanID,
Title: nullString(list.Title),
Status: list.Status,
SentToChatAt: nullString(list.SentToChatAt),
Groups: groups,
ID: list.ID,
WeekStart: list.WeekStart,
PlanID: list.PlanID,
Title: nullString(list.Title),
Status: list.Status,
SentToChatAt: nullString(list.SentToChatAt),
ChatSendUnconfirmedAt: nullString(list.ChatSendUnconfirmedAt),
Groups: groups,
}
}
+23 -1
View File
@@ -3,11 +3,14 @@ package shopping_test
import (
"bytes"
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"io"
"log/slog"
"net/url"
"reflect"
"strings"
"sync"
@@ -17,10 +20,14 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"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/dinners"
"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/recipes/recipestest"
"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"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
@@ -61,6 +68,10 @@ type env struct {
beforeModel func(llm.Request)
// log holds what the service logged.
log *syncBuffer
// chat is the fake Google Chat endpoint chatSvc posts to; no webhook
// is stored until a test stores one.
chat *chattest.Chat
chatSvc *chat.Service
}
// syncBuffer is a bytes.Buffer safe for concurrent use.
@@ -122,7 +133,18 @@ func newEnv(t *testing.T) *env {
if err != nil {
t.Fatal(err)
}
e.svc, err = shopping.NewService(st, title, e.runner, logger)
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatal(err)
}
e.chat = chattest.New(t)
e.chatSvc, err = chat.NewService(st, box, &url.URL{Scheme: "https", Host: "meals.example.com"}, e.chat.Transport())
if err != nil {
t.Fatal(err)
}
e.svc, err = shopping.NewService(st, title, e.runner, notifytest.New(t, st), e.chatSvc, logger)
if err != nil {
t.Fatal(err)
}
+37 -4
View File
@@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", got)
}
}
@@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", got)
}
if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created")
@@ -889,3 +889,36 @@ func TestMigrateShoppingListUntitledRefusesUndatedGroup(t *testing.T) {
t.Error("shopping_groups_copy left behind")
}
}
// TestMigrateShoppingListChatSendUnconfirmed applies 0017 over a list
// already sent: it keeps every value, and the list has no send
// unconfirmed.
func TestMigrateShoppingListChatSendUnconfirmed(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
migrateBefore(t, s, "0017")
for _, stmt := range []string{
`INSERT INTO week_plans (id, week_start, status, created_at, updated_at)
VALUES ('plan-1', '2026-10-12', 'synced', 'c', 'u')`,
`INSERT INTO shopping_lists (id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at)
VALUES ('list-1', '2026-10-12', 'plan-1', 'Noodle week', 'current', 's', NULL, 'c1', 'u1')`,
} {
if _, err := s.Writer.Exec(stmt); err != nil {
t.Fatalf("seed: %v", err)
}
}
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
var got string
if err := s.Reader.QueryRowContext(ctx, `SELECT
id || '|' || title || '|' || status || '|' || sent_to_chat_at || '|' || COALESCE(chat_send_unconfirmed_at, 'NULL') || '|' || updated_at
FROM shopping_lists`).Scan(&got); err != nil {
t.Fatal(err)
}
if want := "list-1|Noodle week|current|s|NULL|u1"; got != want {
t.Errorf("shopping list after 0017 = %s, want %s", got, want)
}
}
+40 -6
View File
@@ -10,8 +10,10 @@ import (
"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/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/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -54,7 +56,18 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// recording the batch's end fails, so does the job; when it times out at
// 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
// 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
// notifies. See store.
func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) {
batch, err := db.New(s.st.Reader).GetSuggestionBatch(ctx, p.BatchID)
if err != nil {
return batchResult{}, fmt.Errorf("suggest: read batch %s: %w", p.BatchID, err)
}
n, err := db.New(s.st.Writer).StartSuggestionBatch(ctx, db.StartSuggestionBatchParams{
UpdatedAt: store.Now().String(),
ID: p.BatchID,
@@ -65,7 +78,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, publish)
res, err := s.fill(ctx, p.BatchID, batch.Trigger != TriggerReplenish, publish)
if err == nil {
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
return batchResult{}, err
@@ -86,8 +99,9 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
}
// fill does runBatch's work, returning a *jobs.Failure for a failure the
// household may see.
func (s *Service) fill(ctx context.Context, batchID string, publish func(batchResult) error) (batchResult, error) {
// 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) {
res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
if err := publish(res); err != nil {
return batchResult{}, err
@@ -108,6 +122,11 @@ func (s *Service) fill(ctx context.Context, batchID string, publish func(batchRe
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))]
@@ -117,7 +136,7 @@ func (s *Service) fill(ctx context.Context, batchID string, publish func(batchRe
return batchResult{}, err
}
res.Dropped += len(round) - len(grounded)
ids, err := s.store(ctx, batchID, grounded)
ids, err := s.store(ctx, batchID, week, grounded)
if err != nil {
return batchResult{}, err
}
@@ -204,8 +223,16 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
// repeats passed or a Rotation check dropped, or a Regular it cites renamed
// or archived. Passed names are read in the same transaction as the store,
// so a Pass or drop committed before it is never missed.
func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, error) {
//
// 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
// 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) {
ids := []string{}
var stored []string
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
existing, err := q.ListUnarchivedDinners(ctx)
@@ -269,12 +296,19 @@ func (s *Service) store(ctx context.Context, batchID string, quests []groundedQu
}
names = append(names, d.Name)
ids = append(ids, id)
stored = append(stored, d.Name)
}
return nil
if week == nil || len(stored) == 0 {
return nil
}
return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(batchID, *week, stored))
})
if err != nil {
return nil, err
}
if week != nil && len(stored) > 0 {
s.notifier.Wake()
}
return ids, nil
}
+9 -4
View File
@@ -20,6 +20,7 @@ import (
"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/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
@@ -109,14 +110,16 @@ type Service struct {
st *store.Store
grounder *recipes.Grounder
suggest Suggester
notifier *notify.Service
logger *slog.Logger
batchJob *jobs.Kind[BatchJob]
}
// NewService returns a service over st that suggests with suggest, grounds
// with grounder and registers the KindSuggestionBatch job on runner, which
// must not have started yet.
func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
// with grounder, tells the household through notifier, which must be over
// st, and registers the KindSuggestionBatch job on runner, which must not
// have started yet.
func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, runner *jobs.Runner, notifier *notify.Service, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("suggest: nil store")
@@ -126,10 +129,12 @@ func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester,
return nil, errors.New("suggest: nil suggester")
case runner == nil:
return nil, errors.New("suggest: nil job runner")
case notifier == nil:
return nil, errors.New("suggest: nil notifier")
case logger == nil:
return nil, errors.New("suggest: nil logger")
}
s := &Service{st: st, grounder: grounder, suggest: suggest, logger: logger}
s := &Service{st: st, grounder: grounder, suggest: suggest, notifier: notifier, logger: logger}
kind, err := jobs.Register(runner, KindSuggestionBatch, s.runBatch, Reasons...)
if err != nil {
return nil, err
+154 -3
View File
@@ -23,6 +23,8 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"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/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
@@ -121,7 +123,7 @@ func newEnv(t *testing.T) *env {
if err != nil {
t.Fatal(err)
}
e.svc, err = suggest.NewService(st, g, suggester, e.runner, logger)
e.svc, err = suggest.NewService(st, g, suggester, e.runner, notifytest.New(t, st), logger)
if err != nil {
t.Fatal(err)
}
@@ -401,6 +403,151 @@ 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.
func TestBatchEmitsNewSuggestions(t *testing.T) {
for _, tc := range []struct {
trigger string
grounds bool
notify bool
}{
{suggest.TriggerScheduled, true, true},
{suggest.TriggerManual, true, true},
{suggest.TriggerReplenish, true, false},
{suggest.TriggerManual, false, false},
} {
t.Run(fmt.Sprintf("%s grounds %v", tc.trigger, tc.grounds), func(t *testing.T) {
e := newEnv(t)
e.start(t)
if tc.grounds {
e.recipe(t, "Chicken Shawarma Bowls")
e.recipe(t, "Chili Verde")
}
e.reply(
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)
}
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 !tc.notify {
if len(rows) != 0 {
t.Errorf("new_suggestions rows = %+v, want none", rows)
}
return
}
if len(rows) != 1 {
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)
}
})
}
}
// TestBatchNotifiesAsSideQuestsAreStored: new_suggestions is stored with
// the first round to add Side quests, so a batch whose later round fails
// still tells the household of those in the deck, once; a later round that
// succeeds adds no second notification; a replenish batch notifies nothing.
func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
for _, tc := range []struct {
trigger string
lastFails bool
notify bool
}{
{suggest.TriggerScheduled, true, true},
{suggest.TriggerManual, true, true},
{suggest.TriggerManual, false, true},
{suggest.TriggerReplenish, true, false},
} {
t.Run(fmt.Sprintf("%s last round fails %v", tc.trigger, tc.lastFails), func(t *testing.T) {
e := newEnv(t)
e.start(t)
// The first round grounds BatchSize Side quests but B; the
// second round grounds F.
var quests []string
for i := range suggest.BatchSize + 1 {
name := fmt.Sprintf("Fajita Dish %c", 'A'+i)
e.search.Results[name] = nil
if i != 1 {
e.recipe(t, name)
}
quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas"))
}
e.reply(quests...)
if tc.lastFails {
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_f BEFORE INSERT ON dinners WHEN NEW.name = 'Fajita Dish F' BEGIN SELECT RAISE(ABORT, 'F refused'); END`); err != nil {
t.Fatal(err)
}
}
b, err := e.svc.StartBatch(t.Context(), tc.trigger)
if err != nil {
t.Fatal(err)
}
job := e.waitJob(t, b.JobID)
want, deck := jobs.StatusSucceeded, suggest.BatchSize
if tc.lastFails {
want, deck = jobs.StatusFailed, suggest.BatchSize-1
}
if job.Status != want {
t.Fatalf("batch job %s, want %s", job.Status, want)
}
if got := e.deckNames(t); len(got) != deck {
t.Fatalf("deck = %v, want %d Side quests", got, deck)
}
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
if !tc.notify {
if len(rows) != 0 {
t.Errorf("new_suggestions rows = %+v, want none", rows)
}
return
}
if len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID ||
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)
}
})
}
}
// TestBatchFailsWhenItsNotificationCannotBeStored: Side quests are stored
// only with their notification, so a notification that cannot be stored
// fails the batch with an empty deck rather than leaving Side quests the
// household is never told of.
func TestBatchFailsWhenItsNotificationCannotBeStored(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"))
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_outbox BEFORE INSERT ON notification_outbox BEGIN SELECT RAISE(ABORT, 'outbox refused'); END`); err != nil {
t.Fatal(err)
}
b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusFailed {
t.Fatalf("batch job %s, want failed", job.Status)
}
if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed {
t.Errorf("batch status = %s, want failed", got)
}
if got := e.deckNames(t); len(got) != 0 {
t.Errorf("deck = %v with no notification stored, want empty", got)
}
}
func TestBatchKeepsAtMostBatchSize(t *testing.T) {
e := newEnv(t)
e.start(t)
@@ -929,7 +1076,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{}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
if err != nil {
t.Fatal(err)
}
@@ -1151,6 +1298,10 @@ func TestBatchJobFailsWhenRecordingItsEndFails(t *testing.T) {
if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed {
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" {
t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want one naming Chili Verde", rows)
}
}
func TestBatchRecoversWhenRecordingItsEndAndItsJobsEndFail(t *testing.T) {
@@ -1284,7 +1435,7 @@ func TestBlockedBatchEndDoesNotHoldShutdown(t *testing.T) {
refuse := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
return skills.SideQuests{}, errors.New("not wanted")
}
svc, err := suggest.NewService(e.st, e.grounder, refuse, next, logger)
svc, err := suggest.NewService(e.st, e.grounder, refuse, next, notifytest.New(t, e.st), logger)
if err != nil {
t.Fatal(err)
}