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): } } }