HTTP API: a chat's recent messages, and sending a message (closes #5)
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
This commit was merged in pull request #15.
This commit is contained in:
2026-09-29 07:33:36 +02:00
parent 8f0906d677
commit f53b666119
12 changed files with 1143 additions and 53 deletions
+14 -1
View File
@@ -1,5 +1,5 @@
// Package api is the bot's HTTP API, through which another program
// reads the bot's chats.
// reads the bot's chats and sends messages in them.
//
// Every request must carry the credential, as "Authorization: Bearer
// {credential}"; with no credential configured, every request is
@@ -47,6 +47,17 @@ const (
type ChatClient interface {
// Contacts returns the contacts of the user userID.
Contacts(ctx context.Context, userID int64) ([]simplex.Contact, error)
// ChatItems returns the last count items of the chat with a
// contact, oldest first.
ChatItems(
ctx context.Context, contactID int64, count int,
) ([]simplex.ChatItem, error)
// SendMessage sends a text message to a contact and returns it.
SendMessage(
ctx context.Context, contactID int64, text string,
) (simplex.ChatItem, error)
}
// Params configures New.
@@ -86,6 +97,8 @@ func New(p Params) *http.Server {
})
router.Route("/api/v1", func(r chi.Router) {
r.Get("/chats", h.handleChats())
r.Get("/chats/{id}/messages", h.handleMessages())
r.Post("/chats/{id}/messages", h.handleSend())
})
return &http.Server{
+36 -7
View File
@@ -22,19 +22,26 @@ const (
bearer = "Bearer " + credential
chatsPath = "/api/v1/chats"
messagesPath = "/api/v1/chats/3/messages"
unauthorized = `{"error":"unauthorized"}` + "\n"
)
var errChat = errors.New("sqlite: database is locked at /var/lib/simplexcalc")
// fakeClient stands in for the chat client. It answers with contacts,
// or with err, and remembers what it was asked.
// items or sent, or with an error: contactsErr from Contacts, err from
// the others. It remembers what it was asked.
type fakeClient struct {
contacts []simplex.Contact
err error
contacts []simplex.Contact
items []simplex.ChatItem
sent simplex.ChatItem
contactsErr error
err error
userID int64
hadDeadline bool
userID, contactID int64
count int
text string
hadDeadline bool
}
func (f *fakeClient) Contacts(
@@ -43,7 +50,25 @@ func (f *fakeClient) Contacts(
f.userID = userID
_, f.hadDeadline = ctx.Deadline()
return f.contacts, f.err
return f.contacts, f.contactsErr
}
func (f *fakeClient) ChatItems(
ctx context.Context, contactID int64, count int,
) ([]simplex.ChatItem, error) {
f.contactID, f.count = contactID, count
_, f.hadDeadline = ctx.Deadline()
return f.items, f.err
}
func (f *fakeClient) SendMessage(
ctx context.Context, contactID int64, text string,
) (simplex.ChatItem, error) {
f.contactID, f.text = contactID, text
_, f.hadDeadline = ctx.Deadline()
return f.sent, f.err
}
func newAPI(token string, client api.ChatClient) *http.Server {
@@ -168,6 +193,10 @@ func TestNoPathIsExempt(t *testing.T) {
http.MethodPost, chatsPath, bearer,
http.StatusMethodNotAllowed, `{"error":"method not allowed"}` + "\n",
},
{
http.MethodPost, messagesPath, "",
http.StatusUnauthorized, unauthorized,
},
} {
rec := request(t, srv, tc.method, tc.path, tc.auth)
if rec.Code != tc.want || rec.Body.String() != tc.body {
@@ -242,7 +271,7 @@ func TestHeaders(t *testing.T) {
{&fakeClient{}, chatsPath, bearer},
{&fakeClient{}, chatsPath, ""},
{&fakeClient{}, "/nothing", bearer},
{&fakeClient{err: errChat}, chatsPath, bearer},
{&fakeClient{contactsErr: errChat}, chatsPath, bearer},
} {
rec := request(t, newAPI(credential, tc.client),
http.MethodGet, tc.path, tc.auth)
+71 -20
View File
@@ -2,26 +2,55 @@ package api
import (
"cmp"
"context"
"net/http"
"slices"
"strconv"
"github.com/go-chi/chi/v5"
)
// handleChats lists the bot's chats, ordered by id. The bot talks to
// people only one to one, so its chats are its contacts, and a chat's
// id is its contact's.
func (h *handlers) handleChats() http.HandlerFunc {
type chat struct {
ID int64 `json:"id"`
DisplayName string `json:"display_name"`
ContactDeleted bool `json:"contact_deleted"`
// noSuchChat answers a request naming a chat the bot does not have.
const noSuchChat = "no such chat"
// chat is how the API shows a chat. The bot talks to people only one to
// one, so its chats are its contacts, and a chat's id is its contact's.
type chat struct {
ID int64 `json:"id"`
DisplayName string `json:"display_name"`
ContactDeleted bool `json:"contact_deleted"`
}
// chats returns the bot's chats, ordered by id. GET /api/v1/chats
// answers with this list, and chatID accepts only an id in it.
func (h *handlers) chats(ctx context.Context) ([]chat, error) {
contacts, err := h.client.Contacts(ctx, h.userID)
if err != nil {
return nil, err
}
chats := make([]chat, 0, len(contacts))
for _, c := range contacts {
chats = append(chats, chat{
ID: c.ContactID,
DisplayName: c.Profile.DisplayName,
ContactDeleted: c.Deleted(),
})
}
slices.SortFunc(chats, func(a, b chat) int { return cmp.Compare(a.ID, b.ID) })
return chats, nil
}
// handleChats lists the bot's chats.
func (h *handlers) handleChats() http.HandlerFunc {
type response struct {
Chats []chat `json:"chats"`
}
return func(w http.ResponseWriter, r *http.Request) {
contacts, err := h.client.Contacts(r.Context(), h.userID)
chats, err := h.chats(r.Context())
if err != nil {
h.log.Error("listing the chats", "error", err)
h.respondError(w, http.StatusInternalServerError,
@@ -30,17 +59,39 @@ func (h *handlers) handleChats() http.HandlerFunc {
return
}
chats := make([]chat, 0, len(contacts))
for _, c := range contacts {
chats = append(chats, chat{
ID: c.ContactID,
DisplayName: c.Profile.DisplayName,
ContactDeleted: c.Deleted(),
})
}
slices.SortFunc(chats, func(a, b chat) int { return cmp.Compare(a.ID, b.ID) })
h.respond(w, http.StatusOK, response{Chats: chats})
}
}
// chatID returns the chat id in the request's path, if it is one of the
// bot's chats. Otherwise it answers the request itself, 404, or 500 if
// the chats could not be read, and returns false.
//
// The id is looked up rather than passed to the chat client, which also
// keeps contact records that are not chats, such as the bot's own
// profile, and would read or send in them.
func (h *handlers) chatID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
h.respondError(w, http.StatusNotFound, noSuchChat)
return 0, false
}
chats, err := h.chats(r.Context())
if err != nil {
h.log.Error("looking up a chat", "error", err)
h.respondError(w, http.StatusInternalServerError,
"the chats could not be read")
return 0, false
}
if !slices.ContainsFunc(chats, func(c chat) bool { return c.ID == id }) {
h.respondError(w, http.StatusNotFound, noSuchChat)
return 0, false
}
return id, true
}
+1 -1
View File
@@ -54,7 +54,7 @@ func TestNoChats(t *testing.T) {
func TestChatsFailure(t *testing.T) {
t.Parallel()
rec := request(t, newAPI(credential, &fakeClient{err: errChat}),
rec := request(t, newAPI(credential, &fakeClient{contactsErr: errChat}),
http.MethodGet, chatsPath, bearer)
want := `{"error":"the chats could not be read"}` + "\n"
+203
View File
@@ -0,0 +1,203 @@
package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strconv"
"time"
"sneak.berlin/go/simplexcalc/internal/simplex"
)
const (
// defaultCount and maxCount bound how many of a chat's last items a
// request for its messages reads.
defaultCount = 20
maxCount = 100
)
// message is how the API shows a message, wherever it shows one.
type message struct {
ID int64 `json:"id"`
Direction string `json:"direction"`
Type string `json:"type"`
Text string `json:"text"`
Time time.Time `json:"time"`
}
// newMessage returns the message a chat item holds, and false for an
// item that holds none: the chat client also records events in a chat,
// such as the contact connecting.
func newMessage(item simplex.ChatItem) (message, bool) {
var direction string
switch item.Content.Type {
case "rcvMsgContent":
direction = "received"
case "sndMsgContent":
direction = "sent"
default:
return message{}, false
}
content := item.Content.MsgContent
if content == nil {
return message{}, false
}
return message{
ID: item.Meta.ItemID,
Direction: direction,
Type: content.Type,
Text: content.Text,
Time: item.Meta.ItemTs,
}, true
}
// handleMessages answers with the messages among a chat's last count
// items, oldest first.
func (h *handlers) handleMessages() http.HandlerFunc {
type response struct {
Messages []message `json:"messages"`
}
return func(w http.ResponseWriter, r *http.Request) {
contactID, ok := h.chatID(w, r)
if !ok {
return
}
// Not r.URL.Query, which drops a pair it cannot decode and so
// would turn count=1% into the default.
query, err := url.ParseQuery(r.URL.RawQuery)
if err != nil {
h.respondError(w, http.StatusBadRequest, "the query cannot be read")
return
}
count, ok := messageCount(query)
if !ok {
h.respondError(w, http.StatusBadRequest,
"count must be a whole number from 1 to "+strconv.Itoa(maxCount))
return
}
items, err := h.client.ChatItems(r.Context(), contactID, count)
if err != nil {
h.respondChatError(w, err, "reading a chat's messages",
"the messages could not be read")
return
}
messages := make([]message, 0, len(items))
for _, item := range items {
if m, ok := newMessage(item); ok {
messages = append(messages, m)
}
}
h.respond(w, http.StatusOK, response{Messages: messages})
}
}
// handleSend sends the text in the request's body to a chat, and answers
// with the message sent.
func (h *handlers) handleSend() http.HandlerFunc {
type request struct {
Text string `json:"text"`
}
type response struct {
Message message `json:"message"`
}
return func(w http.ResponseWriter, r *http.Request) {
contactID, ok := h.chatID(w, r)
if !ok {
return
}
body, err := io.ReadAll(r.Body)
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
h.respondError(w, http.StatusRequestEntityTooLarge, "the body is too large")
return
}
var req request
if err != nil || json.Unmarshal(body, &req) != nil {
h.respondError(w, http.StatusBadRequest,
`the body must be JSON such as {"text":"hello"}`)
return
}
if req.Text == "" {
h.respondError(w, http.StatusBadRequest, "text is empty")
return
}
item, err := h.client.SendMessage(r.Context(), contactID, req.Text)
if err != nil {
h.respondChatError(w, err, "sending a message",
"the message could not be sent")
return
}
sent, ok := newMessage(item)
if !ok {
h.log.Error("sending a message: the answer holds no message",
"content_type", item.Content.Type)
h.respondError(w, http.StatusInternalServerError,
"the chat client's answer could not be read")
return
}
h.respond(w, http.StatusCreated, response{Message: sent})
}
}
// respondChatError answers a request the chat client did not serve: 404
// for a contact removed after chatID found it, 409 for one who cannot
// receive messages, 413 for a text too long to send, and 500 with
// sentence for anything else, which is logged as what.
func (h *handlers) respondChatError(
w http.ResponseWriter, err error, what, sentence string,
) {
switch {
case errors.Is(err, simplex.ErrNoContact):
h.respondError(w, http.StatusNotFound, noSuchChat)
case errors.Is(err, simplex.ErrContactNotReady):
h.respondError(w, http.StatusConflict, "the contact cannot receive messages")
case errors.Is(err, simplex.ErrMessageTooLarge):
h.respondError(w, http.StatusRequestEntityTooLarge, "the text is too long")
default:
h.log.Error(what, "error", err)
h.respondError(w, http.StatusInternalServerError, sentence)
}
}
// messageCount returns the query's count, defaultCount if it has none,
// and false if it is anything but a whole number from 1 to maxCount.
func messageCount(query url.Values) (int, bool) {
if !query.Has("count") {
return defaultCount, true
}
count, err := strconv.Atoi(query.Get("count"))
return count, err == nil && count >= 1 && count <= maxCount
}
+367
View File
@@ -0,0 +1,367 @@
package api_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"sneak.berlin/go/simplexcalc/internal/simplex"
)
// Answers more than one test expects.
const (
noSuchChat = `{"error":"no such chat"}` + "\n"
badCount = `{"error":"count must be a whole number from 1 to 100"}` + "\n"
notJSON = `{"error":"the body must be JSON such as {\"text\":\"hello\"}"}` + "\n"
)
// oneChat returns the bot's contacts in these tests: one, whose chat is
// the one messagesPath names.
func oneChat() []simplex.Contact {
return []simplex.Contact{{ContactID: 3, Status: "active"}}
}
// chatItem is a chat item decoded from a record shaped as the chat
// client sends it, reduced to the fields the API reads.
func chatItem(t *testing.T, record string) simplex.ChatItem {
t.Helper()
var item simplex.ChatItem
err := json.Unmarshal([]byte(record), &item)
if err != nil {
t.Fatalf("decoding %s: %v", record, err)
}
return item
}
// post sends srv a POST of body to path, with the credential.
func post(
t *testing.T, srv *http.Server, path, body string,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, path,
strings.NewReader(body))
req.Header.Set("Authorization", bearer)
rec := httptest.NewRecorder()
srv.Handler.ServeHTTP(rec, req)
return rec
}
// TestMessages: the messages among the chat's last 20 items come back
// oldest first, without the events the chat client records in a chat,
// and the chat client is asked with a deadline.
func TestMessages(t *testing.T) {
t.Parallel()
client := &fakeClient{contacts: oneChat(), items: []simplex.ChatItem{
chatItem(t, `{"meta":{"itemId":7,"itemTs":"2026-09-29T03:13:46.521359978Z"},
"content":{"type":"rcvChatFeature","feature":"calls"}}`),
chatItem(t, `{"meta":{"itemId":9,"itemTs":"2026-09-29T03:14:34Z"},
"content":{"type":"rcvMsgContent",
"msgContent":{"type":"text","text":"2 + 2"}}}`),
chatItem(t, `{"meta":{"itemId":10,"itemTs":"2026-09-29T03:14:35.101Z"},
"content":{"type":"sndMsgContent","msgContent":{"type":"text","text":"4"}}}`),
chatItem(t, `{"meta":{"itemId":11,"itemTs":"2026-09-29T03:15:02Z"},
"content":{"type":"rcvMsgContent","msgContent":{"type":"image",
"text":"a picture","image":"data:image/jpg;base64,/9j/4AAQ"}}}`),
}}
rec := request(t, newAPI(credential, client), http.MethodGet, messagesPath, bearer)
want := `{"messages":[` +
`{"id":9,"direction":"received","type":"text","text":"2 + 2",` +
`"time":"2026-09-29T03:14:34Z"},` +
`{"id":10,"direction":"sent","type":"text","text":"4",` +
`"time":"2026-09-29T03:14:35.101Z"},` +
`{"id":11,"direction":"received","type":"image","text":"a picture",` +
`"time":"2026-09-29T03:15:02Z"}]}` + "\n"
if rec.Code != http.StatusOK || rec.Body.String() != want {
t.Errorf("response = %d %q, want 200 %q", rec.Code, rec.Body.String(), want)
}
if client.contactID != 3 || client.count != 20 || !client.hadDeadline {
t.Errorf("the chat client was asked for %d items of chat %d, deadline %v; "+
"want 20 of chat 3 with a deadline",
client.count, client.contactID, client.hadDeadline)
}
}
// TestNoMessages: a chat without messages is an empty list, not null.
func TestNoMessages(t *testing.T) {
t.Parallel()
rec := request(t, newAPI(credential, &fakeClient{contacts: oneChat()}),
http.MethodGet, messagesPath, bearer)
want := `{"messages":[]}` + "\n"
if rec.Code != http.StatusOK || rec.Body.String() != want {
t.Errorf("response = %d %q, want 200 %q", rec.Code, rec.Body.String(), want)
}
}
// TestMessagesCount: count is a whole number from 1 to 100, and 20 when
// absent. Anything else is refused before the chat's items are read.
func TestMessagesCount(t *testing.T) {
t.Parallel()
// The number of items the chat client is asked for; 0 for refused.
for query, want := range map[string]int{
"": 20,
"?count=1": 1,
"?count=100": 100,
"?count=%35": 5,
"?count=0": 0,
"?count=101": 0,
"?count=-1": 0,
"?count=2.5": 0,
"?count=ten": 0,
"?count=": 0,
} {
t.Run(query, func(t *testing.T) {
t.Parallel()
client := &fakeClient{contacts: oneChat()}
rec := request(t, newAPI(credential, client),
http.MethodGet, messagesPath+query, bearer)
if want == 0 {
if rec.Code != http.StatusBadRequest || rec.Body.String() != badCount ||
client.count != 0 {
t.Errorf("response = %d %q, asked for %d items; "+
"want 400 %q and nothing asked", rec.Code, rec.Body.String(),
client.count, badCount)
}
return
}
if rec.Code != http.StatusOK || client.count != want {
t.Errorf("response = %d, asked for %d items; want 200 and %d",
rec.Code, client.count, want)
}
})
}
}
// TestMessagesUnreadableQuery: a query that cannot be decoded is refused
// with a sentence that says so, whether or not the bad part is count.
// The last query has 10,001 parts, more than Go's url.ParseQuery takes.
func TestMessagesUnreadableQuery(t *testing.T) {
t.Parallel()
want := `{"error":"the query cannot be read"}` + "\n"
for _, query := range []string{
"?count=1%", "?count=5&x=%zz", "?x=%zz", "?count=5;x=1",
"?count=5" + strings.Repeat("&", 10000),
} {
client := &fakeClient{contacts: oneChat()}
rec := request(t, newAPI(credential, client),
http.MethodGet, messagesPath+query, bearer)
if rec.Code != http.StatusBadRequest || rec.Body.String() != want ||
client.count != 0 {
t.Errorf("%s: response = %d %q, asked for %d items; "+
"want 400 %q and nothing asked",
query, rec.Code, rec.Body.String(), client.count, want)
}
}
}
// TestNoSuchChat: an id that GET /api/v1/chats does not list gets 404,
// whether reading messages or sending one, and nothing is read or sent.
// That includes 1 and 2, contact records the chat client keeps on a new
// profile and would read or send in. A contact the chat client no
// longer has when asked also gets 404.
func TestNoSuchChat(t *testing.T) {
t.Parallel()
for _, id := range []string{
"1", "2", "4", "tester", "0", "-3", "3.5", "99999999999999999999",
} {
client := &fakeClient{contacts: oneChat()}
srv := newAPI(credential, client)
path := "/api/v1/chats/" + id + "/messages"
for _, rec := range []*httptest.ResponseRecorder{
request(t, srv, http.MethodGet, path, bearer),
post(t, srv, path, `{"text":"hello"}`),
} {
if rec.Code != http.StatusNotFound || rec.Body.String() != noSuchChat {
t.Errorf("chat %q: response = %d %q, want 404 %q",
id, rec.Code, rec.Body.String(), noSuchChat)
}
}
if client.contactID != 0 {
t.Errorf("chat %q: the chat was read or sent to", id)
}
}
srv := newAPI(credential, &fakeClient{contacts: oneChat(), err: simplex.ErrNoContact})
for _, rec := range []*httptest.ResponseRecorder{
request(t, srv, http.MethodGet, messagesPath, bearer),
post(t, srv, messagesPath, `{"text":"hello"}`),
} {
if rec.Code != http.StatusNotFound || rec.Body.String() != noSuchChat {
t.Errorf("no contact: response = %d %q, want 404 %q",
rec.Code, rec.Body.String(), noSuchChat)
}
}
}
// TestChatLookupFailure: when the chats cannot be read to look the id
// up, the answer is 500 and nothing is read or sent.
func TestChatLookupFailure(t *testing.T) {
t.Parallel()
client := &fakeClient{contactsErr: errChat}
srv := newAPI(credential, client)
want := `{"error":"the chats could not be read"}` + "\n"
for _, rec := range []*httptest.ResponseRecorder{
request(t, srv, http.MethodGet, messagesPath, bearer),
post(t, srv, messagesPath, `{"text":"hello"}`),
} {
if rec.Code != http.StatusInternalServerError || rec.Body.String() != want {
t.Errorf("response = %d %q, want 500 %q", rec.Code, rec.Body.String(), want)
}
}
if client.contactID != 0 {
t.Error("the chat was read or sent to")
}
}
// TestMessagesFailure: when the chat client fails, the answer says so in
// a chosen sentence, never in the error's own text.
func TestMessagesFailure(t *testing.T) {
t.Parallel()
rec := request(t, newAPI(credential, &fakeClient{contacts: oneChat(), err: errChat}),
http.MethodGet, messagesPath, bearer)
want := `{"error":"the messages could not be read"}` + "\n"
if rec.Code != http.StatusInternalServerError || rec.Body.String() != want {
t.Errorf("response = %d %q, want 500 %q", rec.Code, rec.Body.String(), want)
}
}
// TestSend: the text goes to the chat's contact, with a deadline, and
// the answer is 201 with the message as sent.
func TestSend(t *testing.T) {
t.Parallel()
client := &fakeClient{contacts: oneChat(), sent: chatItem(t, `{"meta":{"itemId":12,
"itemTs":"2026-09-29T03:14:43.519552587Z"},"content":{"type":"sndMsgContent",
"msgContent":{"type":"text","text":"hello"}}}`)}
rec := post(t, newAPI(credential, client), messagesPath, `{"text":"hello"}`)
want := `{"message":{"id":12,"direction":"sent","type":"text","text":"hello",` +
`"time":"2026-09-29T03:14:43.519552587Z"}}` + "\n"
if rec.Code != http.StatusCreated || rec.Body.String() != want {
t.Errorf("response = %d %q, want 201 %q", rec.Code, rec.Body.String(), want)
}
if client.contactID != 3 || client.text != "hello" || !client.hadDeadline {
t.Errorf("the chat client was asked to send %q to chat %d, deadline %v; "+
`want "hello" to chat 3 with a deadline`,
client.text, client.contactID, client.hadDeadline)
}
}
// TestSendBadBody: a body that is not JSON with a text, or that is over
// 64 KiB, is refused before anything is sent.
func TestSendBadBody(t *testing.T) {
t.Parallel()
textEmpty := `{"error":"text is empty"}` + "\n"
tooLarge := `{"error":"the body is too large"}` + "\n"
for name, tc := range map[string]struct {
body string
status int
answer string
}{
"empty": {"", http.StatusBadRequest, notJSON},
"not JSON": {"hello", http.StatusBadRequest, notJSON},
"not an object": {`["hello"]`, http.StatusBadRequest, notJSON},
"text not a string": {`{"text":5}`, http.StatusBadRequest, notJSON},
"more after it": {`{"text":"hello"} {}`, http.StatusBadRequest, notJSON},
"no text": {`{}`, http.StatusBadRequest, textEmpty},
"empty text": {`{"text":""}`, http.StatusBadRequest, textEmpty},
"over 64 KiB": {
`{"text":"` + strings.Repeat("a", 64<<10) + `"}`,
http.StatusRequestEntityTooLarge, tooLarge,
},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
client := &fakeClient{contacts: oneChat()}
rec := post(t, newAPI(credential, client), messagesPath, tc.body)
if rec.Code != tc.status || rec.Body.String() != tc.answer {
t.Errorf("response = %d %q, want %d %q",
rec.Code, rec.Body.String(), tc.status, tc.answer)
}
if client.contactID != 0 {
t.Error("the chat client was asked to send")
}
})
}
}
// TestSendRefused: a send the chat client refuses, or answers with no
// message, gets an answer chosen for the reason, never the chat
// client's own words.
func TestSendRefused(t *testing.T) {
t.Parallel()
for name, tc := range map[string]struct {
client *fakeClient
status int
answer string
}{
"contact deleted": {
&fakeClient{contacts: oneChat(), err: simplex.ErrContactNotReady},
http.StatusConflict, `{"error":"the contact cannot receive messages"}`,
},
"text too long": {
&fakeClient{contacts: oneChat(), err: simplex.ErrMessageTooLarge},
http.StatusRequestEntityTooLarge, `{"error":"the text is too long"}`,
},
"anything else": {
&fakeClient{contacts: oneChat(), err: errChat},
http.StatusInternalServerError, `{"error":"the message could not be sent"}`,
},
"no message in the answer": {
&fakeClient{contacts: oneChat(), sent: chatItem(t, `{"meta":{"itemId":13},
"content":{"type":"sndDirectEvent"}}`)},
http.StatusInternalServerError,
`{"error":"the chat client's answer could not be read"}`,
},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
rec := post(t, newAPI(credential, tc.client), messagesPath, `{"text":"hello"}`)
if rec.Code != tc.status || rec.Body.String() != tc.answer+"\n" {
t.Errorf("response = %d %q, want %d %q",
rec.Code, rec.Body.String(), tc.status, tc.answer)
}
})
}
}