Files
simplexcalc/internal/bot/run_test.go
T
clawbot cbcae80260
check / check (push) Successful in 1m2s
HTTP API: register, list and remove webhooks, kept across restarts (closes #6)
POST, GET and DELETE under /api/v1/chats/{id}/webhooks, for the chats
that GET /api/v1/chats lists. The webhooks are kept in
$DATA_DIR/webhooks.json, mode 0600, which each change replaces whole
through a temporary file in the same directory and a rename. bot.Run
reads the file before it starts the chat client: absent means none, and
a file that cannot be read aborts startup. Reading a JSON request body
moved into decodeBody, which the messages endpoint now shares. Nothing
is posted to a webhook yet.

Model: opus-5-5
2026-09-29 06:08:04 +00:00

253 lines
6.4 KiB
Go

package bot_test
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"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
)
// TestMain lets this test binary be the chat client as well: started
// under the chat client's name, as TestStopDuringRequest 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]
}
marker := filepath.Join(filepath.Dir(arg("--database")), asked)
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, marker)
}),
}
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 marker, then holds its answer for contactsDelay.
func answer(w http.ResponseWriter, r *http.Request, marker 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"], " ")
record, ok := records[name]
if !ok {
continue
}
if name == "/_contacts" {
_ = os.WriteFile(marker, nil, 0o600)
time.Sleep(contactsDelay)
}
_ = conn.WriteJSON(map[string]any{
"corrId": cmd["corrId"],
"resp": json.RawMessage(record),
})
}
}
// 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) {
// Run starts the chat client from PATH: put this test binary there
// under the chat client's name.
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)
}
t.Setenv("PATH", bin)
cfg := &config.Config{DataDir: t.TempDir(), Port: freePort(t), APIToken: credential}
// 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)
}
})
// Stop the bot once the request below is waiting on the chat client.
go func() {
for ctx.Err() == nil {
_, err := os.Stat(filepath.Join(cfg.DataDir, asked))
if err == nil {
stop()
}
time.Sleep(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)
}
}
// 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)
}
}
// 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):
}
}
}