A SimpleX Chat bot that answers arithmetic (closes #1)
check / check (push) Successful in 54s
check / check (push) Successful in 54s
Remove the template's HTTP service, database and fx wiring. Add exact arithmetic on go/parser and go/constant, a client that runs simplex-chat as a child process and drives its WebSocket API, and the bot, which keeps an auto-accepting address and replies to each message. The image adds the checksum-pinned simplex-chat v7.0.2 on Ubuntu 22.04. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
package simplex_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"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"}}`
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user