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
+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)
}