Files
simplexcalc/internal/simplex/client_test.go
T
clawbot 2ad0b68e35
check / check (push) Successful in 1m21s
HTTP API: a chat's recent messages, and sending a message (closes #5)
GET /api/v1/chats/{id}/messages returns the messages among a chat's
last count items (default 20, at most 100), oldest first. POST sends a
text, waits for the chat client's answer and returns 201 with the
message as sent. A chat the bot does not have is 404, a contact who
deleted the chat is 409, a text too long for one message is 413, and
any other failure is 500 with a chosen sentence.

The chat client spells out a message's formatting in its answer, up to
26 times the text's length, so 100 items in one answer can pass the
16 MiB read limit and end the connection. Items are read five at a
time.

Model: opus-5-5
2026-09-29 03:48:45 +00:00

372 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 whole command or else under its
// 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
resp, ok := f.replies[cmd.Cmd]
if !ok {
name, _, _ := strings.Cut(cmd.Cmd, " ")
resp, ok = f.replies[name]
}
if 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")
}
}