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
310 lines
9.8 KiB
Go
310 lines
9.8 KiB
Go
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"
|
|
)
|
|
|
|
// 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{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{}),
|
|
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 client is asked.
|
|
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,
|
|
"?count=1%": 0,
|
|
} {
|
|
t.Run(query, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := &fakeClient{}
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNoSuchChat: a chat id that is not a positive whole number gets 404
|
|
// without asking the chat client, and so does one it has no contact
|
|
// for, whether reading messages or sending one.
|
|
func TestNoSuchChat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, id := range []string{"tester", "0", "-3", "3.5", "99999999999999999999"} {
|
|
client := &fakeClient{}
|
|
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 client was asked", id)
|
|
}
|
|
}
|
|
|
|
srv := newAPI(credential, &fakeClient{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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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{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{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{}
|
|
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{err: simplex.ErrContactNotReady},
|
|
http.StatusConflict, `{"error":"the contact cannot receive messages"}`,
|
|
},
|
|
"text too long": {
|
|
&fakeClient{err: simplex.ErrMessageTooLarge},
|
|
http.StatusRequestEntityTooLarge, `{"error":"the text is too long"}`,
|
|
},
|
|
"anything else": {
|
|
&fakeClient{err: errChat},
|
|
http.StatusInternalServerError, `{"error":"the message could not be sent"}`,
|
|
},
|
|
"no message in the answer": {
|
|
&fakeClient{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)
|
|
}
|
|
})
|
|
}
|
|
}
|