CI / ci (push) Successful in 8m22s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
589 lines
19 KiB
Go
589 lines
19 KiB
Go
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", mustDate(t, "2026-10-14")),
|
||
}
|
||
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())
|
||
}
|
||
}
|