2026-09-19 23:32:11 +03:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
2026-09-19 23:36:16 +03:00
|
|
|
"bytes"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"io"
|
|
|
|
|
"mime/multipart"
|
2026-09-19 23:32:11 +03:00
|
|
|
"net/http"
|
2026-09-19 23:36:16 +03:00
|
|
|
"net/http/httptest"
|
2026-09-19 23:32:11 +03:00
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"glchat/internal/permissions"
|
|
|
|
|
"glchat/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// storeOverride скрывает комнату от роли @user.
|
|
|
|
|
func storeOverride(channelID, roleID uint64) store.ChannelOverride {
|
|
|
|
|
return store.ChannelOverride{
|
|
|
|
|
ChannelID: channelID, TargetType: "role", TargetID: roleID,
|
|
|
|
|
Deny: uint64(permissions.ViewChannel),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// messagingFixture создаёт сервер с владельцем, участником и двумя комнатами:
|
|
|
|
|
// «общий» (видна всем) и «тайная» (скрыта от роли @user оверрайдом).
|
|
|
|
|
type messagingFixture struct {
|
|
|
|
|
srv *Server
|
|
|
|
|
ownerCookie *http.Cookie
|
|
|
|
|
memberCookie *http.Cookie
|
|
|
|
|
guildID string
|
|
|
|
|
openChannel string
|
|
|
|
|
secretID string
|
|
|
|
|
memberID string
|
|
|
|
|
ownerID string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newMessagingFixture(t *testing.T) *messagingFixture {
|
|
|
|
|
t.Helper()
|
|
|
|
|
srv, _ := newTestServer(t)
|
|
|
|
|
ownerCookie := registerAndLogin(t, srv, "msg_owner", "msg-owner@example.com")
|
|
|
|
|
memberCookie := registerAndLogin(t, srv, "msg_member", "msg-member@example.com")
|
|
|
|
|
|
|
|
|
|
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Чат"}`, ownerCookie)
|
|
|
|
|
guild := decodeResponse[struct {
|
|
|
|
|
Guild struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Roles []struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
IsDefault bool `json:"is_default"`
|
|
|
|
|
} `json:"roles"`
|
|
|
|
|
Channels []struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
} `json:"channels"`
|
|
|
|
|
} `json:"guild"`
|
|
|
|
|
}](t, created)
|
|
|
|
|
|
|
|
|
|
doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie)
|
|
|
|
|
|
|
|
|
|
secretRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels",
|
|
|
|
|
`{"name":"тайная","type":"text"}`, ownerCookie)
|
|
|
|
|
secret := decodeResponse[struct {
|
|
|
|
|
Channel struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
} `json:"channel"`
|
|
|
|
|
}](t, secretRec)
|
|
|
|
|
|
|
|
|
|
database := srv.store
|
|
|
|
|
var defaultRoleID uint64
|
|
|
|
|
for _, role := range guild.Guild.Roles {
|
|
|
|
|
if role.IsDefault {
|
|
|
|
|
defaultRoleID = guildIDOf(t, role.ID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if err := database.SetChannelOverride(t.Context(), storeOverride(guildIDOf(t, secret.Channel.ID), defaultRoleID)); err != nil {
|
|
|
|
|
t.Fatalf("SetChannelOverride: %v", err)
|
|
|
|
|
}
|
|
|
|
|
srv.perms.InvalidateGuild(guildIDOf(t, guild.Guild.ID))
|
|
|
|
|
|
|
|
|
|
owner, err := srv.auth.UserByEmail(t.Context(), "msg-owner@example.com")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("UserByEmail owner: %v", err)
|
|
|
|
|
}
|
|
|
|
|
member, err := srv.auth.UserByEmail(t.Context(), "msg-member@example.com")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("UserByEmail member: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &messagingFixture{
|
|
|
|
|
srv: srv,
|
|
|
|
|
ownerCookie: ownerCookie,
|
|
|
|
|
memberCookie: memberCookie,
|
|
|
|
|
guildID: guild.Guild.ID,
|
|
|
|
|
openChannel: guild.Guild.Channels[0].ID,
|
|
|
|
|
secretID: secret.Channel.ID,
|
|
|
|
|
memberID: formatSnowflake(member.ID),
|
|
|
|
|
ownerID: formatSnowflake(owner.ID),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMessageLifecycle(t *testing.T) {
|
|
|
|
|
f := newMessagingFixture(t)
|
|
|
|
|
|
|
|
|
|
// Отправка: содержимое нормализуется, ответ содержит автора и время.
|
|
|
|
|
sent := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
|
|
|
|
|
`{"content":" привет, мир "}`, f.memberCookie)
|
|
|
|
|
if sent.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("create message = %d, body = %s", sent.Code, sent.Body.String())
|
|
|
|
|
}
|
|
|
|
|
message := decodeResponse[struct {
|
|
|
|
|
Message struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Content string `json:"content"`
|
|
|
|
|
AuthorID string `json:"author_id"`
|
|
|
|
|
ChannelID string `json:"channel_id"`
|
|
|
|
|
} `json:"message"`
|
|
|
|
|
}](t, sent)
|
|
|
|
|
if message.Message.Content != "привет, мир" {
|
|
|
|
|
t.Fatalf("content = %q, want trimmed", message.Message.Content)
|
|
|
|
|
}
|
|
|
|
|
if message.Message.AuthorID != f.memberID || message.Message.ChannelID != f.openChannel {
|
|
|
|
|
t.Fatalf("unexpected message: %+v", message.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Пустое сообщение без вложений запрещено.
|
|
|
|
|
empty := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
|
|
|
|
|
`{"content":" "}`, f.memberCookie)
|
|
|
|
|
if empty.Code != http.StatusUnprocessableEntity {
|
|
|
|
|
t.Fatalf("empty message = %d, want 422", empty.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Правка автором.
|
|
|
|
|
edited := doJSON(t, f.srv, http.MethodPatch,
|
|
|
|
|
"/api/v1/channels/"+f.openChannel+"/messages/"+message.Message.ID,
|
|
|
|
|
`{"content":"поправлено"}`, f.memberCookie)
|
|
|
|
|
if edited.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("edit message = %d, body = %s", edited.Code, edited.Body.String())
|
|
|
|
|
}
|
|
|
|
|
updated := decodeResponse[struct {
|
|
|
|
|
Message struct {
|
|
|
|
|
Content string `json:"content"`
|
|
|
|
|
EditedAt string `json:"edited_at"`
|
|
|
|
|
} `json:"message"`
|
|
|
|
|
}](t, edited)
|
|
|
|
|
if updated.Message.Content != "поправлено" || updated.Message.EditedAt == "" {
|
|
|
|
|
t.Fatalf("unexpected edited message: %+v", updated.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// История отдаётся от новых к старым.
|
|
|
|
|
second := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
|
|
|
|
|
`{"content":"второе"}`, f.ownerCookie)
|
|
|
|
|
if second.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("second message = %d", second.Code)
|
|
|
|
|
}
|
|
|
|
|
history := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/messages", "", f.memberCookie)
|
|
|
|
|
list := decodeResponse[struct {
|
|
|
|
|
Messages []struct {
|
|
|
|
|
Content string `json:"content"`
|
|
|
|
|
} `json:"messages"`
|
|
|
|
|
}](t, history)
|
|
|
|
|
if len(list.Messages) != 2 || list.Messages[0].Content != "второе" {
|
|
|
|
|
t.Fatalf("history = %+v", list.Messages)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Поиск по FTS5 находит сообщение.
|
|
|
|
|
search := doJSON(t, f.srv, http.MethodGet,
|
|
|
|
|
"/api/v1/channels/"+f.openChannel+"/messages/search?q=поправлено", "", f.memberCookie)
|
|
|
|
|
if search.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("search = %d, body = %s", search.Code, search.Body.String())
|
|
|
|
|
}
|
|
|
|
|
found := decodeResponse[struct {
|
|
|
|
|
Messages []struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
} `json:"messages"`
|
|
|
|
|
}](t, search)
|
|
|
|
|
if len(found.Messages) != 1 || found.Messages[0].ID != message.Message.ID {
|
|
|
|
|
t.Fatalf("search results = %+v", found.Messages)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Реакции: поставили, увидели в истории, сняли.
|
|
|
|
|
addReaction := doJSON(t, f.srv, http.MethodPut,
|
|
|
|
|
"/api/v1/channels/"+f.openChannel+"/messages/"+message.Message.ID+"/reactions/👍", "", f.ownerCookie)
|
|
|
|
|
if addReaction.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("add reaction = %d, body = %s", addReaction.Code, addReaction.Body.String())
|
|
|
|
|
}
|
|
|
|
|
afterReaction := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/messages", "", f.ownerCookie)
|
|
|
|
|
reactions := decodeResponse[struct {
|
|
|
|
|
Messages []struct {
|
|
|
|
|
Reactions []struct {
|
|
|
|
|
Emoji string `json:"emoji"`
|
|
|
|
|
Count int `json:"count"`
|
|
|
|
|
Me bool `json:"me"`
|
|
|
|
|
} `json:"reactions"`
|
|
|
|
|
} `json:"messages"`
|
|
|
|
|
}](t, afterReaction)
|
|
|
|
|
var foundReaction bool
|
|
|
|
|
for _, item := range reactions.Messages {
|
|
|
|
|
for _, reaction := range item.Reactions {
|
|
|
|
|
if reaction.Emoji == "👍" && reaction.Count == 1 && reaction.Me {
|
|
|
|
|
foundReaction = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !foundReaction {
|
|
|
|
|
t.Fatalf("reaction not visible: %+v", reactions.Messages)
|
|
|
|
|
}
|
|
|
|
|
removeReaction := doJSON(t, f.srv, http.MethodDelete,
|
|
|
|
|
"/api/v1/channels/"+f.openChannel+"/messages/"+message.Message.ID+"/reactions/👍", "", f.ownerCookie)
|
|
|
|
|
if removeReaction.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("remove reaction = %d", removeReaction.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Закрепление требует MANAGE_MESSAGES: у участника его нет.
|
|
|
|
|
deniedPin := doJSON(t, f.srv, http.MethodPut,
|
|
|
|
|
"/api/v1/channels/"+f.openChannel+"/pins/"+message.Message.ID, "", f.memberCookie)
|
|
|
|
|
if deniedPin.Code != http.StatusForbidden {
|
|
|
|
|
t.Fatalf("member pin = %d, want 403", deniedPin.Code)
|
|
|
|
|
}
|
|
|
|
|
pin := doJSON(t, f.srv, http.MethodPut,
|
|
|
|
|
"/api/v1/channels/"+f.openChannel+"/pins/"+message.Message.ID, "", f.ownerCookie)
|
|
|
|
|
if pin.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("owner pin = %d, body = %s", pin.Code, pin.Body.String())
|
|
|
|
|
}
|
|
|
|
|
pins := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/pins", "", f.memberCookie)
|
|
|
|
|
pinned := decodeResponse[struct {
|
|
|
|
|
Messages []struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Pinned bool `json:"pinned"`
|
|
|
|
|
} `json:"messages"`
|
|
|
|
|
}](t, pins)
|
|
|
|
|
if len(pinned.Messages) != 1 || !pinned.Messages[0].Pinned {
|
|
|
|
|
t.Fatalf("pins = %+v", pinned.Messages)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Удаление: чужое сообщение участник удалить не может, модератор — может.
|
|
|
|
|
deniedDelete := doJSON(t, f.srv, http.MethodDelete,
|
|
|
|
|
"/api/v1/channels/"+f.openChannel+"/messages/"+message.Message.ID, "", f.ownerCookie)
|
|
|
|
|
if deniedDelete.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("owner delete = %d, body = %s", deniedDelete.Code, deniedDelete.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMessagesRespectChannelVisibility(t *testing.T) {
|
|
|
|
|
f := newMessagingFixture(t)
|
|
|
|
|
|
|
|
|
|
// Участник не видит скрытую комнату: список и отправка дают 404.
|
|
|
|
|
list := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.secretID+"/messages", "", f.memberCookie)
|
|
|
|
|
if list.Code != http.StatusNotFound {
|
|
|
|
|
t.Fatalf("hidden channel list = %d, want 404", list.Code)
|
|
|
|
|
}
|
|
|
|
|
send := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.secretID+"/messages",
|
|
|
|
|
`{"content":"секрет"}`, f.memberCookie)
|
|
|
|
|
if send.Code != http.StatusNotFound {
|
|
|
|
|
t.Fatalf("hidden channel send = %d, want 404", send.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Владелец пишет в скрытую комнату и читает её.
|
|
|
|
|
ownerSend := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.secretID+"/messages",
|
|
|
|
|
`{"content":"для своих"}`, f.ownerCookie)
|
|
|
|
|
if ownerSend.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("owner send to hidden = %d, body = %s", ownerSend.Code, ownerSend.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Администратор инстанса видит всё (AGENT.md 7.19).
|
|
|
|
|
adminCookie := registerAndLogin(t, f.srv, "msg_admin", "msg-admin@example.com")
|
|
|
|
|
promoteAdmin(t, f.srv, "msg-admin@example.com")
|
|
|
|
|
adminList := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.secretID+"/messages", "", adminCookie)
|
|
|
|
|
if adminList.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("instance admin list = %d, want 200", adminList.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSlowmodeLimitsMessages(t *testing.T) {
|
|
|
|
|
f := newMessagingFixture(t)
|
|
|
|
|
|
|
|
|
|
// Включаем slowmode 60 секунд в комнате.
|
|
|
|
|
update := doJSON(t, f.srv, http.MethodPatch,
|
|
|
|
|
"/api/v1/guilds/"+f.guildID+"/channels/"+f.openChannel,
|
|
|
|
|
`{"slowmode_seconds":60}`, f.ownerCookie)
|
|
|
|
|
if update.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("set slowmode = %d, body = %s", update.Code, update.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
first := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
|
|
|
|
|
`{"content":"первое"}`, f.memberCookie)
|
|
|
|
|
if first.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("first message = %d, body = %s", first.Code, first.Body.String())
|
|
|
|
|
}
|
|
|
|
|
second := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
|
|
|
|
|
`{"content":"второе"}`, f.memberCookie)
|
|
|
|
|
if second.Code != http.StatusTooManyRequests {
|
|
|
|
|
t.Fatalf("second message = %d, want 429", second.Code)
|
|
|
|
|
}
|
|
|
|
|
if code := errorCodeOf(t, second); code != "rate_limited" {
|
|
|
|
|
t.Fatalf("error code = %q, want rate_limited", code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Модератор (MANAGE_MESSAGES) и администратор инстанса slowmode обходят.
|
|
|
|
|
owner := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
|
|
|
|
|
`{"content":"модератор пишет"}`, f.ownerCookie)
|
|
|
|
|
if owner.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("owner message with slowmode = %d, body = %s", owner.Code, owner.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMentionsAndReplies(t *testing.T) {
|
|
|
|
|
f := newMessagingFixture(t)
|
|
|
|
|
|
|
|
|
|
parent := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
|
|
|
|
|
`{"content":"родитель"}`, f.ownerCookie)
|
|
|
|
|
parentMessage := decodeResponse[struct {
|
|
|
|
|
Message struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
} `json:"message"`
|
|
|
|
|
}](t, parent)
|
|
|
|
|
|
|
|
|
|
reply := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
|
|
|
|
|
`{"content":"ответ <@`+f.memberID+`>","reply_to_id":"`+parentMessage.Message.ID+`"}`, f.ownerCookie)
|
|
|
|
|
if reply.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("reply = %d, body = %s", reply.Code, reply.Body.String())
|
|
|
|
|
}
|
|
|
|
|
payload := decodeResponse[struct {
|
|
|
|
|
Message struct {
|
|
|
|
|
ReplyToID string `json:"reply_to_id"`
|
|
|
|
|
Mentions []string `json:"mentions"`
|
|
|
|
|
} `json:"message"`
|
|
|
|
|
}](t, reply)
|
|
|
|
|
if payload.Message.ReplyToID != parentMessage.Message.ID {
|
|
|
|
|
t.Fatalf("reply_to_id = %q", payload.Message.ReplyToID)
|
|
|
|
|
}
|
|
|
|
|
if len(payload.Message.Mentions) != 1 || payload.Message.Mentions[0] != f.memberID {
|
|
|
|
|
t.Fatalf("mentions = %+v, want member", payload.Message.Mentions)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ответ на сообщение из другой комнаты отклоняется.
|
|
|
|
|
secretMessage := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.secretID+"/messages",
|
|
|
|
|
`{"content":"в другой комнате"}`, f.ownerCookie)
|
|
|
|
|
secretID := decodeResponse[struct {
|
|
|
|
|
Message struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
} `json:"message"`
|
|
|
|
|
}](t, secretMessage)
|
|
|
|
|
crossReply := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
|
|
|
|
|
`{"content":"нельзя","reply_to_id":"`+secretID.Message.ID+`"}`, f.ownerCookie)
|
|
|
|
|
if crossReply.Code != http.StatusUnprocessableEntity {
|
|
|
|
|
t.Fatalf("cross-channel reply = %d, want 422", crossReply.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTypingAndReadState(t *testing.T) {
|
|
|
|
|
f := newMessagingFixture(t)
|
|
|
|
|
|
|
|
|
|
typing := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/typing", "", f.memberCookie)
|
|
|
|
|
if typing.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("typing = %d, body = %s", typing.Code, typing.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sent := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
|
|
|
|
|
`{"content":"прочитано"}`, f.ownerCookie)
|
|
|
|
|
message := decodeResponse[struct {
|
|
|
|
|
Message struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
} `json:"message"`
|
|
|
|
|
}](t, sent)
|
|
|
|
|
|
|
|
|
|
ack := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/ack",
|
|
|
|
|
`{"last_message_id":"`+message.Message.ID+`"}`, f.memberCookie)
|
|
|
|
|
if ack.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("ack = %d, body = %s", ack.Code, ack.Body.String())
|
|
|
|
|
}
|
|
|
|
|
states, err := f.srv.store.ListReadStates(t.Context(), guildIDOf(t, f.memberID))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("ListReadStates: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(states) != 1 || formatSnowflake(states[0].LastMessageID) != message.Message.ID {
|
|
|
|
|
t.Fatalf("read states = %+v", states)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-19 23:36:16 +03:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|