Feature 19: Notifications - Web Push and Google Chat (#169)
CI / ci (push) Successful in 8m31s
CI / ci (push) Successful in 8m31s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #169.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user