2026-10-09 18:04:05 -04:00
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
2026-10-10 20:07:53 -04:00
if r . Key != "list_ready:2026-10-12" || p . URL != "/plan/shopping?week=2026-10-12" || p . Week != "2026-10-12" ||
2026-10-09 18:04:05 -04:00
p . Title != "This week's Supply run is ready" || p . Body != "3 items to buy for 2 dinners" {
2026-10-10 20:07:53 -04:00
t . Errorf ( "%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping?week=2026-10-12" , r . Channel , r )
2026-10-09 18:04:05 -04:00
}
}
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 )
}
}