check / check (push) Successful in 1m45s
Each message a contact sends in a direct chat is posted to each webhook on that chat, as JSON holding `chat_id` and the message record of the messages endpoint. The event handler only queues it: `api.Deliveries` serves a queue of 100 with four goroutines, reads the chat's webhooks there, and makes one POST per webhook, with a 10-second timeout, no retry and no redirect followed. A full queue or a failed post is logged by ids, never by URL or text. `bot.Run` stops it last, once the chat client has exited. Tests cover the payload and its routing, a full queue, failures, and a webhook that never answers; the README documents the payload and what delivery promises. Model: opus-5-5
424 lines
11 KiB
Go
424 lines
11 KiB
Go
package bot_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"sneak.berlin/go/simplexcalc/internal/bot"
|
|
"sneak.berlin/go/simplexcalc/internal/config"
|
|
"sneak.berlin/go/simplexcalc/internal/simplex"
|
|
)
|
|
|
|
const (
|
|
// credential is what the bot's API is configured with here.
|
|
credential = "a-credential-for-these-tests" //nolint:gosec // G101: invented for tests
|
|
|
|
// asked is the file the stand-in chat client creates in the data
|
|
// directory when it is asked for the contacts.
|
|
asked = "asked-for-contacts"
|
|
|
|
// contactsDelay is how long the stand-in then holds its answer:
|
|
// long enough for the test to stop the bot meanwhile, and well
|
|
// within the 5 seconds the API gets to finish its requests.
|
|
contactsDelay = time.Second
|
|
|
|
// sent starts the name of the file the stand-in writes in the data
|
|
// directory for each message the bot sends, holding the command.
|
|
sent = "sent-"
|
|
)
|
|
|
|
// twoMessages is the event the stand-in sends once the bot is set up: a
|
|
// contact sending the bot two messages. It is reduced to the fields the
|
|
// bot reads.
|
|
const twoMessages = `{"type":"newChatItems","chatItems":[
|
|
{"chatInfo":{"type":"direct","contact":{"contactId":3}},
|
|
"chatItem":{"chatDir":{"type":"directRcv"},
|
|
"meta":{"itemId":41,"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":42,"itemTs":"2026-09-29T03:14:35Z"},
|
|
"content":{"type":"rcvMsgContent","msgContent":{"type":"text","text":"3 * 3"}}}}]}`
|
|
|
|
// TestMain lets this test binary be the chat client as well: started
|
|
// under the chat client's name, as standInPath arranges, it is the
|
|
// stand-in instead of running the tests.
|
|
func TestMain(m *testing.M) {
|
|
if filepath.Base(os.Args[0]) == simplex.Binary {
|
|
standIn() // never returns
|
|
}
|
|
|
|
m.Run()
|
|
}
|
|
|
|
// standIn plays the chat client: it serves the WebSocket API on the
|
|
// port it is given, on localhost, and answers the commands the bot
|
|
// sends. SIGTERM ends it, as it ends the real client. Unlike the real
|
|
// client, it also exits when the bot hangs up, so that it never
|
|
// outlives a test run that was cut short.
|
|
func standIn() {
|
|
arg := func(name string) string {
|
|
return os.Args[slices.Index(os.Args, name)+1]
|
|
}
|
|
|
|
dir := filepath.Dir(arg("--database"))
|
|
|
|
srv := &http.Server{
|
|
Addr: "127.0.0.1:" + arg("--chat-server-port"),
|
|
ReadHeaderTimeout: time.Second,
|
|
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
answer(w, r, dir)
|
|
}),
|
|
}
|
|
|
|
err := srv.ListenAndServe()
|
|
_, _ = fmt.Fprintln(os.Stderr, err)
|
|
|
|
os.Exit(1)
|
|
}
|
|
|
|
// answer answers the commands on one connection with records reduced to
|
|
// the fields the bot reads. Asked for the contacts, it first creates the
|
|
// file asked in dir, then holds its answer for contactsDelay. Once the
|
|
// bot is set up, it sends twoMessages. Each message the bot sends, it
|
|
// writes to a file in dir, named sent and the command's id.
|
|
func answer(w http.ResponseWriter, r *http.Request, dir string) {
|
|
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
records := map[string]string{
|
|
"/user": `{"type":"activeUser","user":{"userId":1}}`,
|
|
"/_show_address": `{"type":"userContactLink","contactLink":{}}`,
|
|
"/_address_settings": `{"type":"userContactLinkUpdated"}`,
|
|
"/_contacts": `{"type":"contactsList","contacts":[{"contactId":3,` +
|
|
`"profile":{"displayName":"tester"},"contactStatus":"active"}]}`,
|
|
}
|
|
|
|
for {
|
|
var cmd map[string]string
|
|
|
|
err = conn.ReadJSON(&cmd)
|
|
if err != nil {
|
|
os.Exit(0)
|
|
}
|
|
|
|
name, _, _ := strings.Cut(cmd["cmd"], " ")
|
|
|
|
if name == "/_send" {
|
|
_ = os.WriteFile(filepath.Join(dir, sent+cmd["corrId"]),
|
|
[]byte(cmd["cmd"]), 0o600)
|
|
|
|
continue
|
|
}
|
|
|
|
record, ok := records[name]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
if name == "/_contacts" {
|
|
_ = os.WriteFile(filepath.Join(dir, asked), nil, 0o600)
|
|
|
|
time.Sleep(contactsDelay)
|
|
}
|
|
|
|
_ = conn.WriteJSON(map[string]any{
|
|
"corrId": cmd["corrId"],
|
|
"resp": json.RawMessage(record),
|
|
})
|
|
|
|
// The last command of the bot's set-up.
|
|
if name == "/_address_settings" {
|
|
_ = conn.WriteJSON(map[string]any{"resp": json.RawMessage(twoMessages)})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStopDuringRequest: a request that is waiting on the chat client
|
|
// when the bot is told to stop still gets the chat client's answer,
|
|
// because the chat client is stopped only once the API has stopped.
|
|
func TestStopDuringRequest(t *testing.T) {
|
|
t.Setenv("PATH", standInPath(t))
|
|
|
|
cfg := &config.Config{DataDir: t.TempDir(), Port: freePort(t), APIToken: credential}
|
|
stop, done := runBot(t, cfg)
|
|
|
|
// Stop the bot once the request below is waiting on the chat client.
|
|
go func() {
|
|
for {
|
|
_, err := os.Stat(filepath.Join(cfg.DataDir, asked))
|
|
if err == nil {
|
|
stop()
|
|
}
|
|
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-time.After(10 * time.Millisecond):
|
|
}
|
|
}
|
|
}()
|
|
|
|
status, body := getChats(t, cfg.Port, done)
|
|
|
|
want := `{"chats":[{"id":3,"display_name":"tester","contact_deleted":false}]}` + "\n"
|
|
if status != http.StatusOK || body != want {
|
|
t.Errorf("GET /api/v1/chats = %d %q, want 200 %q", status, body, want)
|
|
}
|
|
}
|
|
|
|
// TestSlowWebhook: a webhook that takes the bot's POSTs and never answers
|
|
// them holds up neither the bot's replies nor its stop.
|
|
func TestSlowWebhook(t *testing.T) {
|
|
t.Setenv("PATH", standInPath(t))
|
|
|
|
posts := make(chan string, 2)
|
|
receiver := httptest.NewServer(http.HandlerFunc(
|
|
func(_ http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
posts <- string(body)
|
|
|
|
<-r.Context().Done() // until the bot abandons the POST
|
|
}))
|
|
t.Cleanup(receiver.Close)
|
|
|
|
cfg := &config.Config{DataDir: t.TempDir(), Port: freePort(t), APIToken: credential}
|
|
|
|
err := os.WriteFile(filepath.Join(cfg.DataDir, "webhooks.json"),
|
|
[]byte(`{"webhooks":[{"id":"00112233445566778899aabbccddeeff",`+
|
|
`"chat_id":3,"url":"`+receiver.URL+`"}]}`), 0o600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
stop, done := runBot(t, cfg)
|
|
|
|
// The webhook gets both messages, and holds on to them...
|
|
got := []string{receive(t, posts, done), receive(t, posts, done)}
|
|
slices.Sort(got)
|
|
|
|
want := []string{
|
|
`{"chat_id":3,"message":{"id":41,"direction":"received",` +
|
|
`"type":"text","text":"2 + 2","time":"2026-09-29T03:14:34Z"}}`,
|
|
`{"chat_id":3,"message":{"id":42,"direction":"received",` +
|
|
`"type":"text","text":"3 * 3","time":"2026-09-29T03:14:35Z"}}`,
|
|
}
|
|
if !slices.Equal(got, want) {
|
|
t.Errorf("posted %q, want %q", got, want)
|
|
}
|
|
|
|
// ...while the bot answers both.
|
|
replies := []string{
|
|
`/_send @3 json [{"quotedItemId":41,` +
|
|
`"msgContent":{"type":"text","text":"4"},"mentions":{}}]`,
|
|
`/_send @3 json [{"quotedItemId":42,` +
|
|
`"msgContent":{"type":"text","text":"9"},"mentions":{}}]`,
|
|
}
|
|
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
|
|
for !slices.Equal(sentMessages(t, cfg.DataDir), replies) {
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("sent %q, want %q", sentMessages(t, cfg.DataDir), replies)
|
|
}
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
|
|
// Stopping abandons the POSTs, rather than give the webhook the 10
|
|
// seconds it has to answer.
|
|
stop()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Run did not return while a webhook held a POST")
|
|
}
|
|
}
|
|
|
|
// TestUnreadableWebhooks: a webhooks file that cannot be read stops Run
|
|
// before it starts the chat client.
|
|
func TestUnreadableWebhooks(t *testing.T) {
|
|
// No chat client on PATH: starting one would fail with another error.
|
|
t.Setenv("PATH", t.TempDir())
|
|
|
|
cfg := &config.Config{DataDir: t.TempDir(), Port: freePort(t)}
|
|
|
|
err := os.WriteFile(filepath.Join(cfg.DataDir, "webhooks.json"), []byte("{"), 0o600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = bot.Run(t.Context(), slog.New(slog.DiscardHandler), cfg, freePort(t))
|
|
if err == nil || !strings.Contains(err.Error(), "webhooks.json") {
|
|
t.Errorf("Run = %v, want an error naming webhooks.json", err)
|
|
}
|
|
}
|
|
|
|
// standInPath returns a directory holding this test binary under the
|
|
// chat client's name. Run starts the chat client from PATH, so with PATH
|
|
// set to it, Run starts the stand-in.
|
|
func standInPath(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
bin := t.TempDir()
|
|
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = os.Symlink(exe, filepath.Join(bin, simplex.Binary))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return bin
|
|
}
|
|
|
|
// runBot runs the bot with cfg until stop is called or the test ends,
|
|
// and fails the test if Run returns an error. done is closed once Run
|
|
// has returned.
|
|
func runBot(t *testing.T, cfg *config.Config) (context.CancelFunc, <-chan struct{}) {
|
|
t.Helper()
|
|
|
|
// Never bot.ChatPort: a real chat client may be listening there.
|
|
chatPort := freePort(t)
|
|
ctx, stop := context.WithCancel(t.Context())
|
|
done := make(chan struct{})
|
|
|
|
var runErr error
|
|
|
|
go func() {
|
|
defer close(done)
|
|
|
|
runErr = bot.Run(ctx, slog.New(slog.DiscardHandler), cfg, chatPort)
|
|
}()
|
|
|
|
t.Cleanup(func() {
|
|
stop()
|
|
<-done
|
|
|
|
if runErr != nil {
|
|
t.Errorf("Run: %v", runErr)
|
|
}
|
|
})
|
|
|
|
return stop, done
|
|
}
|
|
|
|
// receive returns the next of posts. It fails the test if none comes
|
|
// within 10 seconds, or if Run returns first, which closes done.
|
|
func receive(t *testing.T, posts <-chan string, done <-chan struct{}) string {
|
|
t.Helper()
|
|
|
|
select {
|
|
case p := <-posts:
|
|
return p
|
|
case <-done:
|
|
t.Fatal("Run returned before the webhook got the messages")
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("the webhook did not get the messages")
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// sentMessages returns the commands of the messages the bot has sent, as
|
|
// the stand-in wrote them in dir, sorted.
|
|
func sentMessages(t *testing.T, dir string) []string {
|
|
t.Helper()
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var cmds []string
|
|
|
|
for _, entry := range entries {
|
|
if !strings.HasPrefix(entry.Name(), sent) {
|
|
continue
|
|
}
|
|
|
|
//nolint:gosec // G304: the test's own file.
|
|
cmd, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cmds = append(cmds, string(cmd))
|
|
}
|
|
|
|
slices.Sort(cmds)
|
|
|
|
return cmds
|
|
}
|
|
|
|
// freePort returns a TCP port that nothing listens on at the moment.
|
|
func freePort(t *testing.T) int {
|
|
t.Helper()
|
|
|
|
l, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", ":0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = l.Close() }()
|
|
|
|
return int(netip.MustParseAddrPort(l.Addr().String()).Port())
|
|
}
|
|
|
|
// getChats asks the bot's API for the chats, trying again while the API
|
|
// is not listening yet, and returns the answer's status and body. It
|
|
// fails the test if Run returns first, which closes done.
|
|
func getChats(t *testing.T, port int, done <-chan struct{}) (int, string) {
|
|
t.Helper()
|
|
|
|
url := "http://127.0.0.1:" + strconv.Itoa(port) + "/api/v1/chats"
|
|
|
|
for {
|
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+credential)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err == nil {
|
|
body, err := io.ReadAll(resp.Body)
|
|
_ = resp.Body.Close()
|
|
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return resp.StatusCode, string(body)
|
|
}
|
|
|
|
select {
|
|
case <-done:
|
|
t.Fatal("Run returned before the API answered")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|