package simplex_test import ( "errors" "slices" "strconv" "strings" "testing" "time" "sneak.berlin/go/simplexcalc/internal/simplex" ) // Chat items as simplex-chat v7.0.2 sends them, oldest first: an event it // records in a chat, the greeting the bot sent, a text, a formatted text // and a picture a contact sent, and a text the bot sent. const ( itemEvent = `{"chatDir":{"type":"directRcv"},"meta":{"itemId":7, "itemTs":"2026-09-29T03:13:46.521359978Z", "itemText":"Audio/video calls: enabled","itemStatus":{"type":"rcvRead"}, "createdAt":"2026-09-29T03:13:46.521359978Z"}, "content":{"type":"rcvChatFeature","feature":"calls", "enabled":{"forUser":true,"forContact":true}},"mentions":{},"reactions":[]}` itemGreeting = `{"chatDir":{"type":"directSnd"},"meta":{"itemId":8, "itemTs":"2026-09-29T03:13:46.925072391Z","itemText":"hi", "itemStatus":{"type":"sndRcvd","msgRcptStatus":"ok","sndProgress":"complete"}, "createdAt":"2026-09-29T03:13:46.925072391Z"}, "content":{"type":"sndMsgContent","msgContent":{"type":"text","text":"hi"}}, "mentions":{},"reactions":[]}` itemText = `{"chatDir":{"type":"directRcv"},"meta":{"itemId":9, "itemTs":"2026-09-29T03:14:34Z","itemText":"2 + 2", "itemStatus":{"type":"rcvNew"},"itemSharedMsgId":"bCtLTUFwY3NCSFZIR002Ng==", "createdAt":"2026-09-29T03:14:34.865730547Z"}, "content":{"type":"rcvMsgContent","msgContent":{"type":"text","text":"2 + 2"}}, "mentions":{},"reactions":[]}` itemFormatted = `{"chatDir":{"type":"directRcv"},"meta":{"itemId":10, "itemTs":"2026-09-29T03:14:34Z","itemText":"*bold* and _italic_", "itemStatus":{"type":"rcvNew"},"createdAt":"2026-09-29T03:14:34.935833284Z"}, "content":{"type":"rcvMsgContent", "msgContent":{"type":"text","text":"*bold* and _italic_"}},"mentions":{}, "formattedText":[{"format":{"type":"bold"},"text":"bold"},{"text":" and "}, {"format":{"type":"italic"},"text":"italic"}],"reactions":[]}` itemPicture = `{"chatDir":{"type":"directRcv"},"meta":{"itemId":11, "itemTs":"2026-09-29T03:14:34Z","itemText":"a picture", "itemStatus":{"type":"rcvNew"},"createdAt":"2026-09-29T03:14:35.020538268Z"}, "content":{"type":"rcvMsgContent","msgContent":{"type":"image", "text":"a picture","image":"data:image/jpg;base64,/9j/4AAQSkZJRgABAQ=="}}, "mentions":{},"reactions":[]}` itemSent = `{"chatDir":{"type":"directSnd"},"meta":{"itemId":12, "itemTs":"2026-09-29T03:14:43.519552587Z","itemText":"hello from the API", "itemStatus":{"type":"sndNew"},"createdAt":"2026-09-29T03:14:43.519552587Z"}, "content":{"type":"sndMsgContent", "msgContent":{"type":"text","text":"hello from the API"}}, "mentions":{},"reactions":[]}` ) // The chat client's answer to /_send, and its refusals: of a contact the // bot does not have, of one who has deleted their chat with the bot, and // of a text too long for one message. const ( sentItems = `{"type":"newChatItems","user":{"userId":1},"chatItems":[ {"chatInfo":{"type":"direct","contact":{"contactId":3, "localDisplayName":"tester","contactStatus":"active"}}, "chatItem":` + itemSent + `}]}` contactNotFound = `{"type":"chatCmdError","chatError":{"type":"errorStore", "storeError":{"type":"contactNotFound","contactId":4}}}` contactNotReady = `{"type":"chatCmdError","chatError":{"type":"error", "errorType":{"type":"contactNotReady","contact":{"contactId":4, "localDisplayName":"tester_1","contactStatus":"deleted", "activeConn":{"connId":3,"connStatus":{"type":"deleted"}}}}}}` largeMsg = `{"type":"chatCmdError","chatError":{"type":"errorStore", "storeError":{"type":"largeMsg"}}}` ) // apiChat is the chat client's answer to /_get chat, holding items. func apiChat(items ...string) string { return `{"type":"apiChat","user":{"userId":1},"chat":{"chatInfo":{ "type":"direct","contact":{"contactId":3,"localDisplayName":"tester", "contactStatus":"active"}},"chatItems":[` + strings.Join(items, ",") + `], "chatStats":{"unreadCount":0,"minUnreadItemId":0,"unreadChat":false}}, "navInfo":null}` } // describe sums up a chat item as its id, content type, message if it // has one, and time. func describe(item simplex.ChatItem) string { s := strconv.FormatInt(item.Meta.ItemID, 10) + " " + item.Content.Type if m := item.Content.MsgContent; m != nil { s += " " + m.Type + " " + strconv.Quote(m.Text) } return s + " " + item.Meta.ItemTs.Format(time.RFC3339Nano) } // TestChatItems: the chat's last items come back oldest first, read five // at a time from the newest backwards, and reading stops at the start // of the chat. func TestChatItems(t *testing.T) { t.Parallel() f, url := newFakeChat(t, map[string]string{ "/_get chat @3 count=5": apiChat( itemGreeting, itemText, itemFormatted, itemPicture, itemSent), "/_get chat @3 before=8 count=2": apiChat(itemEvent), }) c, ctx := dial(t, url, nil) items, err := c.ChatItems(ctx, 3, 7) if err != nil { t.Fatalf("ChatItems: %v", err) } for _, want := range []string{ "/_get chat @3 count=5", "/_get chat @3 before=8 count=2", } { if got := f.next(t); got != want { t.Errorf("command = %s, want %s", got, want) } } want := []string{ `7 rcvChatFeature 2026-09-29T03:13:46.521359978Z`, `8 sndMsgContent text "hi" 2026-09-29T03:13:46.925072391Z`, `9 rcvMsgContent text "2 + 2" 2026-09-29T03:14:34Z`, `10 rcvMsgContent text "*bold* and _italic_" 2026-09-29T03:14:34Z`, `11 rcvMsgContent image "a picture" 2026-09-29T03:14:34Z`, `12 sndMsgContent text "hello from the API" 2026-09-29T03:14:43.519552587Z`, } got := make([]string, 0, len(items)) for _, item := range items { got = append(got, describe(item)) } if !slices.Equal(got, want) { t.Errorf("ChatItems =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) } } // TestSendMessage: a text goes out as /_send, and comes back as the chat // client recorded it. func TestSendMessage(t *testing.T) { t.Parallel() f, url := newFakeChat(t, map[string]string{"/_send": sentItems}) c, ctx := dial(t, url, nil) item, err := c.SendMessage(ctx, 3, "hello from the API") if err != nil { t.Fatalf("SendMessage: %v", err) } want := `/_send @3 json [{"msgContent":{"type":"text",` + `"text":"hello from the API"},"mentions":{}}]` if got := f.next(t); got != want { t.Errorf("command = %s\nwant %s", got, want) } wantItem := `12 sndMsgContent text "hello from the API" ` + `2026-09-29T03:14:43.519552587Z` if got := describe(item); got != wantItem { t.Errorf("SendMessage = %s, want %s", got, wantItem) } } // TestRefusals: the chat client's refusals that callers answer for come // back as this package's errors, still carrying the chat client's // reason. func TestRefusals(t *testing.T) { t.Parallel() for name, tc := range map[string]struct { record string read bool // ChatItems rather than SendMessage want error }{ "reading, no such contact": {contactNotFound, true, simplex.ErrNoContact}, "sending, no such contact": {contactNotFound, false, simplex.ErrNoContact}, "sending, contact deleted": {contactNotReady, false, simplex.ErrContactNotReady}, "sending, text too long": {largeMsg, false, simplex.ErrMessageTooLarge}, } { t.Run(name, func(t *testing.T) { t.Parallel() _, url := newFakeChat(t, map[string]string{ "/_get": tc.record, "/_send": tc.record, }) c, ctx := dial(t, url, nil) var err error if tc.read { _, err = c.ChatItems(ctx, 4, 20) } else { _, err = c.SendMessage(ctx, 4, "hi") } var cerr *simplex.CommandError if !errors.Is(err, tc.want) || !errors.As(err, &cerr) { t.Errorf("error = %v, want %v with the chat client's reason", err, tc.want) } }) } }