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") } }