Make new_suggestions and Send to Google Chat recoverable
CI / ci (push) Successful in 6m48s
CI / ci (pull_request) Successful in 6m26s

MQ-167-01: a scheduled or manual batch now emits new_suggestions:{batchId}
in the transaction that first stores its Side quests, so the cards and
their notification commit together. A later round that fails, or a
failure recording the batch's end, no longer loses the event; a later
round's emit is a no-op by key; a notification that cannot be stored
fails that store, leaving no unannounced cards. Replenish batches still
never notify. finishBatch goes back to recording status only.

MQ-167-02: migration 0017 adds shopping_lists.chat_send_unconfirmed_at.
SendToChat records the send as begun before posting, clears it with
sent_to_chat_at once Google Chat accepts, and clears it alone when Chat
refuses. A post Chat does not answer, or one it accepts that cannot be
recorded, is ErrSendUnconfirmed (502 chat_unconfirmed, not retryable)
and leaves the marker, as does a crash mid-send; a repeat without resend
never reposts, and resend posts again. The Supply run screen re-reads
the list after a failed send, shows an unconfirmed send, offers no Retry
for it, and sends again only with resend.

Task: f0cd9d50-9c11-41be-a945-7bae9f5f28a4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 16:26:19 -04:00
co-authored by Claude Opus 5.5
parent 45c4f8a803
commit 80a8dd0edf
23 changed files with 586 additions and 175 deletions
+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 {
+8 -2
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)
@@ -235,8 +241,8 @@ 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, unless it is
// frozen into the Supply archive.
// 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.
+52 -6
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
}
@@ -364,7 +410,7 @@ func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string
const markShoppingListSentToChat = `-- name: MarkShoppingListSentToChat :execrows
UPDATE shopping_lists
SET sent_to_chat_at = ?, updated_at = ?
SET sent_to_chat_at = ?, chat_send_unconfirmed_at = NULL, updated_at = ?
WHERE id = ? AND status = 'current' AND frozen_at IS NULL
`
@@ -374,8 +420,8 @@ type MarkShoppingListSentToChatParams struct {
ID string
}
// Records list id sent to Google Chat at sent_to_chat_at, unless it is
// frozen into the Supply archive.
// 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 {
+20 -14
View File
@@ -29,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"`
}
@@ -132,11 +135,13 @@ 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 is answered as it is,
// without a second post, unless resend is true. No webhook stored is 409
// not_configured; Google Chat not answering is 502 chat_unreachable,
// retryable, and refusing the message 502 chat_refused, retryable only for
// 429 or 5xx.
// {} 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)
@@ -154,9 +159,9 @@ func postSupplyRunSendChat(logger *slog.Logger, svc *shopping.Service) http.Hand
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, chat.ErrUnreachable):
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_unreachable", Message: "Google Chat did not answer", Retryable: true})
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)
@@ -287,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
}
+21 -16
View File
@@ -83,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"`
@@ -185,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)
}
@@ -888,15 +889,19 @@ func TestSupplyRunSendChatErrors(t *testing.T) {
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)
}
e.chat.Close()
status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil)
if status != http.StatusBadGateway || errorCode(body) != "chat_unreachable" {
t.Errorf("send Google Chat does not answer = %d %v, want 502 chat_unreachable", status, body)
}
var l supplyRunJSON
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
if l.SentToChatAt != nil {
t.Errorf("sent_to_chat_at after failed sends = %s, want null", *l.SentToChatAt)
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)
+40 -10
View File
@@ -3,6 +3,7 @@ package shopping
import (
"context"
"database/sql"
"errors"
"fmt"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
@@ -16,10 +17,17 @@ import (
// 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 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. A
// post that fails returns chat.PostSupplyRun's error and records nothing.
// 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()
@@ -30,7 +38,7 @@ func (s *Service) SendToChat(ctx context.Context, weekStart store.LocalDate, res
if l.Status != StatusCurrent {
return List{}, ErrFrozen
}
if l.SentToChatAt != nil && !resend {
if (l.SentToChatAt != nil || l.ChatSendUnconfirmedAt != nil) && !resend {
return l, nil
}
name, err := household.WeekLabel(weekStart)
@@ -54,20 +62,42 @@ func (s *Service) SendToChat(ctx context.Context, weekStart store.LocalDate, res
groups[i].Items = append(groups[i].Items, item)
}
}
if err := s.chat.PostSupplyRun(ctx, weekStart, name, groups); err != nil {
return List{}, err
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)
}
// The post is made: record it even if the client has gone.
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 := db.New(s.st.Writer).MarkShoppingListSentToChat(write, db.MarkShoppingListSentToChatParams{
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("shopping: record sent to Google Chat: %w", err)
return List{}, fmt.Errorf("%w: record sent to Google Chat: %w", ErrSendUnconfirmed, err)
}
if n != 1 {
return List{}, ErrFrozen
+81 -8
View File
@@ -127,8 +127,8 @@ func TestSendToChat(t *testing.T) {
}
// TestSendToChatFailuresRecordNothing: a send with no webhook, or one
// Google Chat refuses or does not answer, leaves sent_to_chat_at unset; a
// week with no list, or a frozen one, is refused without posting.
// 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"})
@@ -148,12 +148,8 @@ func TestSendToChatFailuresRecordNothing(t *testing.T) {
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrRefused) {
t.Errorf("SendToChat answered 500 = %v, want chat.ErrRefused", err)
}
e.chat.Close()
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrUnreachable) {
t.Errorf("SendToChat unanswered = %v, want chat.ErrUnreachable", err)
}
if l, err := e.svc.Get(t.Context(), week); err != nil || l.SentToChatAt != nil {
t.Errorf("sent_to_chat_at after failed sends = %v, %v, want unset", l.SentToChatAt, 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 {
@@ -167,3 +163,80 @@ func TestSendToChatFailuresRecordNothing(t *testing.T) {
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)
}
}
+19 -8
View File
@@ -82,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")
@@ -158,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
@@ -563,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,
}
}
+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)
}
}
+46 -44
View File
@@ -15,7 +15,6 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
@@ -58,12 +57,12 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// shutdown, the job is left running and the batch with it, for the next
// start to fail.
//
// A scheduled or manual batch that ends done having added Side quests to
// the deck emits new_suggestions, keyed by the batch, in the transaction
// that records it done. A replenish batch, which tops up the deck as the
// household swipes, never notifies. A notification that cannot be stored
// fails the done write with it, as any failure recording the batch's end
// does: the job fails, and its Side quests stay in the deck.
// 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 {
@@ -79,19 +78,14 @@ 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)
}
f, err := s.fill(ctx, p.BatchID, publish)
res, err := s.fill(ctx, p.BatchID, batch.Trigger != TriggerReplenish, publish)
if err == nil {
var ev *notify.Event
if batch.Trigger != TriggerReplenish && len(f.names) > 0 {
e := chat.NewSuggestions(p.BatchID, household.WeekStart(f.today), f.names)
ev = &e
}
if err := s.finishBatch(ctx, p.BatchID, BatchDone, ev); err != nil {
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
return batchResult{}, err
}
return f.res, nil
return res, nil
}
finishErr := s.finishBatch(ctx, p.BatchID, BatchFailed, nil)
finishErr := s.finishBatch(ctx, p.BatchID, BatchFailed)
if ctx.Err() != nil {
return batchResult{}, errors.Join(ctx.Err(), finishErr)
}
@@ -104,60 +98,56 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
return batchResult{}, errors.Join(err, finishErr)
}
// filled is what fill did: the batch's result, the names of the Side
// quests it added to the deck, and the household's date it ran on.
type filled struct {
res batchResult
names []string
today store.LocalDate
}
// 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) (filled, 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 filled{}, err
return batchResult{}, err
}
in, err := s.request(ctx)
if err != nil {
return filled{}, err
return batchResult{}, err
}
out, err := s.suggest(ctx, batchID, in)
if err != nil {
if ctx.Err() != nil {
return filled{}, ctx.Err()
return batchResult{}, ctx.Err()
}
return filled{}, &jobs.Failure{Code: ReasonAIFailed}
return batchResult{}, &jobs.Failure{Code: ReasonAIFailed}
}
res.Stage = StageGrounding
res.Dropped = len(out.Dropped)
if err := publish(res); err != nil {
return filled{}, err
return batchResult{}, err
}
var week *store.LocalDate
if notifies {
w := household.WeekStart(in.Context.Today)
week = &w
}
var names []string
left := out.Suggestions
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 {
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))]
left = left[len(round):]
grounded, err := s.ground(ctx, round)
if err != nil {
return filled{}, err
return batchResult{}, err
}
res.Dropped += len(round) - len(grounded)
ids, stored, err := s.store(ctx, batchID, grounded)
ids, err := s.store(ctx, batchID, week, grounded)
if err != nil {
return filled{}, err
return batchResult{}, err
}
res.Dropped += len(grounded) - len(ids)
res.SuggestionIDs = append(res.SuggestionIDs, ids...)
names = append(names, stored...)
if err := publish(res); err != nil {
return filled{}, err
return batchResult{}, err
}
}
res.Stage = StageDone
return filled{res: res, names: names, today: in.Context.Today}, nil
return res, nil
}
// request reads what suggest_side_quests is given: the household context,
@@ -232,9 +222,15 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
// suggest_side_quests ran, a dinner it repeats was added, a suggestion it
// 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. The names stored
// are returned with the ids.
func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, []string, error) {
// so a Pass or drop committed before it is never missed.
//
// When week is not nil and a Side quest is stored, new_suggestions:{batchID}
// naming those stored is emitted in the same transaction, for the week
// starting week, so the Side quests and their notification commit together
// 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 {
@@ -302,12 +298,18 @@ func (s *Service) store(ctx context.Context, batchID string, quests []groundedQu
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, nil, err
return nil, err
}
return ids, stored, nil
if week != nil && len(stored) > 0 {
s.notifier.Wake()
}
return ids, nil
}
// citedIDs returns the ids of the Regulars named cited, false when one
+12 -24
View File
@@ -257,39 +257,27 @@ func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (B
return Batch{ID: row.ID, JobID: jobID, Trigger: row.Trigger, Status: row.Status, Created: true}, nil
}
// finishBatch records running batch id as status, and emits ev with it
// when ev is not nil, with jobs.FinalWriteContext: the write is made even
// when ctx has been cancelled, but a writer that stays busy cannot hold its
// worker past jobs.FinalWriteTimeout. Its job's handler returns any error, so the job
// finishBatch records running batch id as status, with
// jobs.FinalWriteContext: the write is made even when ctx has been
// cancelled, but a writer that stays busy cannot hold its worker past
// jobs.FinalWriteTimeout. Its job's handler returns any error, so the job
// never ends succeeded with the batch still running; the runner keeps only
// a code, so the error is logged too.
func (s *Service) finishBatch(ctx context.Context, id, status string, ev *notify.Event) error {
func (s *Service) finishBatch(ctx context.Context, id, status string) error {
write, cancel := jobs.FinalWriteContext(ctx)
defer cancel()
err := s.st.WithTx(write, func(tx *sql.Tx) error {
n, err := db.New(tx).FinishSuggestionBatch(write, db.FinishSuggestionBatchParams{
Status: status,
UpdatedAt: store.Now().String(),
ID: id,
})
if err != nil {
return err
}
if n != 1 {
return errors.New("batch is not running")
}
if ev == nil {
return nil
}
return s.notifier.EmitTx(write, tx, *ev)
n, err := db.New(s.st.Writer).FinishSuggestionBatch(write, db.FinishSuggestionBatchParams{
Status: status,
UpdatedAt: store.Now().String(),
ID: id,
})
if err == nil && n != 1 {
err = errors.New("batch is not running")
}
if err != nil {
s.logger.ErrorContext(ctx, "record suggestion batch status", "batch_id", id, "status", status, "error", err)
return fmt.Errorf("suggest: record batch %s %s: %w", id, status, err)
}
if ev != nil {
s.notifier.Wake()
}
return nil
}
+97 -2
View File
@@ -454,6 +454,100 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
}
}
// 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)
@@ -1204,8 +1298,9 @@ 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)
}
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 0 {
t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want none", rows)
// 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)
}
}