check / check (push) Successful in 1m1s
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
367 lines
9.2 KiB
Go
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")
|
|
}
|
|
}
|