Files
glchat/internal/server/messages_test.go
T
grendervill 2538e07ec3 fix(voice): отдавать клиенту базовый адрес LiveKit без сегмента /rtc
LiveKit SDK сам дописывает сигнальный путь (`/rtc`, `/rtc/v1`) к базовому
адресу, поэтому `wss://host/rtc` из конфига превращался в `wss://host/rtc/rtc`
и комната навсегда оставалась в состоянии «Подключение…» (AGENT.md 7.14).

- `voice.ResolveLiveKitURL` снимает завершающий сегмент `/rtc`, сохраняя
  префикс прокси (`wss://host/livekit/rtc` → `wss://host/livekit`);
- `POST /channels/{id}/voice/join` отдаёт адрес через `VoiceURLForClient` и
  отвечает `voice.disabled`, если адрес не разрешился;
- тесты: таблица адресов, ожидание `wss://gl.test` в голосовом сценарии.
2026-09-20 19:29:19 +03:00

1316 lines
51 KiB
Go

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}
}