feat(messages): команды чата, личные сообщения и лимиты
AGENT.md 7.6: - команды разбираются на сервере: /me (блок действия), /whisper и /wisper (шёпот), /scream (громкий блок), «//» экранирует слэш, неизвестная команда остаётся обычным текстом, пустой текст у команды — 422; - /ls <логин> <текст> — личное сообщение: тип private, адресат в mentions, получатель проверяется на участие в сервере и доступ к комнате; сообщение не отдаётся посторонним ни в истории, ни в поиске (и FTS, и подстрочном) и доставляется в Gateway только автору и адресатам; - лимиты: 10 правок в минуту, 20 уникальных реакций на сообщение, 50 закреплений на комнату, не более 10 упоминаний, @everyone/@here только с MENTION_EVERYONE; - тесты: команды и экранирование, изоляция личного сообщения (история, поиск, третий участник), лимит правок и @everyone.
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"glchat/internal/permissions"
|
||||
@@ -622,3 +623,162 @@ func TestFuzzySearch(t *testing.T) {
|
||||
t.Fatalf("поиск «другое» вернул %d результатов: %v", len(results), results)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCommands проверяет команды /me, /whisper, /scream и экранирование.
|
||||
func TestChatCommands(t *testing.T) {
|
||||
f := newMessagingFixture(t)
|
||||
|
||||
cases := []struct {
|
||||
input string
|
||||
want string
|
||||
kind string
|
||||
}{
|
||||
{`{"content":"/me танцует"}`, "танцует", "action"},
|
||||
{`{"content":"/whisper тихо"}`, "тихо", "whisper"},
|
||||
{`{"content":"/scream ГРОМКО"}`, "ГРОМКО", "scream"},
|
||||
{`{"content":"/wisper тихо"}`, "тихо", "whisper"},
|
||||
{`{"content":"/unknown команда"}`, "/unknown команда", "default"},
|
||||
{`{"content":"//не команда"}`, "/не команда", "default"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
|
||||
tc.input, f.ownerCookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s → %d, body = %s", tc.input, rec.Code, rec.Body.String())
|
||||
}
|
||||
message := decodeResponse[struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
} `json:"message"`
|
||||
}](t, rec)
|
||||
if message.Message.Content != tc.want || message.Message.Type != tc.kind {
|
||||
t.Fatalf("%s → content=%q type=%q, ожидалось %q/%q",
|
||||
tc.input, message.Message.Content, message.Message.Type, tc.want, tc.kind)
|
||||
}
|
||||
}
|
||||
|
||||
// Команда без текста — ошибка.
|
||||
empty := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
|
||||
`{"content":"/me"}`, f.ownerCookie)
|
||||
if empty.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("/me без текста = %d, want 422", empty.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrivateMessageVisibility проверяет /ls: сообщение видят только автор и адресат.
|
||||
func TestPrivateMessageVisibility(t *testing.T) {
|
||||
f := newMessagingFixture(t)
|
||||
memberLogin := "msg_member"
|
||||
|
||||
sent := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
|
||||
`{"content":"/ls `+memberLogin+` секретное сообщение"}`, f.ownerCookie)
|
||||
if sent.Code != http.StatusOK {
|
||||
t.Fatalf("private message = %d, body = %s", sent.Code, sent.Body.String())
|
||||
}
|
||||
message := decodeResponse[struct {
|
||||
Message struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
Mentions []string `json:"mentions"`
|
||||
} `json:"message"`
|
||||
}](t, sent)
|
||||
if message.Message.Type != "private" || message.Message.Content != "секретное сообщение" {
|
||||
t.Fatalf("private message payload = %+v", message.Message)
|
||||
}
|
||||
if len(message.Message.Mentions) != 1 || message.Message.Mentions[0] != f.memberID {
|
||||
t.Fatalf("получатель = %+v", message.Message.Mentions)
|
||||
}
|
||||
|
||||
// Автор и адресат видят сообщение в истории.
|
||||
ownerHistory := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/messages", "", f.ownerCookie)
|
||||
memberHistory := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/messages", "", f.memberCookie)
|
||||
for name, rec := range map[string]*httptest.ResponseRecorder{"автор": ownerHistory, "адресат": memberHistory} {
|
||||
list := decodeResponse[struct {
|
||||
Messages []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"messages"`
|
||||
}](t, rec)
|
||||
found := false
|
||||
for _, item := range list.Messages {
|
||||
if item.ID == message.Message.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%s не видит личное сообщение", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Третий участник не видит ни в истории, ни в поиске.
|
||||
thirdCookie := registerAndLogin(t, f.srv, "msg_third", "msg-third@example.com")
|
||||
doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/join", "", thirdCookie)
|
||||
thirdHistory := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/messages", "", thirdCookie)
|
||||
list := decodeResponse[struct {
|
||||
Messages []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"messages"`
|
||||
}](t, thirdHistory)
|
||||
for _, item := range list.Messages {
|
||||
if item.ID == message.Message.ID {
|
||||
t.Fatal("посторонний увидел личное сообщение в истории")
|
||||
}
|
||||
}
|
||||
thirdSearch := doJSON(t, f.srv, http.MethodGet,
|
||||
"/api/v1/channels/"+f.openChannel+"/messages/search?q="+url.QueryEscape("секретное"), "", thirdCookie)
|
||||
results := decodeResponse[struct {
|
||||
Messages []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"messages"`
|
||||
}](t, thirdSearch)
|
||||
if len(results.Messages) != 0 {
|
||||
leaked, _ := f.srv.store.GetMessage(t.Context(), guildIDOf(t, results.Messages[0].ID))
|
||||
t.Fatalf("поиск отдал личное сообщение постороннему: id=%s type=%s author=%v mentions=%v (ожидалось private id=%s)",
|
||||
results.Messages[0].ID, leaked.Type, leaked.AuthorID, leaked.Mentions, message.Message.ID)
|
||||
}
|
||||
|
||||
// Несуществующий получатель — понятная ошибка.
|
||||
bad := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
|
||||
`{"content":"/ls nobody_here текст"}`, f.ownerCookie)
|
||||
if bad.Code != http.StatusNotFound {
|
||||
t.Fatalf("/ls с неизвестным логином = %d, want 404", bad.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessageLimits проверяет лимиты пинов и правок (AGENT.md 7.6).
|
||||
func TestMessageLimits(t *testing.T) {
|
||||
f := newMessagingFixture(t)
|
||||
|
||||
created := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
|
||||
`{"content":"для правок"}`, f.memberCookie)
|
||||
message := decodeResponse[struct {
|
||||
Message struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"message"`
|
||||
}](t, created)
|
||||
|
||||
// 10 правок в минуту: одиннадцатая — 429.
|
||||
var last *httptest.ResponseRecorder
|
||||
for i := range 11 {
|
||||
last = doJSON(t, f.srv, http.MethodPatch,
|
||||
"/api/v1/channels/"+f.openChannel+"/messages/"+message.Message.ID,
|
||||
`{"content":"правка `+strconv.Itoa(i)+`"}`, f.memberCookie)
|
||||
}
|
||||
if last.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("одиннадцатая правка = %d, want 429", last.Code)
|
||||
}
|
||||
|
||||
// @everyone без MENTION_EVERYONE запрещён у участника с ролью по умолчанию.
|
||||
everyone := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
|
||||
`{"content":"@everyone привет"}`, f.memberCookie)
|
||||
if everyone.Code != http.StatusForbidden {
|
||||
t.Fatalf("@everyone без права = %d, want 403", everyone.Code)
|
||||
}
|
||||
// Владелец (ADMINISTRATOR) может.
|
||||
ownerEveryone := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
|
||||
`{"content":"@everyone привет"}`, f.ownerCookie)
|
||||
if ownerEveryone.Code != http.StatusOK {
|
||||
t.Fatalf("@everyone владельцем = %d, body = %s", ownerEveryone.Code, ownerEveryone.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user