package chat import ( "bytes" "context" "crypto/rand" "database/sql" "encoding/base64" "encoding/json" "errors" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "strings" "sync" "testing" "time" "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/notify" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" ) // posted is one request the fake Chat endpoint received. type posted struct { Method, Host, Path string Query url.Values ContentType string Body map[string]any } // fakeChat is an httptest Google Chat endpoint answering status. type fakeChat struct { srv *httptest.Server mu sync.Mutex // status is what it answers. status int got []posted } func newFakeChat(t *testing.T) *fakeChat { t.Helper() f := &fakeChat{status: http.StatusOK} f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { data, _ := io.ReadAll(r.Body) var body map[string]any if err := json.Unmarshal(data, &body); err != nil { t.Errorf("Chat endpoint body %q: %v", data, err) } f.mu.Lock() f.got = append(f.got, posted{r.Method, r.Host, r.URL.Path, r.URL.Query(), r.Header.Get("Content-Type"), body}) status := f.status f.mu.Unlock() w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) w.Write([]byte(`{}`)) })) t.Cleanup(f.srv.Close) return f } func (f *fakeChat) setStatus(status int) { f.mu.Lock() defer f.mu.Unlock() f.status = status } func (f *fakeChat) requests() []posted { f.mu.Lock() defer f.mu.Unlock() return append([]posted(nil), f.got...) } // redirectTo sends every request to the server at target, keeping its path // and query, as if it were chat.googleapis.com. type redirectTo struct { target *url.URL } func (r redirectTo) RoundTrip(req *http.Request) (*http.Response, error) { out := req.Clone(req.Context()) out.URL.Scheme, out.URL.Host = r.target.Scheme, r.target.Host out.Host = req.URL.Host return http.DefaultTransport.RoundTrip(out) } func newTestStore(t *testing.T) *store.Store { t.Helper() ctx := context.Background() st, err := store.Open(ctx, t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { if err := st.Close(); err != nil { t.Error(err) } }) if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { t.Fatal(err) } return st } func newTestBox(t *testing.T) *secretbox.Box { t.Helper() key := make([]byte, secretbox.KeySize) rand.Read(key) box, err := secretbox.New(base64.StdEncoding.EncodeToString(key)) if err != nil { t.Fatal(err) } return box } // newTestService returns a service over a migrated temp database whose // requests go to f, with cards linking under https://mealquest.example. func newTestService(t *testing.T, f *fakeChat) (*Service, *store.Store) { t.Helper() st := newTestStore(t) target, err := url.Parse(f.srv.URL) if err != nil { t.Fatal(err) } pub, err := url.Parse("https://mealquest.example") if err != nil { t.Fatal(err) } svc, err := NewService(st, newTestBox(t), pub, redirectTo{target}) if err != nil { t.Fatal(err) } return svc, st } // assertNoSecret fails when s holds the webhook URL, its key or its token. func assertNoSecret(t *testing.T, what, s string) { t.Helper() tokenDecoded, _ := url.QueryUnescape(testToken) for _, secret := range []string{testHook, testKey, testToken, tokenDecoded, testKey[:12], testToken[:12]} { if strings.Contains(s, secret) { t.Errorf("%s holds the webhook URL or a credential: %s", what, s) return } } } func TestNewServiceRequiresDependencies(t *testing.T) { t.Parallel() st, box := newTestStore(t), newTestBox(t) pub, _ := url.Parse("https://mealquest.example") for name, build := range map[string]func() (*Service, error){ "store": func() (*Service, error) { return NewService(nil, box, pub, http.DefaultTransport) }, "box": func() (*Service, error) { return NewService(st, nil, pub, http.DefaultTransport) }, "public URL": func() (*Service, error) { return NewService(st, box, nil, http.DefaultTransport) }, "transport": func() (*Service, error) { return NewService(st, box, pub, nil) }, } { if _, err := build(); err == nil { t.Errorf("NewService without %s succeeded", name) } } } func TestPutGetStoresSealedWebhook(t *testing.T) { t.Parallel() ctx := context.Background() svc, st := newTestService(t, newFakeChat(t)) got, err := svc.Get(ctx) if err != nil || got.SpaceLabel != nil || got.URLMasked != nil || got.LastTestAt != nil { t.Fatalf("Get unconfigured = %+v, %v", got, err) } put, err := svc.Put(ctx, testHook, " Family dinner ") if err != nil { t.Fatal(err) } if *put.SpaceLabel != "Family dinner" || *put.URLMasked != "https://chat.googleapis.com/v1/spaces/"+testSpace+"/messages?key=…&token=…ZxQ=" || put.LastTestAt != nil || put.LastTestStatus != nil { t.Errorf("Put = %+v", put) } got, err = svc.Get(ctx) if err != nil || *got.URLMasked != *put.URLMasked || *got.SpaceLabel != "Family dinner" { t.Errorf("Get = %+v, %v", got, err) } row, err := db.New(st.Reader).GetChatSettings(ctx) if err != nil { t.Fatal(err) } if bytes.Contains(row.WebhookUrlCt, []byte(testKey)) || bytes.Contains(row.WebhookUrlCt, []byte("chat.googleapis.com")) { t.Errorf("webhook_url_ct holds the URL in plain text") } plain, err := svc.box.Decrypt(row.WebhookUrlCt) if err != nil || string(plain) != testHook { t.Errorf("webhook_url_ct decrypts to %q, %v", plain, err) } encoded, _ := json.Marshal(got) assertNoSecret(t, "GET settings", string(encoded)) } func TestPutRefusesInvalid(t *testing.T) { t.Parallel() ctx := context.Background() svc, st := newTestService(t, newFakeChat(t)) for _, tc := range []struct { url, label string fields []string }{ {"https://chat.googleapis.com.evil.example/v1/spaces/X/messages?key=" + testKey + "&token=" + testToken, "Family", []string{"url"}}, {testHook, " ", []string{"space_label"}}, {testHook, strings.Repeat("x", MaxSpaceLabelChars+1), []string{"space_label"}}, {testHook, "bad\nlabel", []string{"space_label"}}, {"http://chat.googleapis.com/v1/spaces/X/messages?key=" + testKey + "&token=" + testToken, "", []string{"url", "space_label"}}, } { _, err := svc.Put(ctx, tc.url, tc.label) var invalid *ValidationError if !errors.As(err, &invalid) || len(invalid.Problems) != len(tc.fields) { t.Errorf("Put(%q, %q) = %v, want problems with %v", tc.url, tc.label, err, tc.fields) continue } for i, p := range invalid.Problems { if p.Field != tc.fields[i] { t.Errorf("Put(%q, %q) problem %d = %s, want %s", tc.url, tc.label, i, p.Field, tc.fields[i]) } } assertNoSecret(t, "validation error", err.Error()) } if _, err := db.New(st.Reader).GetChatSettings(ctx); !errors.Is(err, sql.ErrNoRows) { t.Errorf("a refused Put stored a row: %v", err) } } func TestTestRecordsOutcome(t *testing.T) { t.Parallel() ctx := context.Background() f := newFakeChat(t) svc, _ := newTestService(t, f) svc.now = func() time.Time { return time.Date(2026, 10, 9, 15, 0, 0, 0, time.UTC) } if _, err := svc.Test(ctx); !errors.Is(err, ErrNotConfigured) { t.Fatalf("Test unconfigured = %v, want ErrNotConfigured", err) } if _, err := svc.Put(ctx, testHook, "Family"); err != nil { t.Fatal(err) } res, err := svc.Test(ctx) if err != nil { t.Fatal(err) } if res.HTTPStatus != 200 || *res.LastTestStatus != TestOK || *res.LastTestAt != "2026-10-09T15:00:00.000Z" { t.Errorf("Test = %+v, want 200 ok", res) } reqs := f.requests() if len(reqs) != 1 { t.Fatalf("Chat got %d requests, want 1", len(reqs)) } r := reqs[0] if r.Method != http.MethodPost || r.Host != "chat.googleapis.com" || r.Path != "/v1/spaces/"+testSpace+"/messages" || r.Query.Get("key") != testKey || r.Query.Get("threadKey") != "" || r.Body["text"] != TestText || r.Body["cardsV2"] != nil { t.Errorf("test request = %+v", r) } f.setStatus(http.StatusNotFound) res, err = svc.Test(ctx) if err != nil || res.HTTPStatus != 404 || *res.LastTestStatus != TestFailed { t.Errorf("Test answered 404 = %+v, %v; want 404 failed", res, err) } got, _ := svc.Get(ctx) if *got.LastTestStatus != TestFailed || *got.LastTestAt != "2026-10-09T15:00:00.000Z" { t.Errorf("Get after a failed test = %+v", got) } // A new webhook has no test outcome. got, err = svc.Put(ctx, testHook, "Family") if err != nil || got.LastTestAt != nil || got.LastTestStatus != nil { t.Errorf("Put after a test = %+v, %v; want the outcome cleared", got, err) } f.srv.Close() _, err = svc.Test(ctx) if !errors.Is(err, ErrUnreachable) { t.Errorf("Test against a stopped endpoint = %v, want ErrUnreachable", err) } else { assertNoSecret(t, "unreachable error", err.Error()) } if got, _ := svc.Get(ctx); got.LastTestStatus == nil || *got.LastTestStatus != TestFailed { t.Errorf("Get after an unanswered test = %+v, want failed", got) } } func TestSendThreadsByWeek(t *testing.T) { t.Parallel() ctx := context.Background() f := newFakeChat(t) svc, _ := newTestService(t, f) if _, err := svc.Put(ctx, testHook, "Family"); err != nil { t.Fatal(err) } week := mustDate(t, "2026-10-12") events := []notify.Event{ PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "A", "B", "C", "D"}), ListReady(week, 19, 7), DayOfReminder(mustDate(t, "2026-10-18"), "01J9DINNER", "Tacos"), NewSuggestions("01J9BATCH", week, []string{"Pho"}), RotationReview("01J9DINNER", "Pho", week), } for _, ev := range events { if err := svc.Send(ctx, notify.Message{ID: "n1", EventKey: ev.Key, Channel: notify.ChannelChat, Payload: payloadOf(ev), Attempt: 1}); err != nil { t.Fatalf("Send %s: %v", ev.Type, err) } } reqs := f.requests() if len(reqs) != len(events) { t.Fatalf("Chat got %d requests, want %d", len(reqs), len(events)) } for i, r := range reqs { ev := events[i] if r.Method != http.MethodPost || r.Path != "/v1/spaces/"+testSpace+"/messages" || !strings.HasPrefix(r.ContentType, "application/json") { t.Errorf("%s: request = %+v", ev.Type, r) } if r.Query.Get("key") != testKey || r.Query.Get("token") != strings.ReplaceAll(testToken, "%3D", "=") { t.Errorf("%s: key and token not kept: %v", ev.Type, r.Query) } if r.Query.Get("threadKey") != "mealquest-week-2026-W42" || r.Query.Get("messageReplyOption") != "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" { t.Errorf("%s: threading = %v", ev.Type, r.Query) } if want := Text(payloadOf(ev)); r.Body["text"] != want { t.Errorf("%s: text = %q, want %q", ev.Type, r.Body["text"], want) } _, hasCards := r.Body["cardsV2"] if hasCards != (ev.Type == notify.PlanReady || ev.Type == notify.ListReady) { t.Errorf("%s: cardsV2 present = %v", ev.Type, hasCards) } } if reqs[0].Body["text"] != "This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →" { t.Errorf("plan text = %q", reqs[0].Body["text"]) } // The cards' "Open" buttons link under the public URL. for i, wantLink := range map[int]string{0: "https://mealquest.example/plan", 1: "https://mealquest.example/plan/shopping"} { data, _ := json.Marshal(reqs[i].Body["cardsV2"]) var cards []cardV2 if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 { t.Fatalf("cardsV2 = %s, %v", data, err) } c := cards[0] buttons := c.Card.Sections[0].Widgets[0].ButtonList.Buttons if c.CardID != string(events[i].Type) || c.Card.Header.Title != events[i].Title || c.Card.Header.Subtitle != events[i].Body || len(buttons) != 1 || buttons[0].Text != "Open" || buttons[0].OnClick.OpenLink.URL != wantLink { t.Errorf("%s card = %s, want an Open button to %s", events[i].Type, data, wantLink) } } } func TestSendWithoutWeekIsUnthreaded(t *testing.T) { t.Parallel() ctx := context.Background() f := newFakeChat(t) svc, _ := newTestService(t, f) if _, err := svc.Put(ctx, testHook, "Family"); err != nil { t.Fatal(err) } p := notify.Payload{Event: notify.RotationReview, Title: "Keep it in rotation?", URL: "/", Tag: "rotation_review:x"} if err := svc.Send(ctx, notify.Message{Payload: p}); err != nil { t.Fatal(err) } if q := f.requests()[0].Query; q.Has("threadKey") || q.Has("messageReplyOption") { t.Errorf("query = %v, want no threading", q) } } func TestSendClassifiesStatus(t *testing.T) { t.Parallel() ctx := context.Background() f := newFakeChat(t) svc, _ := newTestService(t, f) if _, err := svc.Put(ctx, testHook, "Family"); err != nil { t.Fatal(err) } m := notify.Message{Payload: payloadOf(PlanReady(mustDate(t, "2026-10-12"), mustDate(t, "2026-10-12"), []string{"Tacos"}))} for _, tc := range []struct { status int ok, permanent bool }{ {200, true, false}, {204, true, false}, {301, false, true}, {400, false, true}, {401, false, true}, {403, false, true}, {404, false, true}, {413, false, true}, {429, false, false}, {500, false, false}, {502, false, false}, {503, false, false}, } { f.setStatus(tc.status) err := svc.Send(ctx, m) if (err == nil) != tc.ok || errors.Is(err, notify.ErrUndeliverable) != tc.permanent { t.Errorf("status %d: Send = %v, want ok %v, undeliverable %v", tc.status, err, tc.ok, tc.permanent) } if err != nil { assertNoSecret(t, "Send error", err.Error()) } } f.srv.Close() err := svc.Send(ctx, m) if err == nil || errors.Is(err, notify.ErrUndeliverable) { t.Errorf("Send to a stopped endpoint = %v, want a retryable error", err) } else { assertNoSecret(t, "Send error", err.Error()) } } func TestSendWithoutWebhookIsUndeliverable(t *testing.T) { t.Parallel() svc, _ := newTestService(t, newFakeChat(t)) err := svc.Send(context.Background(), notify.Message{Payload: payloadOf(ListReady(mustDate(t, "2026-10-12"), 3, 2))}) if !errors.Is(err, notify.ErrUndeliverable) || !errors.Is(err, ErrNotConfigured) { t.Errorf("Send = %v, want undeliverable, not configured", err) } } // TestOutboxRowsFollowClassification delivers through the notify worker: a // 400 fails the row at once, a 503 leaves it pending for a retry. Neither // the stored error nor the log holds the webhook URL. func TestOutboxRowsFollowClassification(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(context.Background()) f := newFakeChat(t) svc, st := newTestService(t, f) if _, err := svc.Put(ctx, testHook, "Family"); err != nil { t.Fatal(err) } if err := household.SeedNotificationPrefs(ctx, st); err != nil { t.Fatal(err) } if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0, chat = 1`); err != nil { t.Fatal(err) } var logs syncBuffer notifier, err := notify.NewService(st, slog.New(slog.NewTextHandler(&logs, nil))) if err != nil { t.Fatal(err) } if err := notifier.Register(notify.ChannelChat, svc); err != nil { t.Fatal(err) } if err := notifier.Start(ctx); err != nil { t.Fatal(err) } t.Cleanup(func() { cancel() notifier.Wait() }) week := mustDate(t, "2026-10-12") f.setStatus(http.StatusBadRequest) planned := PlanReady(week, week, []string{"Tacos"}) if err := notifier.Emit(ctx, planned); err != nil { t.Fatal(err) } status, lastErr := waitForAttempt(t, st, planned.Key) if status != "failed" || !strings.Contains(lastErr, "status 400") { t.Errorf("row after a 400 = %s %q, want failed", status, lastErr) } f.setStatus(http.StatusServiceUnavailable) listed := ListReady(week, 3, 1) if err := notifier.Emit(ctx, listed); err != nil { t.Fatal(err) } status, lastErr = waitForAttempt(t, st, listed.Key) if status != "pending" || !strings.Contains(lastErr, "status 503") { t.Errorf("row after a 503 = %s %q, want pending for a retry", status, lastErr) } cancel() notifier.Wait() assertNoSecret(t, "log", logs.String()) assertNoSecret(t, "last_error", lastErr) } // waitForAttempt waits for the chat row of key to have been attempted and // returns its status and last error. func waitForAttempt(t *testing.T, st *store.Store, key string) (string, string) { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { var status string var attempts int var lastErr sql.NullString err := st.Reader.QueryRow(`SELECT status, attempts, last_error FROM notification_outbox WHERE event_key = ? AND channel = 'chat'`, key). Scan(&status, &attempts, &lastErr) if err != nil && !errors.Is(err, sql.ErrNoRows) { t.Fatal(err) } if attempts > 0 { return status, lastErr.String } time.Sleep(10 * time.Millisecond) } t.Fatalf("chat row of %s was never attempted", key) return "", "" } // syncBuffer is a bytes.Buffer safe for the worker's logger. type syncBuffer struct { mu sync.Mutex buf bytes.Buffer } func (b *syncBuffer) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.buf.Write(p) } func (b *syncBuffer) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buf.String() } func TestPostSupplyRun(t *testing.T) { t.Parallel() ctx := context.Background() f := newFakeChat(t) svc, _ := newTestService(t, f) week := mustDate(t, "2026-10-12") groups := []SupplyRunGroup{{Dinner: "Tacos", Items: []SupplyRunItem{{Name: "tortillas"}}}} if err := svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups); !errors.Is(err, ErrNotConfigured) { t.Fatalf("PostSupplyRun without a webhook = %v, want ErrNotConfigured", err) } if _, err := svc.Put(ctx, testHook, "Family"); err != nil { t.Fatal(err) } if err := svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups); err != nil { t.Fatal(err) } reqs := f.requests() if len(reqs) != 1 { t.Fatalf("Chat got %d requests, want 1", len(reqs)) } r := reqs[0] if r.Query.Get("threadKey") != "mealquest-week-2026-W42" || r.Query.Get("messageReplyOption") != replyOption { t.Errorf("threading = %v", r.Query) } if want := SupplyRunText("Week 42 · Oct 12 – 18", groups); r.Body["text"] != want { t.Errorf("text = %q, want %q", r.Body["text"], want) } data, _ := json.Marshal(r.Body["cardsV2"]) var cards []cardV2 if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 { t.Fatalf("cardsV2 = %s, %v", data, err) } buttons := cards[0].Card.Sections[0].Widgets[0].ButtonList.Buttons if cards[0].Card.Header.Title != "Supply run · Week 42 · Oct 12 – 18" || len(buttons) != 1 || buttons[0].OnClick.OpenLink.URL != "https://mealquest.example/plan/shopping?week=2026-10-12" { t.Errorf("card = %s, want an Open button to the week's Supply run", data) } f.setStatus(http.StatusForbidden) err := svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups) if !errors.Is(err, ErrRefused) || !strings.Contains(err.Error(), "403") { t.Errorf("PostSupplyRun answered 403 = %v, want ErrRefused with the status", err) } if err != nil { assertNoSecret(t, "refused error", err.Error()) } f.srv.Close() err = svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups) if !errors.Is(err, ErrUnreachable) { t.Errorf("PostSupplyRun unanswered = %v, want ErrUnreachable", err) } if err != nil { assertNoSecret(t, "unreachable error", err.Error()) } }