Files
MealQuest/internal/shopping/notify_test.go
T
2026-10-09 18:04:05 -04:00

243 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}