ecd52c11d3
AGENT.md 7.14, начало Фазы 3:
- `internal/voice`: собственный подписыватель токенов LiveKit (HS256, grants
roomJoin/canPublish/canSubscribe/canPublishData) и адрес комнаты
`guild_{g}_channel_{c}` — без внешних зависимостей;
- таблица `voice_states` (миграция 00009) и store-методы: вход, флаги
(микрофон, звук, камера, экран), серверный мьют и глушение, перемещение,
выход, подсчёт участников комнаты;
- ручки: POST /channels/{id}/voice/join (проверяет CONNECT_VOICE, тип комнаты,
лимит участников и выдаёт токен), POST /channels/{id}/voice/leave,
PATCH /guilds/{id}/voice-states/@me, GET /guilds/{id}/voice-states,
PATCH /guilds/{id}/voice-states/{user_id} (MUTE_MEMBERS/DEAFEN_MEMBERS,
аудит), POST /guilds/{id}/voice-states/{user_id}/move (MOVE_MEMBERS);
- события VOICE_STATE_UPDATE всем участникам сервера, системные записи о входе
в голосовую комнату, READY отдаёт голосовые состояния серверов;
- конфиг: LIVEKIT_API_KEY/SECRET/URL приложению, публичный адрес
`LIVEKIT_PUBLIC_URL` (по умолчанию ws(s)://<домен>/rtc) в установщике и
compose; `/api/v1/meta` сообщает voice_enabled и voice_url;
- тесты: claims токена LiveKit и подпись, выключенный голос, имя комнаты, вход
и флаги, запрет мьюта без прав, перемещение и выход.
1056 lines
41 KiB
Go
1056 lines
41 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)
|
|
if payload.Token == "" || payload.URL != "wss://gl.test/rtc" {
|
|
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)
|
|
}
|
|
}
|