Files
simplexcalc/internal/simplex/client_test.go
T
clawbot 17d6be9fe1
check / check (push) Successful in 1m1s
HTTP API: server, credential and the list of chats (closes #4)
The bot now serves an HTTP API on PORT (default 8080) beside the chat
client. Every request needs the credential read at startup from the
file named by API_TOKEN_FILE, sent as a bearer token; without one,
every request is refused. Responses carry the security headers, bodies
are capped at 64 KiB and each request's work at 10 seconds.

GET /api/v1/chats lists the bot's contacts from the chat client's
/_contacts command, ordered by id, and marks the contacts who deleted
their chat with the bot, which the chat client keeps listing. bot.Run
starts the API after set-up and stops it within 5 seconds; a listener
failure ends the bot as a chat client failure does.

Model: opus-5-5
2026-09-29 00:37:57 +00:00

367 lines
9.2 KiB
Go

package simplex_test
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
"sneak.berlin/go/simplexcalc/internal/simplex"
)
const testTimeout = 5 * time.Second
// cmdUser is the command that reads the active user profile.
const cmdUser = "/user"
// Records as the chat client sends them, with fields this package does
// not read left in, since ignoring those is part of the contract.
const (
activeUser = `{"type":"activeUser","user":{"userId":1,"agentUserId":1,
"profile":{"profileId":1,"displayName":"calc","fullName":"",
"peerType":"bot","localAlias":""},"activeUser":true}}`
addressNotFound = `{"type":"chatCmdError","chatError":{"type":"errorStore",
"storeError":{"type":"userContactLinkNotFound"}}}`
addressCreated = `{"type":"userContactLinkCreated","user":{"userId":1},
"connLinkContact":{"connFullLink":"simplex:/contact#/?v=2-7&smp=x",
"connShortLink":"https://smp.example/a#key"}}`
addressUpdated = `{"type":"userContactLinkUpdated","user":{"userId":1},
"contactLink":{"userContactLinkId":1}}`
noActiveUser = `{"type":"chatCmdError","chatError":{"type":"error",
"errorType":{"type":"noActiveUser"}}}`
contactConnected = `{"type":"contactConnected","user":{"userId":1},
"contact":{"contactId":3,"localDisplayName":"alice"}}`
// Two contacts with the same display name, which the chat client
// tells apart by the local name it gives the second. The second has
// deleted its chat with the bot, and is still listed.
contactsList = `{"type":"contactsList","user":{"userId":1},"contacts":[
{"contactId":3,"localDisplayName":"tester","profile":{"profileId":3,
"displayName":"tester","fullName":"","localAlias":""},
"activeConn":{"connId":2,"connStatus":{"type":"ready"}},
"contactUsed":true,"contactStatus":"active","chatDeleted":false},
{"contactId":4,"localDisplayName":"tester_1","profile":{"profileId":4,
"displayName":"tester","fullName":"","localAlias":""},
"activeConn":{"connId":3,"connStatus":{"type":"deleted"}},
"contactUsed":true,"contactStatus":"deleted","chatDeleted":false}]}`
)
// fakeChat stands in for the chat client's API. It answers each command
// with the record in replies under the command's first word, stays
// silent for a command it has no record for, and reports every command
// it receives on got.
type fakeChat struct {
replies map[string]string
got chan string
mu sync.Mutex
conn *websocket.Conn
up chan struct{}
}
func newFakeChat(t *testing.T, replies map[string]string) (*fakeChat, string) {
t.Helper()
f := &fakeChat{
replies: replies,
got: make(chan string, 16),
up: make(chan struct{}),
}
srv := httptest.NewServer(f)
t.Cleanup(srv.Close)
return f, "ws" + strings.TrimPrefix(srv.URL, "http")
}
func (f *fakeChat) ServeHTTP(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
f.mu.Lock()
f.conn = conn
f.mu.Unlock()
close(f.up)
for {
//nolint:tagliatelle // the chat client's wire format.
var cmd struct {
CorrID string `json:"corrId"`
Cmd string `json:"cmd"`
}
err = conn.ReadJSON(&cmd)
if err != nil {
return
}
f.got <- cmd.Cmd
name, _, _ := strings.Cut(cmd.Cmd, " ")
if resp, ok := f.replies[name]; ok {
f.send(cmd.CorrID, resp)
}
}
}
// send writes one record, as a response to corrID, or as an event when
// corrID is empty.
func (f *fakeChat) send(corrID, resp string) {
msg := map[string]any{"resp": json.RawMessage(resp)}
if corrID != "" {
msg["corrId"] = corrID
}
f.mu.Lock()
defer f.mu.Unlock()
_ = f.conn.WriteJSON(msg)
}
func (f *fakeChat) hangUp() {
f.mu.Lock()
defer f.mu.Unlock()
_ = f.conn.Close()
}
func (f *fakeChat) next(t *testing.T) string {
t.Helper()
select {
case cmd := <-f.got:
return cmd
case <-time.After(testTimeout):
t.Fatal("the client sent no command")
return ""
}
}
func dial(
t *testing.T, url string, onEvent simplex.EventHandler,
) (*simplex.Client, context.Context) {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
t.Cleanup(cancel)
if onEvent == nil {
onEvent = func(*simplex.Client, simplex.Event) {}
}
c, err := simplex.Dial(ctx, url, slog.New(slog.DiscardHandler), onEvent)
if err != nil {
t.Fatalf("Dial: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
return c, ctx
}
// TestAddressSetup walks the calls the bot makes on its first start,
// and checks the exact commands that reach the chat client.
func TestAddressSetup(t *testing.T) {
t.Parallel()
f, url := newFakeChat(t, map[string]string{
cmdUser: activeUser,
"/_show_address": addressNotFound,
"/_address": addressCreated,
"/_address_settings": addressUpdated,
})
c, ctx := dial(t, url, nil)
user, err := c.ActiveUser(ctx)
if err != nil {
t.Fatalf("ActiveUser: %v", err)
}
if user.UserID != 1 || user.Profile.DisplayName != "calc" {
t.Errorf("ActiveUser = %+v, want user 1 named calc", user)
}
_, ok, err := c.Address(ctx, 1)
if err != nil || ok {
t.Fatalf("Address = %v, %v; want no address and no error", ok, err)
}
link, err := c.CreateAddress(ctx, 1)
if err != nil {
t.Fatalf("CreateAddress: %v", err)
}
if link.ShortLink != "https://smp.example/a#key" ||
link.FullLink != "simplex:/contact#/?v=2-7&smp=x" {
t.Errorf("CreateAddress = %+v", link)
}
err = c.SetAddressSettings(ctx, 1, simplex.AddressSettings{
AutoAccept: &simplex.AutoAccept{},
AutoReply: &simplex.MsgContent{Type: "text", Text: "hi"},
})
if err != nil {
t.Fatalf("SetAddressSettings: %v", err)
}
for _, want := range []string{
cmdUser,
"/_show_address 1",
"/_address 1",
`/_address_settings 1 {"businessAddress":false,` +
`"autoAccept":{"acceptIncognito":false},` +
`"autoReply":{"type":"text","text":"hi"}}`,
} {
if got := f.next(t); got != want {
t.Errorf("command = %s\nwant %s", got, want)
}
}
}
// TestContacts: the contacts of the given user come back with their
// ids, display names and whether they are deleted.
func TestContacts(t *testing.T) {
t.Parallel()
f, url := newFakeChat(t, map[string]string{"/_contacts": contactsList})
c, ctx := dial(t, url, nil)
contacts, err := c.Contacts(ctx, 1)
if err != nil {
t.Fatalf("Contacts: %v", err)
}
if got := f.next(t); got != "/_contacts 1" {
t.Errorf("command = %s, want /_contacts 1", got)
}
tester := simplex.Profile{DisplayName: "tester"}
want := []simplex.Contact{
{ContactID: 3, Profile: tester, Status: "active"},
{ContactID: 4, Profile: tester, Status: "deleted"},
}
if !slices.Equal(contacts, want) {
t.Fatalf("Contacts = %+v\nwant %+v", contacts, want)
}
if contacts[0].Deleted() || !contacts[1].Deleted() {
t.Error("Deleted must be false for contact 3 and true for contact 4")
}
}
// TestRefusedCommand: a command the chat client refuses is an error
// that names the reason.
func TestRefusedCommand(t *testing.T) {
t.Parallel()
_, url := newFakeChat(t, map[string]string{cmdUser: noActiveUser})
c, ctx := dial(t, url, nil)
_, err := c.ActiveUser(ctx)
var cerr *simplex.CommandError
if !errors.As(err, &cerr) || cerr.Detail != "noActiveUser" {
t.Errorf("ActiveUser error = %v, want a CommandError for noActiveUser", err)
}
}
// TestUnexpectedResponse: a response of the wrong type is an error, not
// a zero value.
func TestUnexpectedResponse(t *testing.T) {
t.Parallel()
_, url := newFakeChat(t, map[string]string{cmdUser: addressUpdated})
c, ctx := dial(t, url, nil)
_, err := c.ActiveUser(ctx)
if err == nil {
t.Error("ActiveUser accepted a userContactLinkUpdated response")
}
}
// TestEventsAndReplies: an event reaches the handler, and a reply sent
// from inside the handler reaches the chat client.
func TestEventsAndReplies(t *testing.T) {
t.Parallel()
f, url := newFakeChat(t, nil)
seen := make(chan simplex.Event, 1)
dial(t, url, func(c *simplex.Client, ev simplex.Event) {
seen <- ev
err := c.SendText(3, 7, `4 "exactly"`)
if err != nil {
t.Errorf("SendText: %v", err)
}
})
<-f.up
f.send("", contactConnected)
select {
case ev := <-seen:
var r simplex.ContactConnected
err := ev.Decode(&r)
if err != nil || ev.Type != simplex.TypeContactConnected ||
r.Contact.ContactID != 3 {
t.Errorf("event = %s %+v (%v), want contactConnected for contact 3",
ev.Type, r, err)
}
case <-time.After(testTimeout):
t.Fatal("the event never reached the handler")
}
want := `/_send @3 json [{"quotedItemId":7,` +
`"msgContent":{"type":"text","text":"4 \"exactly\""},"mentions":{}}]`
if got := f.next(t); got != want {
t.Errorf("command = %s\nwant %s", got, want)
}
}
// TestConnectionLoss: when the chat client goes away, Done closes and a
// command fails instead of waiting for an answer that cannot come.
func TestConnectionLoss(t *testing.T) {
t.Parallel()
f, url := newFakeChat(t, nil)
c, ctx := dial(t, url, nil)
<-f.up
f.hangUp()
select {
case <-c.Done():
case <-time.After(testTimeout):
t.Fatal("Done did not close after the connection ended")
}
if c.Err() == nil {
t.Error("Err is nil after the connection ended")
}
_, err := c.ActiveUser(ctx)
if err == nil {
t.Error("a command on a closed connection succeeded")
}
}