package server import ( "bytes" "encoding/json" "io" "mime/multipart" "net/http" "net/http/httptest" "net/url" "strconv" "testing" "glchat/internal/permissions" "glchat/internal/store" ) // storeOverride скрывает комнату от роли @user. func storeOverride(channelID, roleID uint64) store.ChannelOverride { return store.ChannelOverride{ ChannelID: channelID, TargetType: "role", TargetID: roleID, Deny: uint64(permissions.ViewChannel), } } // messagingFixture создаёт сервер с владельцем, участником и двумя комнатами: // «общий» (видна всем) и «тайная» (скрыта от роли @user оверрайдом). type messagingFixture struct { srv *Server ownerCookie *http.Cookie memberCookie *http.Cookie guildID string openChannel string secretID string memberID string ownerID string } func newMessagingFixture(t *testing.T) *messagingFixture { t.Helper() srv, _ := newTestServer(t) ownerCookie := registerAndLogin(t, srv, "msg_owner", "msg-owner@example.com") memberCookie := registerAndLogin(t, srv, "msg_member", "msg-member@example.com") created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Чат"}`, ownerCookie) guild := decodeResponse[struct { Guild struct { ID string `json:"id"` Roles []struct { ID string `json:"id"` IsDefault bool `json:"is_default"` } `json:"roles"` Channels []struct { ID string `json:"id"` Name string `json:"name"` } `json:"channels"` } `json:"guild"` }](t, created) doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie) secretRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels", `{"name":"тайная","type":"text"}`, ownerCookie) secret := decodeResponse[struct { Channel struct { ID string `json:"id"` } `json:"channel"` }](t, secretRec) database := srv.store var defaultRoleID uint64 for _, role := range guild.Guild.Roles { if role.IsDefault { defaultRoleID = guildIDOf(t, role.ID) } } if err := database.SetChannelOverride(t.Context(), storeOverride(guildIDOf(t, secret.Channel.ID), defaultRoleID)); err != nil { t.Fatalf("SetChannelOverride: %v", err) } srv.perms.InvalidateGuild(guildIDOf(t, guild.Guild.ID)) owner, err := srv.auth.UserByEmail(t.Context(), "msg-owner@example.com") if err != nil { t.Fatalf("UserByEmail owner: %v", err) } member, err := srv.auth.UserByEmail(t.Context(), "msg-member@example.com") if err != nil { t.Fatalf("UserByEmail member: %v", err) } return &messagingFixture{ srv: srv, ownerCookie: ownerCookie, memberCookie: memberCookie, guildID: guild.Guild.ID, openChannel: guild.Guild.Channels[0].ID, secretID: secret.Channel.ID, memberID: formatSnowflake(member.ID), ownerID: formatSnowflake(owner.ID), } } func TestMessageLifecycle(t *testing.T) { f := newMessagingFixture(t) // Отправка: содержимое нормализуется, ответ содержит автора и время. sent := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", `{"content":" привет, мир "}`, f.memberCookie) if sent.Code != http.StatusOK { t.Fatalf("create message = %d, body = %s", sent.Code, sent.Body.String()) } message := decodeResponse[struct { Message struct { ID string `json:"id"` Content string `json:"content"` AuthorID string `json:"author_id"` ChannelID string `json:"channel_id"` } `json:"message"` }](t, sent) if message.Message.Content != "привет, мир" { t.Fatalf("content = %q, want trimmed", message.Message.Content) } if message.Message.AuthorID != f.memberID || message.Message.ChannelID != f.openChannel { t.Fatalf("unexpected message: %+v", message.Message) } // Пустое сообщение без вложений запрещено. empty := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", `{"content":" "}`, f.memberCookie) if empty.Code != http.StatusUnprocessableEntity { t.Fatalf("empty message = %d, want 422", empty.Code) } // Правка автором. edited := doJSON(t, f.srv, http.MethodPatch, "/api/v1/channels/"+f.openChannel+"/messages/"+message.Message.ID, `{"content":"поправлено"}`, f.memberCookie) if edited.Code != http.StatusOK { t.Fatalf("edit message = %d, body = %s", edited.Code, edited.Body.String()) } updated := decodeResponse[struct { Message struct { Content string `json:"content"` EditedAt string `json:"edited_at"` } `json:"message"` }](t, edited) if updated.Message.Content != "поправлено" || updated.Message.EditedAt == "" { t.Fatalf("unexpected edited message: %+v", updated.Message) } // История отдаётся от новых к старым. second := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", `{"content":"второе"}`, f.ownerCookie) if second.Code != http.StatusOK { t.Fatalf("second message = %d", second.Code) } history := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/messages", "", f.memberCookie) list := decodeResponse[struct { Messages []struct { Content string `json:"content"` } `json:"messages"` }](t, history) if len(list.Messages) != 2 || list.Messages[0].Content != "второе" { t.Fatalf("history = %+v", list.Messages) } // Поиск по FTS5 находит сообщение. search := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/messages/search?q=поправлено", "", f.memberCookie) if search.Code != http.StatusOK { t.Fatalf("search = %d, body = %s", search.Code, search.Body.String()) } found := decodeResponse[struct { Messages []struct { ID string `json:"id"` } `json:"messages"` }](t, search) if len(found.Messages) != 1 || found.Messages[0].ID != message.Message.ID { t.Fatalf("search results = %+v", found.Messages) } // Реакции: поставили, увидели в истории, сняли. addReaction := doJSON(t, f.srv, http.MethodPut, "/api/v1/channels/"+f.openChannel+"/messages/"+message.Message.ID+"/reactions/👍", "", f.ownerCookie) if addReaction.Code != http.StatusOK { t.Fatalf("add reaction = %d, body = %s", addReaction.Code, addReaction.Body.String()) } afterReaction := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/messages", "", f.ownerCookie) reactions := decodeResponse[struct { Messages []struct { Reactions []struct { Emoji string `json:"emoji"` Count int `json:"count"` Me bool `json:"me"` } `json:"reactions"` } `json:"messages"` }](t, afterReaction) var foundReaction bool for _, item := range reactions.Messages { for _, reaction := range item.Reactions { if reaction.Emoji == "👍" && reaction.Count == 1 && reaction.Me { foundReaction = true } } } if !foundReaction { t.Fatalf("reaction not visible: %+v", reactions.Messages) } removeReaction := doJSON(t, f.srv, http.MethodDelete, "/api/v1/channels/"+f.openChannel+"/messages/"+message.Message.ID+"/reactions/👍", "", f.ownerCookie) if removeReaction.Code != http.StatusOK { t.Fatalf("remove reaction = %d", removeReaction.Code) } // Закрепление требует MANAGE_MESSAGES: у участника его нет. deniedPin := doJSON(t, f.srv, http.MethodPut, "/api/v1/channels/"+f.openChannel+"/pins/"+message.Message.ID, "", f.memberCookie) if deniedPin.Code != http.StatusForbidden { t.Fatalf("member pin = %d, want 403", deniedPin.Code) } pin := doJSON(t, f.srv, http.MethodPut, "/api/v1/channels/"+f.openChannel+"/pins/"+message.Message.ID, "", f.ownerCookie) if pin.Code != http.StatusOK { t.Fatalf("owner pin = %d, body = %s", pin.Code, pin.Body.String()) } pins := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/pins", "", f.memberCookie) pinned := decodeResponse[struct { Messages []struct { ID string `json:"id"` Pinned bool `json:"pinned"` } `json:"messages"` }](t, pins) if len(pinned.Messages) != 1 || !pinned.Messages[0].Pinned { t.Fatalf("pins = %+v", pinned.Messages) } // Удаление: чужое сообщение участник удалить не может, модератор — может. deniedDelete := doJSON(t, f.srv, http.MethodDelete, "/api/v1/channels/"+f.openChannel+"/messages/"+message.Message.ID, "", f.ownerCookie) if deniedDelete.Code != http.StatusOK { t.Fatalf("owner delete = %d, body = %s", deniedDelete.Code, deniedDelete.Body.String()) } } func TestMessagesRespectChannelVisibility(t *testing.T) { f := newMessagingFixture(t) // Участник не видит скрытую комнату: список и отправка дают 404. list := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.secretID+"/messages", "", f.memberCookie) if list.Code != http.StatusNotFound { t.Fatalf("hidden channel list = %d, want 404", list.Code) } send := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.secretID+"/messages", `{"content":"секрет"}`, f.memberCookie) if send.Code != http.StatusNotFound { t.Fatalf("hidden channel send = %d, want 404", send.Code) } // Владелец пишет в скрытую комнату и читает её. ownerSend := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.secretID+"/messages", `{"content":"для своих"}`, f.ownerCookie) if ownerSend.Code != http.StatusOK { t.Fatalf("owner send to hidden = %d, body = %s", ownerSend.Code, ownerSend.Body.String()) } // Администратор инстанса видит всё (AGENT.md 7.19). adminCookie := registerAndLogin(t, f.srv, "msg_admin", "msg-admin@example.com") promoteAdmin(t, f.srv, "msg-admin@example.com") adminList := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.secretID+"/messages", "", adminCookie) if adminList.Code != http.StatusOK { t.Fatalf("instance admin list = %d, want 200", adminList.Code) } } func TestSlowmodeLimitsMessages(t *testing.T) { f := newMessagingFixture(t) // Включаем slowmode 60 секунд в комнате. update := doJSON(t, f.srv, http.MethodPatch, "/api/v1/guilds/"+f.guildID+"/channels/"+f.openChannel, `{"slowmode_seconds":60}`, f.ownerCookie) if update.Code != http.StatusOK { t.Fatalf("set slowmode = %d, body = %s", update.Code, update.Body.String()) } first := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", `{"content":"первое"}`, f.memberCookie) if first.Code != http.StatusOK { t.Fatalf("first message = %d, body = %s", first.Code, first.Body.String()) } second := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", `{"content":"второе"}`, f.memberCookie) if second.Code != http.StatusTooManyRequests { t.Fatalf("second message = %d, want 429", second.Code) } if code := errorCodeOf(t, second); code != "rate_limited" { t.Fatalf("error code = %q, want rate_limited", code) } // Модератор (MANAGE_MESSAGES) и администратор инстанса slowmode обходят. owner := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", `{"content":"модератор пишет"}`, f.ownerCookie) if owner.Code != http.StatusOK { t.Fatalf("owner message with slowmode = %d, body = %s", owner.Code, owner.Body.String()) } } func TestMentionsAndReplies(t *testing.T) { f := newMessagingFixture(t) parent := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", `{"content":"родитель"}`, f.ownerCookie) parentMessage := decodeResponse[struct { Message struct { ID string `json:"id"` } `json:"message"` }](t, parent) reply := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", `{"content":"ответ <@`+f.memberID+`>","reply_to_id":"`+parentMessage.Message.ID+`"}`, f.ownerCookie) if reply.Code != http.StatusOK { t.Fatalf("reply = %d, body = %s", reply.Code, reply.Body.String()) } payload := decodeResponse[struct { Message struct { ReplyToID string `json:"reply_to_id"` Mentions []string `json:"mentions"` } `json:"message"` }](t, reply) if payload.Message.ReplyToID != parentMessage.Message.ID { t.Fatalf("reply_to_id = %q", payload.Message.ReplyToID) } if len(payload.Message.Mentions) != 1 || payload.Message.Mentions[0] != f.memberID { t.Fatalf("mentions = %+v, want member", payload.Message.Mentions) } // Ответ на сообщение из другой комнаты отклоняется. secretMessage := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.secretID+"/messages", `{"content":"в другой комнате"}`, f.ownerCookie) secretID := decodeResponse[struct { Message struct { ID string `json:"id"` } `json:"message"` }](t, secretMessage) crossReply := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", `{"content":"нельзя","reply_to_id":"`+secretID.Message.ID+`"}`, f.ownerCookie) if crossReply.Code != http.StatusUnprocessableEntity { t.Fatalf("cross-channel reply = %d, want 422", crossReply.Code) } } func TestTypingAndReadState(t *testing.T) { f := newMessagingFixture(t) typing := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/typing", "", f.memberCookie) if typing.Code != http.StatusOK { t.Fatalf("typing = %d, body = %s", typing.Code, typing.Body.String()) } sent := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", `{"content":"прочитано"}`, f.ownerCookie) message := decodeResponse[struct { Message struct { ID string `json:"id"` } `json:"message"` }](t, sent) ack := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/ack", `{"last_message_id":"`+message.Message.ID+`"}`, f.memberCookie) if ack.Code != http.StatusOK { t.Fatalf("ack = %d, body = %s", ack.Code, ack.Body.String()) } states, err := f.srv.store.ListReadStates(t.Context(), guildIDOf(t, f.memberID)) if err != nil { t.Fatalf("ListReadStates: %v", err) } if len(states) != 1 || formatSnowflake(states[0].LastMessageID) != message.Message.ID { t.Fatalf("read states = %+v", states) } } func TestFileUploadAndAccess(t *testing.T) { f := newMessagingFixture(t) httpServer := httptest.NewServer(f.srv.Handler()) t.Cleanup(httpServer.Close) content := []byte("вложение: проверка загрузки") body := &bytes.Buffer{} writer := multipart.NewWriter(body) part, err := writer.CreateFormFile("file", "документ.txt") if err != nil { t.Fatalf("CreateFormFile: %v", err) } if _, err := part.Write(content); err != nil { t.Fatalf("write part: %v", err) } if err := writer.Close(); err != nil { t.Fatalf("close writer: %v", err) } req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, httpServer.URL+"/api/v1/channels/"+f.openChannel+"/files", bytes.NewReader(body.Bytes())) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Content-Type", writer.FormDataContentType()) req.AddCookie(f.memberCookie) resp, err := httpServer.Client().Do(req) if err != nil { t.Fatalf("upload: %v", err) } payload, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("upload = %d, body = %s", resp.StatusCode, payload) } var decoded struct { File struct { FileID string `json:"file_id"` Filename string `json:"filename"` SizeBytes int64 `json:"size_bytes"` } `json:"file"` } if err := json.Unmarshal(payload, &decoded); err != nil { t.Fatalf("decode upload: %v (%s)", err, payload) } if decoded.File.FileID == "" || decoded.File.Filename != "документ.txt" || decoded.File.SizeBytes != int64(len(content)) { t.Fatalf("unexpected upload payload: %s", payload) } // Скачивание участником комнаты. downloadReq, err := http.NewRequestWithContext(t.Context(), http.MethodGet, httpServer.URL+"/files/"+decoded.File.FileID, nil) if err != nil { t.Fatalf("new download request: %v", err) } downloadReq.AddCookie(f.memberCookie) downloadResp, err := httpServer.Client().Do(downloadReq) if err != nil { t.Fatalf("download: %v", err) } downloaded, _ := io.ReadAll(downloadResp.Body) _ = downloadResp.Body.Close() if downloadResp.StatusCode != http.StatusOK || !bytes.Equal(downloaded, content) { t.Fatalf("download = %d, body = %q", downloadResp.StatusCode, downloaded) } if downloadResp.Header.Get("ETag") == "" { t.Error("ETag header is missing") } // Файл из скрытой комнаты недоступен тому, кто её не видит: загружаем // тот же контент владельцем в «тайную» и проверяем участника. secretBody := &bytes.Buffer{} secretWriter := multipart.NewWriter(secretBody) secretPart, err := secretWriter.CreateFormFile("file", "секрет.txt") if err != nil { t.Fatalf("CreateFormFile secret: %v", err) } if _, err := secretPart.Write(content); err != nil { t.Fatalf("write secret part: %v", err) } if err := secretWriter.Close(); err != nil { t.Fatalf("close secret writer: %v", err) } secretReq, err := http.NewRequestWithContext(t.Context(), http.MethodPost, httpServer.URL+"/api/v1/channels/"+f.secretID+"/files", bytes.NewReader(secretBody.Bytes())) if err != nil { t.Fatalf("new secret request: %v", err) } secretReq.Header.Set("Content-Type", secretWriter.FormDataContentType()) secretReq.AddCookie(f.ownerCookie) secretResp, err := httpServer.Client().Do(secretReq) if err != nil { t.Fatalf("secret upload: %v", err) } secretPayload, _ := io.ReadAll(secretResp.Body) _ = secretResp.Body.Close() if secretResp.StatusCode != http.StatusOK { t.Fatalf("secret upload = %d, body = %s", secretResp.StatusCode, secretPayload) } var secretFile struct { File struct { FileID string `json:"file_id"` } `json:"file"` } if err := json.Unmarshal(secretPayload, &secretFile); err != nil { t.Fatalf("decode secret upload: %v", err) } hiddenReq, err := http.NewRequestWithContext(t.Context(), http.MethodGet, httpServer.URL+"/files/"+secretFile.File.FileID, nil) if err != nil { t.Fatalf("new hidden request: %v", err) } hiddenReq.AddCookie(f.memberCookie) hiddenResp, err := httpServer.Client().Do(hiddenReq) if err != nil { t.Fatalf("hidden download: %v", err) } _ = hiddenResp.Body.Close() if hiddenResp.StatusCode != http.StatusNotFound { t.Fatalf("hidden channel file = %d, want 404", hiddenResp.StatusCode) } // Вложение прикрепляется к сообщению и попадает в ответ API. sent := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", `{"content":"","attachment_ids":["`+decoded.File.FileID+`"]}`, f.memberCookie) if sent.Code != http.StatusOK { t.Fatalf("message with attachment = %d, body = %s", sent.Code, sent.Body.String()) } message := decodeResponse[struct { Message struct { Attachments []struct { FileID string `json:"file_id"` Filename string `json:"filename"` } `json:"attachments"` } `json:"message"` }](t, sent) if len(message.Message.Attachments) != 1 || message.Message.Attachments[0].FileID != decoded.File.FileID { t.Fatalf("attachments = %+v", message.Message.Attachments) } } func TestMentionsIncrementReadState(t *testing.T) { f := newMessagingFixture(t) // Владелец упоминает участника: у того растёт счётчик упоминаний. sent := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", `{"content":"<@`+f.memberID+`> привет"}`, f.ownerCookie) if sent.Code != http.StatusOK { t.Fatalf("message with mention = %d, body = %s", sent.Code, sent.Body.String()) } memberID := guildIDOf(t, f.memberID) channelID := guildIDOf(t, f.openChannel) states, err := f.srv.store.ListReadStates(t.Context(), memberID) if err != nil { t.Fatalf("ListReadStates: %v", err) } var mentions int for _, state := range states { if state.ChannelID == channelID { mentions = state.MentionCount } } if mentions != 1 { t.Fatalf("mention count = %d, want 1 (states: %+v)", mentions, states) } // Автор отметил своё сообщение прочитанным. authorStates, err := f.srv.store.ListReadStates(t.Context(), guildIDOf(t, f.ownerID)) if err != nil { t.Fatalf("ListReadStates owner: %v", err) } if len(authorStates) != 1 || formatSnowflake(authorStates[0].LastMessageID) == "" { t.Fatalf("author read state = %+v", authorStates) } // Подтверждение прочтения сбрасывает упоминания. ack := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/ack", `{}`, f.memberCookie) if ack.Code != http.StatusOK { t.Fatalf("ack = %d, body = %s", ack.Code, ack.Body.String()) } after, err := f.srv.store.ListReadStates(t.Context(), memberID) if err != nil { t.Fatalf("ListReadStates after ack: %v", err) } for _, state := range after { if state.ChannelID == channelID && state.MentionCount != 0 { t.Fatalf("mention count after ack = %d, want 0", state.MentionCount) } } } // TestFuzzySearch проверяет нечёткий поиск: по началу слова, по подстроке и // без учёта регистра (запрос пользователя). func TestFuzzySearch(t *testing.T) { f := newMessagingFixture(t) for _, text := range []string{"Привет, мир", "приветствие другу", "ПРИВЕТ ВСЕМ"} { if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", `{"content":"`+text+`"}`, f.ownerCookie); rec.Code != http.StatusOK { t.Fatalf("send %q = %d, body = %s", text, rec.Code, rec.Body.String()) } } if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", `{"content":"совсем другое"}`, f.ownerCookie); rec.Code != http.StatusOK { t.Fatalf("send other = %d", rec.Code) } search := func(query string) []string { t.Helper() rec := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/messages/search?q="+url.QueryEscape(query), "", f.memberCookie) if rec.Code != http.StatusOK { t.Fatalf("search %q = %d, body = %s", query, rec.Code, rec.Body.String()) } payload := decodeResponse[struct { Messages []struct { Content string `json:"content"` } `json:"messages"` }](t, rec) contents := make([]string, 0, len(payload.Messages)) for _, message := range payload.Messages { contents = append(contents, message.Content) } return contents } // По началу слова в нижнем регистре: «пр» должно найти все три сообщения. prefix := search("пр") if len(prefix) != 3 { t.Fatalf("поиск «пр» вернул %d результатов: %v", len(prefix), prefix) } // Подстрока в середине слова тоже находится. substring := search("ивет") if len(substring) != 3 { t.Fatalf("поиск «ивет» вернул %d результатов: %v", len(substring), substring) } // Посторонний текст не попадает в выдачу. if results := search("привет всем"); len(results) != 1 { t.Fatalf("поиск по фразе вернул %d результатов: %v", len(results), results) } if results := search("другое"); len(results) != 1 { 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()) } }