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