package api_test import ( "bytes" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "slices" "strconv" "strings" "sync" "testing" "time" "sneak.berlin/go/simplexcalc/internal/api" "sneak.berlin/go/simplexcalc/internal/simplex" ) // newChatItems is a newChatItems event, reduced to the fields the bot // reads. It holds, in this order, a message in a group, a message the bot // sent, an event the chat client records in a chat, and two messages a // contact sent: a text and a picture with a caption. const newChatItems = `{"type":"newChatItems","chatItems":[ {"chatInfo":{"type":"group","groupInfo":{"groupId":9}}, "chatItem":{"chatDir":{"type":"groupRcv"},"meta":{"itemId":5}, "content":{"type":"rcvMsgContent","msgContent":{"type":"text","text":"1 + 1"}}}}, {"chatInfo":{"type":"direct","contact":{"contactId":3}}, "chatItem":{"chatDir":{"type":"directSnd"},"meta":{"itemId":6}, "content":{"type":"sndMsgContent","msgContent":{"type":"text","text":"2"}}}}, {"chatInfo":{"type":"direct","contact":{"contactId":3}}, "chatItem":{"chatDir":{"type":"directRcv"},"meta":{"itemId":7}, "content":{"type":"rcvChatFeature","feature":"calls"}}}, {"chatInfo":{"type":"direct","contact":{"contactId":3}}, "chatItem":{"chatDir":{"type":"directRcv"}, "meta":{"itemId":9,"itemTs":"2026-09-29T03:14:34Z"}, "content":{"type":"rcvMsgContent","msgContent":{"type":"text","text":"2 + 2"}}}}, {"chatInfo":{"type":"direct","contact":{"contactId":3}}, "chatItem":{"chatDir":{"type":"directRcv"}, "meta":{"itemId":11,"itemTs":"2026-09-29T03:15:02Z"}, "content":{"type":"rcvMsgContent","msgContent":{"type":"image", "text":"a picture","image":"data:image/jpg;base64,/9j/4AAQ"}}}}]}` // textItem is the index in newChatItems of the text the contact sent. const textItem = 3 // logBuffer is a log that the goroutines posting messages write to while // the test reads it. type logBuffer struct { mu sync.Mutex buf bytes.Buffer } func (b *logBuffer) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.buf.Write(p) } func (b *logBuffer) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buf.String() } // chatItems returns the chat items in a newChatItems record. func chatItems(t *testing.T, record string) []simplex.AChatItem { t.Helper() var r simplex.NewChatItems err := json.Unmarshal([]byte(record), &r) if err != nil { t.Fatalf("decoding %s: %v", record, err) } return r.ChatItems } // receive returns the next of posts, and fails the test if none comes // within 5 seconds. func receive(t *testing.T, posts <-chan string) string { t.Helper() select { case p := <-posts: return p case <-time.After(5 * time.Second): t.Fatal("nothing more was posted") return "" } } // within runs f, and fails the test with complaint if f has not returned // within 5 seconds. func within(t *testing.T, complaint string, f func()) { t.Helper() done := make(chan struct{}) go func() { defer close(done) f() }() select { case <-done: case <-time.After(5 * time.Second): t.Fatal(complaint) } } // TestDeliveries: each message a contact sends is posted, as JSON with // its chat's id, to each webhook of its chat and to no other chat's. // Messages in groups, the bot's own messages and the events the chat // client records in a chat are not posted. func TestDeliveries(t *testing.T) { t.Parallel() posts := make(chan string, 10) receiver := httptest.NewServer(http.HandlerFunc( func(_ http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) posts <- r.Method + " " + r.URL.Path + " " + r.Header.Get("Content-Type") + " " + string(body) })) t.Cleanup(receiver.Close) webhooks := readWebhooks(t, t.TempDir()) srv := webhookAPI(webhooks) // Chat 4's webhook comes between chat 3's, so a message posted to // every webhook in turn would reach it before chat 3's second. register(t, srv, webhooksPath, receiver.URL+"/a", http.StatusCreated) register(t, srv, "/api/v1/chats/4/webhooks", receiver.URL+"/c", http.StatusCreated) register(t, srv, webhooksPath, receiver.URL+"/b", http.StatusCreated) deliveries := api.StartDeliveries(t.Context(), slog.New(slog.DiscardHandler), webhooks) t.Cleanup(deliveries.Stop) for _, item := range chatItems(t, newChatItems) { deliveries.Add(item) } text := `{"chat_id":3,"message":{"id":9,"direction":"received",` + `"type":"text","text":"2 + 2","time":"2026-09-29T03:14:34Z"}}` picture := `{"chat_id":3,"message":{"id":11,"direction":"received",` + `"type":"image","text":"a picture","time":"2026-09-29T03:15:02Z"}}` want := []string{ "POST /a application/json " + text, "POST /a application/json " + picture, "POST /b application/json " + text, "POST /b application/json " + picture, } got := make([]string, 0, len(want)+1) for range want { got = append(got, receive(t, posts)) } deliveries.Stop() select { case p := <-posts: got = append(got, p) default: } slices.Sort(want) slices.Sort(got) if !slices.Equal(got, want) { t.Errorf("posted:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) } } // TestDeliveriesQueueFull: while a webhook holds a POST from each of the // goroutines, Add queues as many messages as the queue takes, then drops // the next rather than wait, logging it by its chat's id and its own, // never its text. Stop abandons the POSTs in progress rather than wait // for them. func TestDeliveriesQueueFull(t *testing.T) { t.Parallel() held := make(chan string, api.DeliveryGoroutines) receiver := httptest.NewServer(http.HandlerFunc( func(_ http.ResponseWriter, r *http.Request) { // Read to the end: only then does the server notice when the // bot abandons the POST, and end r's context. body, _ := io.ReadAll(r.Body) held <- string(body) <-r.Context().Done() // never answers })) t.Cleanup(receiver.Close) webhooks := readWebhooks(t, t.TempDir()) register(t, webhookAPI(webhooks), webhooksPath, receiver.URL, http.StatusCreated) var logged logBuffer deliveries := api.StartDeliveries(t.Context(), slog.New(slog.NewJSONHandler(&logged, nil)), webhooks) t.Cleanup(deliveries.Stop) // A message for each goroutine, whose POST the webhook holds, then as // many as the queue takes, then one more. items := make([]simplex.AChatItem, api.DeliveryGoroutines+api.QueueLength+1) for i := range items { items[i] = chatItems(t, newChatItems)[textItem] items[i].ChatItem.Meta.ItemID = int64(i + 1) } for _, item := range items[:api.DeliveryGoroutines] { deliveries.Add(item) receive(t, held) } within(t, "Add waited for room in the queue", func() { for _, item := range items[api.DeliveryGoroutines:] { deliveries.Add(item) } }) within(t, "Stop waited for the webhook to answer", deliveries.Stop) log := logged.String() dropped := `"level":"WARN","msg":"dropping a message for the webhooks: ` + `the queue is full","chat_id":3,"message_id":` if strings.Count(log, dropped) != 1 || !strings.Contains(log, dropped+strconv.Itoa(len(items))+"}") { t.Errorf("log = %s, want message %d dropped, and no other", log, len(items)) } if strings.Contains(log, "2 + 2") { t.Errorf("log = %s, holding a message's text", log) } } // TestDeliveryFailures: a POST that fails, or is answered with a status // other than 2xx, is logged with the webhook's id, the message's and the // reason or the status, and is not made again. A redirect is not // followed. The log holds neither the webhook's URL nor the message's // text. func TestDeliveryFailures(t *testing.T) { t.Parallel() requested := make(chan string, 10) receiver := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { requested <- r.URL.Path switch r.URL.Path { case "/refuses": w.WriteHeader(http.StatusServiceUnavailable) case "/redirects": http.Redirect(w, r, "/elsewhere", http.StatusFound) case "/hangs-up": conn, _, err := http.NewResponseController(w).Hijack() if err == nil { _ = conn.Close() } } })) t.Cleanup(receiver.Close) webhooks := readWebhooks(t, t.TempDir()) paths := []string{"/hangs-up", "/redirects", "/refuses"} ids := map[string]string{} // The query stands for a secret that a webhook's URL can hold. for _, path := range paths { ids[path] = register(t, webhookAPI(webhooks), webhooksPath, receiver.URL+path+"?key=s3cret", http.StatusCreated).ID } var logged logBuffer deliveries := api.StartDeliveries(t.Context(), slog.New(slog.NewJSONHandler(&logged, nil)), webhooks) t.Cleanup(deliveries.Stop) deliveries.Add(chatItems(t, newChatItems)[textItem]) failed := `"level":"WARN","msg":"posting a message to a webhook","webhook_id":"` within(t, "the three failures were not logged", func() { for strings.Count(logged.String(), failed) < len(paths) { time.Sleep(10 * time.Millisecond) } }) log := logged.String() for _, want := range []string{ failed + ids["/hangs-up"] + `","message_id":9,"error":"`, failed + ids["/redirects"] + `","message_id":9,"status":302}`, failed + ids["/refuses"] + `","message_id":9,"status":503}`, } { if !strings.Contains(log, want) { t.Errorf("log = %s, want it to hold %s", log, want) } } for _, secret := range []string{receiver.URL, "s3cret", "2 + 2"} { if strings.Contains(log, secret) { t.Errorf("log = %s, holding %s", log, secret) } } // Each webhook was asked once, before its failure was logged, and // the redirect's target never. got := []string{<-requested, <-requested, <-requested} slices.Sort(got) if !slices.Equal(got, paths) || len(requested) != 0 { t.Errorf("requested %v and %d more, want %v once each", got, len(requested), paths) } }