check / check (push) Successful in 1m5s
`GET /api/v1/chats/{id}/messages?count=N` returns a chat's recent messages, oldest first (`count` 1 to 100, default 20), and `POST` to the same path sends a text message and returns it once the chat client has taken it. Both answer `404` for any id the chats list does not show, looked up in the same contact list. The message record is defined once, for the webhooks to reuse.
Disclosures: items are read five at a time, because one item can be many times its text and 100 at once could pass the 16 MiB read limit; text too long gets `413`, a contact who deleted the chat `409` (unverified for one still connecting); a query the server cannot read gets its own `400`.
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)
|
|
}
|
|
})
|
|
}
|
|
}
|