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. 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", } { 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) } }) } }