package server import ( "bytes" "encoding/json" "io" "mime/multipart" "net/http" "net/http/httptest" "net/textproto" "net/url" "strconv" "strings" "testing" "time" "glchat/internal/permissions" "glchat/internal/store" "glchat/internal/voice" ) // 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"` Type string `json:"type"` } `json:"messages"` }](t, history) // Системные записи (вход участника, создание комнаты) в выдаче есть, но // проверяем именно пользовательские сообщения. userMessages := make([]string, 0, len(list.Messages)) for _, message := range list.Messages { if message.Type == "system" { continue } userMessages = append(userMessages, message.Content) } if len(userMessages) != 2 || userMessages[0] != "второе" { t.Fatalf("history = %+v", userMessages) } // Поиск по 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()) } } // TestCustomEmojis проверяет загрузку эмодзи сервера, событие и реакции. func TestCustomEmojis(t *testing.T) { f := newMessagingFixture(t) httpServer := httptest.NewServer(f.srv.Handler()) t.Cleanup(httpServer.Close) // Участник без MANAGE_EMOJIS не может загрузить эмодзи. denied := uploadEmoji(t, httpServer, f.memberCookie, f.guildID, "pepe", []byte("\x89PNG\r\n\x1a\nэмодзи")) if denied.status != http.StatusForbidden { t.Fatalf("member upload emoji = %d, want 403", denied.status) } // Владелец загружает эмодзи. created := uploadEmoji(t, httpServer, f.ownerCookie, f.guildID, "pepe", []byte("\x89PNG\r\n\x1a\nэмодзи")) if created.status != http.StatusOK { t.Fatalf("owner upload emoji = %d, body = %s", created.status, created.body) } emoji := decodeResponse[struct { Emoji struct { ID string `json:"id"` Name string `json:"name"` FileID string `json:"file_id"` Token string `json:"token"` } `json:"emoji"` }](t, created.recorder) if emoji.Emoji.Name != "pepe" || emoji.Emoji.Token != "<:pepe:"+emoji.Emoji.FileID+">" { t.Fatalf("unexpected emoji: %+v", emoji.Emoji) } // Список эмодзи и READY содержат загруженный эмодзи. list := doJSON(t, f.srv, http.MethodGet, "/api/v1/guilds/"+f.guildID+"/emojis", "", f.memberCookie) emojis := decodeResponse[struct { Emojis []struct { Name string `json:"name"` } `json:"emojis"` }](t, list) if len(emojis.Emojis) != 1 || emojis.Emojis[0].Name != "pepe" { t.Fatalf("emoji list = %+v", emojis.Emojis) } // Реакция кастомным эмодзи и отказ для неизвестного. reaction := doJSON(t, f.srv, http.MethodPut, "/api/v1/channels/"+f.openChannel+"/messages/"+ createPlainMessage(t, f)+"/reactions/"+url.PathEscape(emoji.Emoji.Token), "", f.memberCookie) if reaction.Code != http.StatusOK { t.Fatalf("custom emoji reaction = %d, body = %s", reaction.Code, reaction.Body.String()) } unknown := doJSON(t, f.srv, http.MethodPut, "/api/v1/channels/"+f.openChannel+"/messages/"+ createPlainMessage(t, f)+"/reactions/"+url.PathEscape("<:ghost:999999999999>"), "", f.memberCookie) if unknown.Code != http.StatusUnprocessableEntity { t.Fatalf("unknown custom emoji reaction = %d, want 422", unknown.Code) } // Переименование и удаление. renamed := doJSON(t, f.srv, http.MethodPatch, "/api/v1/guilds/"+f.guildID+"/emojis/"+emoji.Emoji.ID, `{"name":"pepe_happy"}`, f.ownerCookie) if renamed.Code != http.StatusOK { t.Fatalf("rename emoji = %d, body = %s", renamed.Code, renamed.Body.String()) } deleted := doJSON(t, f.srv, http.MethodDelete, "/api/v1/guilds/"+f.guildID+"/emojis/"+emoji.Emoji.ID, "", f.ownerCookie) if deleted.Code != http.StatusOK { t.Fatalf("delete emoji = %d, body = %s", deleted.Code, deleted.Body.String()) } after := doJSON(t, f.srv, http.MethodGet, "/api/v1/guilds/"+f.guildID+"/emojis", "", f.ownerCookie) empty := decodeResponse[struct { Emojis []struct { Name string `json:"name"` } `json:"emojis"` }](t, after) if len(empty.Emojis) != 0 { t.Fatalf("emoji list after delete = %+v", empty.Emojis) } } // createPlainMessage создаёт сообщение и возвращает его идентификатор. func createPlainMessage(t *testing.T, f *messagingFixture) string { t.Helper() rec := 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, rec) return message.Message.ID } // uploadEmoji загружает эмодзи через multipart. func uploadEmoji(t *testing.T, server *httptest.Server, cookie *http.Cookie, guildID, name string, content []byte) struct { status int body string recorder *httptest.ResponseRecorder } { t.Helper() body := &bytes.Buffer{} writer := multipart.NewWriter(body) if err := writer.WriteField("name", name); err != nil { t.Fatalf("write field: %v", err) } header := textproto.MIMEHeader{} header.Set("Content-Disposition", `form-data; name="file"; filename="`+name+`.png"`) header.Set("Content-Type", "image/png") part, err := writer.CreatePart(header) if err != nil { t.Fatalf("CreatePart: %v", err) } if _, err := part.Write(content); err != nil { t.Fatalf("write emoji: %v", err) } if err := writer.Close(); err != nil { t.Fatalf("close writer: %v", err) } req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, server.URL+"/api/v1/guilds/"+guildID+"/emojis", bytes.NewReader(body.Bytes())) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Content-Type", writer.FormDataContentType()) req.AddCookie(cookie) resp, err := server.Client().Do(req) if err != nil { t.Fatalf("upload emoji: %v", err) } payload, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() recorder := httptest.NewRecorder() recorder.WriteHeader(resp.StatusCode) if _, err := recorder.Body.Write(payload); err != nil { t.Fatalf("copy body: %v", err) } return struct { status int body string recorder *httptest.ResponseRecorder }{status: resp.StatusCode, body: string(payload), recorder: recorder} } // TestVoiceStateFlow проверяет вход в голосовую комнату, флаги и модерацию. func TestVoiceStateFlow(t *testing.T) { f := newMessagingFixture(t) // Создаём голосовую комнату и добавляем участника. voiceRec := doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/channels", `{"name":"Голосовая","type":"voice","user_limit":2}`, f.ownerCookie) voiceChannel := decodeResponse[struct { Channel struct { ID string `json:"id"` } `json:"channel"` }](t, voiceRec) // Без ключей LiveKit вход недоступен с понятной ошибкой. join := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+voiceChannel.Channel.ID+"/voice/join", "", f.memberCookie) if join.Code != http.StatusServiceUnavailable { t.Fatalf("join без LiveKit = %d, want 503", join.Code) } // Включаем голос: ключи подставляем в конфиг тестового сервера. f.srv.cfg.LiveKitAPIKey = "testkey" f.srv.cfg.LiveKitAPISecret = "testsecret" f.srv.cfg.LiveKitURL = "wss://gl.test/rtc" f.srv.voice = voice.NewIssuer("testkey", "testsecret", time.Hour) join = doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+voiceChannel.Channel.ID+"/voice/join", "", f.memberCookie) if join.Code != http.StatusOK { t.Fatalf("join = %d, body = %s", join.Code, join.Body.String()) } payload := decodeResponse[struct { Token string `json:"token"` URL string `json:"url"` Room string `json:"room"` ChannelID string `json:"channel_id"` State struct { UserID string `json:"user_id"` ChannelID string `json:"channel_id"` SelfMute bool `json:"self_mute"` Camera bool `json:"camera"` } `json:"state"` }](t, join) // Адрес отдаётся в виде, который ждёт LiveKit SDK: без сегмента `/rtc`, // иначе SDK допишет его второй раз (AGENT.md 7.14). if payload.Token == "" || payload.URL != "wss://gl.test" { t.Fatalf("токен/адрес = %q / %q", payload.Token, payload.URL) } if payload.State.UserID != f.memberID || payload.State.ChannelID != voiceChannel.Channel.ID { t.Fatalf("состояние = %+v", payload.State) } if !strings.HasPrefix(payload.Room, "guild_") || !strings.Contains(payload.Room, "channel_") { t.Fatalf("имя комнаты = %q", payload.Room) } // Свои флаги: микрофон и камера. flags := doJSON(t, f.srv, http.MethodPatch, "/api/v1/guilds/"+f.guildID+"/voice-states/@me", `{"self_mute":true,"camera":true}`, f.memberCookie) if flags.Code != http.StatusOK { t.Fatalf("flags = %d, body = %s", flags.Code, flags.Body.String()) } states := decodeResponse[struct { States []struct { UserID string `json:"user_id"` SelfMute bool `json:"self_mute"` Camera bool `json:"camera"` } `json:"voice_states"` }](t, flags) if len(states.States) != 1 || !states.States[0].SelfMute || !states.States[0].Camera { t.Fatalf("states = %+v", states.States) } // Модерация: у участника нет MUTE_MEMBERS — серверный мьют запрещён. denied := doJSON(t, f.srv, http.MethodPatch, "/api/v1/guilds/"+f.guildID+"/voice-states/"+f.ownerID, `{"server_mute":true}`, f.memberCookie) if denied.Code != http.StatusForbidden { t.Fatalf("member mute = %d, want 403", denied.Code) } muted := doJSON(t, f.srv, http.MethodPatch, "/api/v1/guilds/"+f.guildID+"/voice-states/"+f.memberID, `{"server_mute":true}`, f.ownerCookie) if muted.Code != http.StatusOK { t.Fatalf("owner mute = %d, body = %s", muted.Code, muted.Body.String()) } // Перемещение и выход. secondRec := doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/channels", `{"name":"Вторая","type":"voice"}`, f.ownerCookie) second := decodeResponse[struct { Channel struct { ID string `json:"id"` } `json:"channel"` }](t, secondRec) move := doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/voice-states/"+f.memberID+"/move", `{"channel_id":"`+second.Channel.ID+`"}`, f.ownerCookie) if move.Code != http.StatusOK { t.Fatalf("move = %d, body = %s", move.Code, move.Body.String()) } moved := decodeResponse[struct { States []struct { ChannelID string `json:"channel_id"` } `json:"voice_states"` }](t, move) if len(moved.States) != 1 || moved.States[0].ChannelID != second.Channel.ID { t.Fatalf("после перемещения = %+v", moved.States) } leave := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+second.Channel.ID+"/voice/leave", "", f.memberCookie) if leave.Code != http.StatusOK { t.Fatalf("leave = %d, body = %s", leave.Code, leave.Body.String()) } after := doJSON(t, f.srv, http.MethodGet, "/api/v1/guilds/"+f.guildID+"/voice-states", "", f.ownerCookie) empty := decodeResponse[struct { States []struct { UserID string `json:"user_id"` } `json:"voice_states"` }](t, after) if len(empty.States) != 0 { t.Fatalf("после выхода = %+v", empty.States) } } // TestLiveKitWebhook проверяет подпись, идемпотентность и синхронизацию состояний. func TestLiveKitWebhook(t *testing.T) { f := newMessagingFixture(t) f.srv.cfg.LiveKitAPIKey = "testkey" f.srv.cfg.LiveKitAPISecret = "testsecret" f.srv.cfg.LiveKitURL = "wss://gl.test/rtc" f.srv.voice = voice.NewIssuer("testkey", "testsecret", time.Hour) voiceRec := doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/channels", `{"name":"Голос","type":"voice"}`, f.ownerCookie) voiceChannel := decodeResponse[struct { Channel struct { ID string `json:"id"` } `json:"channel"` }](t, voiceRec) channelID := guildIDOf(t, voiceChannel.Channel.ID) guildID := guildIDOf(t, f.guildID) room := voice.RoomName(guildID, channelID) // Событие без подписи отклоняется. noAuth := doJSON(t, f.srv, http.MethodPost, "/api/v1/livekit/webhook", `{"event":"participant_joined"}`, f.ownerCookie) if noAuth.Code != http.StatusUnauthorized { t.Fatalf("webhook без подписи = %d, want 401", noAuth.Code) } post := func(body string) *httptest.ResponseRecorder { t.Helper() signature, err := f.srv.voice.IssueWebhookForTest([]byte(body)) if err != nil { t.Fatalf("подпись вебхука: %v", err) } req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/livekit/webhook", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", signature) rec := httptest.NewRecorder() f.srv.Handler().ServeHTTP(rec, req) return rec } // Участник «появился» на стороне SFU — состояние восстанавливается. joined := post(`{"event":"participant_joined","id":"evt-1","room":{"name":"` + room + `"},` + `"participant":{"identity":"` + f.memberID + `"}}`) if joined.Code != http.StatusOK { t.Fatalf("participant_joined = %d, body = %s", joined.Code, joined.Body.String()) } states := doJSON(t, f.srv, http.MethodGet, "/api/v1/guilds/"+f.guildID+"/voice-states", "", f.ownerCookie) list := decodeResponse[struct { States []struct { UserID string `json:"user_id"` ChannelID string `json:"channel_id"` } `json:"voice_states"` }](t, states) if len(list.States) != 1 || list.States[0].UserID != f.memberID { t.Fatalf("состояния после вебхука = %+v", list.States) } // Повторная доставка того же события ничего не меняет. duplicate := post(`{"event":"participant_joined","id":"evt-1","room":{"name":"` + room + `"},` + `"participant":{"identity":"` + f.memberID + `"}}`) var duplicateBody struct { Duplicate bool `json:"duplicate"` } if err := json.Unmarshal(duplicate.Body.Bytes(), &duplicateBody); err != nil { t.Fatalf("decode duplicate: %v", err) } if !duplicateBody.Duplicate { t.Fatalf("повторное событие должно помечаться duplicate: %s", duplicate.Body.String()) } // Публикация камеры отмечает флаг. camera := post(`{"event":"track_published","id":"evt-2","room":{"name":"` + room + `"},` + `"participant":{"identity":"` + f.memberID + `"},"track":{"source":"CAMERA"}}`) if camera.Code != http.StatusOK { t.Fatalf("track_published = %d, body = %s", camera.Code, camera.Body.String()) } cameraState, err := f.srv.store.GetVoiceState(t.Context(), guildID, guildIDOf(t, f.memberID)) if err != nil { t.Fatalf("GetVoiceState: %v", err) } if !cameraState.Camera { t.Fatal("флаг камеры не выставлен по вебхуку") } // Уход участника убирает состояние. left := post(`{"event":"participant_left","id":"evt-3","room":{"name":"` + room + `"},` + `"participant":{"identity":"` + f.memberID + `"}}`) if left.Code != http.StatusOK { t.Fatalf("participant_left = %d", left.Code) } after := doJSON(t, f.srv, http.MethodGet, "/api/v1/guilds/"+f.guildID+"/voice-states", "", f.ownerCookie) empty := decodeResponse[struct { States []struct { UserID string `json:"user_id"` } `json:"voice_states"` }](t, after) if len(empty.States) != 0 { t.Fatalf("после participant_left = %+v", empty.States) } } // TestSoundboard проверяет загрузку звуков, права, лимит и событие проигрывания. func TestSoundboard(t *testing.T) { f := newMessagingFixture(t) httpServer := httptest.NewServer(f.srv.Handler()) t.Cleanup(httpServer.Close) f.srv.cfg.LiveKitAPIKey = "testkey" f.srv.cfg.LiveKitAPISecret = "testsecret" f.srv.cfg.LiveKitURL = "wss://gl.test/rtc" f.srv.voice = voice.NewIssuer("testkey", "testsecret", time.Hour) voiceRec := doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/channels", `{"name":"Голос","type":"voice"}`, f.ownerCookie) voiceChannel := decodeResponse[struct { Channel struct { ID string `json:"id"` } `json:"channel"` }](t, voiceRec) // Участник без MANAGE_SOUNDS не может загрузить звук. denied := uploadSound(t, httpServer, f.memberCookie, f.guildID, "soundboard", "boo", "", []byte("OggS\x00звук")) if denied.status != http.StatusForbidden { t.Fatalf("member upload sound = %d, want 403", denied.status) } // Владелец загружает звук саундборда. created := uploadSound(t, httpServer, f.ownerCookie, f.guildID, "soundboard", "boo", "", []byte("OggS\x00звук")) if created.status != http.StatusOK { t.Fatalf("owner upload sound = %d, body = %s", created.status, created.body) } sound := decodeResponse[struct { Sound struct { ID string `json:"id"` Name string `json:"name"` Kind string `json:"kind"` FileID string `json:"file_id"` } `json:"sound"` }](t, created.recorder) if sound.Sound.Name != "boo" || sound.Sound.Kind != "soundboard" { t.Fatalf("unexpected sound: %+v", sound.Sound) } // Звук интерфейса требует событие. uiMissing := uploadSound(t, httpServer, f.ownerCookie, f.guildID, "ui", "join_sound", "", []byte("OggS\x00звук")) if uiMissing.status != http.StatusUnprocessableEntity { t.Fatalf("ui sound без события = %d, want 422", uiMissing.status) } ui := uploadSound(t, httpServer, f.ownerCookie, f.guildID, "ui", "join_sound", "member_join", []byte("OggS\x00звук")) if ui.status != http.StatusOK { t.Fatalf("ui sound = %d, body = %s", ui.status, ui.body) } // Список и фильтр по виду. list := doJSON(t, f.srv, http.MethodGet, "/api/v1/guilds/"+f.guildID+"/sounds?kind=soundboard", "", f.memberCookie) sounds := decodeResponse[struct { Sounds []struct { Name string `json:"name"` Kind string `json:"kind"` } `json:"sounds"` }](t, list) if len(sounds.Sounds) != 1 || sounds.Sounds[0].Name != "boo" { t.Fatalf("soundboard list = %+v", sounds.Sounds) } // Проигрывание вне голосовой комнаты запрещено. notConnected := doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/sounds/"+sound.Sound.ID+"/play", "", f.memberCookie) if notConnected.Code != http.StatusForbidden { t.Fatalf("play вне комнаты = %d, want 403", notConnected.Code) } // Владелец входит в голосовую комнату и играет звук. join := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+voiceChannel.Channel.ID+"/voice/join", "", f.ownerCookie) if join.Code != http.StatusOK { t.Fatalf("join voice = %d, body = %s", join.Code, join.Body.String()) } play := doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/sounds/"+sound.Sound.ID+"/play", "", f.ownerCookie) if play.Code != http.StatusOK { t.Fatalf("play = %d, body = %s", play.Code, play.Body.String()) } // Лимит частоты: 3 звука за 10 секунд. blocked := (*httptest.ResponseRecorder)(nil) for range 4 { blocked = doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/sounds/"+sound.Sound.ID+"/play", "", f.ownerCookie) } if blocked.Code != http.StatusTooManyRequests { t.Fatalf("четвёртый звук подряд = %d, want 429", blocked.Code) } // Переименование и удаление. renamed := doJSON(t, f.srv, http.MethodPatch, "/api/v1/guilds/"+f.guildID+"/sounds/"+sound.Sound.ID, `{"name":"boo2","emoji":"👻"}`, f.ownerCookie) if renamed.Code != http.StatusOK { t.Fatalf("rename sound = %d, body = %s", renamed.Code, renamed.Body.String()) } deleted := doJSON(t, f.srv, http.MethodDelete, "/api/v1/guilds/"+f.guildID+"/sounds/"+sound.Sound.ID, "", f.ownerCookie) if deleted.Code != http.StatusOK { t.Fatalf("delete sound = %d", deleted.Code) } } // uploadSound загружает звук через multipart. func uploadSound(t *testing.T, server *httptest.Server, cookie *http.Cookie, guildID, kind, name, event string, content []byte) struct { status int body string recorder *httptest.ResponseRecorder } { t.Helper() body := &bytes.Buffer{} writer := multipart.NewWriter(body) if err := writer.WriteField("name", name); err != nil { t.Fatalf("write name: %v", err) } if event != "" { if err := writer.WriteField("event", event); err != nil { t.Fatalf("write event: %v", err) } } header := textproto.MIMEHeader{} header.Set("Content-Disposition", `form-data; name="file"; filename="`+name+`.ogg"`) header.Set("Content-Type", "audio/ogg") part, err := writer.CreatePart(header) if err != nil { t.Fatalf("CreatePart: %v", err) } if _, err := part.Write(content); err != nil { t.Fatalf("write sound: %v", err) } if err := writer.Close(); err != nil { t.Fatalf("close writer: %v", err) } url := server.URL + "/api/v1/guilds/" + guildID + "/sounds?kind=" + kind req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, url, bytes.NewReader(body.Bytes())) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Content-Type", writer.FormDataContentType()) req.AddCookie(cookie) resp, err := server.Client().Do(req) if err != nil { t.Fatalf("upload sound: %v", err) } payload, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() recorder := httptest.NewRecorder() recorder.WriteHeader(resp.StatusCode) if _, err := recorder.Body.Write(payload); err != nil { t.Fatalf("copy body: %v", err) } return struct { status int body string recorder *httptest.ResponseRecorder }{status: resp.StatusCode, body: string(payload), recorder: recorder} }