check / check (push) Successful in 1m21s
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
215 lines
7.5 KiB
Go
215 lines
7.5 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|