feat(messages): сообщения, реакции, пины, typing, read states и поиск
Бэкенд текстовой связи (AGENT.md 7.6, 7.15, 7.16, 8.6):
- POST/GET/PATCH/DELETE /channels/{id}/messages, ответы и упоминания,
окно правки 24 часа, права автора или MANAGE_MESSAGES;
- реакции (PUT/DELETE .../reactions/{emoji}) с агрегацией и отметкой «моя»;
- закрепления (GET/PUT/DELETE .../pins) под MANAGE_MESSAGES;
- typing с лимитом 1/3 с и адресной рассылкой всем, кроме автора;
- read states: POST /channels/{id}/ack, синхронизация READ_STATE_UPDATE между
устройствами пользователя;
- поиск FTS5 по комнате (10/мин) с экранированием запроса;
- лимиты отправки 5/5 с (burst 10) и slowmode комнаты; администратор инстанса
обходит и то, и другое (AGENT.md 7.19);
- Gateway: DispatchToChannel/DispatchToChannelExcept доставляют события комнат
только тем, кто видит комнату (VIEW_CHANNEL), права считает общий движок;
- store: messages, message_reactions, channel_read_states, files (загрузка
файлов появится вместе с вложениями).
Тесты: жизненный цикл сообщения, видимость скрытой комнаты (404 участнику,
200 админу), slowmode, упоминания и ответы, typing и read state, фильтрация
событий комнаты между двумя WS-клиентами.
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user