check / check (push) Successful in 1m9s
The README listed the queries that GET /api/v1/chats/{id}/messages
cannot read as if the list were complete, but a query of more than
10,000 parts separated by & is refused the same way, because Go's
url.ParseQuery takes no more. The README now states the rule, that a
query the server cannot read gets 400 with "the query cannot be read",
and gives the three cases as examples. TestMessagesUnreadableQuery
covers the 10,000-part case.
Model: opus-5-5
368 lines
12 KiB
Go
368 lines
12 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"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
})
|
|
}
|
|
}
|