feat(dm): групповые личные беседы (AGENT.md 7.8, Фаза 7)
Беседа на несколько участников живёт в тех же таблицах, что и 1:1
(channels.type = 'dm' + dm_participants), добавляется только владелец
(channels.dm_owner_id, миграция 00020).
Сервер: POST /users/@me/channels/group (2–9 приглашённых, имя необязательно —
собирается из имён), PUT/DELETE /channels/{id}/recipients/{user_id}
(добавляет любой участник, удаляет других и переименовывает только владелец,
выйти может каждый сам), PATCH /channels/{id}. Правила состава: группа — это
3+ участника; при двух беседа снова обычная личная (имя и владелец
сбрасываются), при одном — удаляется вместе с перепиской. Заблокированного
нельзя ни пригласить, ни добавить; посторонним беседа не видна (404).
READY и REST отдают is_group, name, member_count, owner_id и состав;
участники получают DM_CHANNEL_CREATE/DM_CHANNEL_UPDATE, удалённый —
DM_CHANNEL_DELETE.
Клиент: группа в сайдбаре с числом участников (без точки чужого статуса),
шапка беседы с числом участников и именами в подсказке, создание группы из
списка друзей (минимум двое) с переходом в новую беседу.
Тесты: Go — полный жизненный цикл (создание, состав у каждого участника,
добавление, запрет посторонним, удаление владельцем, выход, превращение в
1:1, удаление последней беседы), валидация (меньше трёх, дубликаты,
заблокированный) и группа в READY; web — 4 vitest (сайдбар, шапка, создание,
выключенная кнопка).
This commit is contained in:
@@ -3,6 +3,8 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -66,6 +68,25 @@ type dmChannelPayload struct {
|
||||
} `json:"recipient"`
|
||||
LastMessageID string `json:"last_message_id,omitempty"`
|
||||
LastMessageAt string `json:"last_message_at,omitempty"`
|
||||
// Групповая беседа (AGENT.md 7.8): имя, участники и владелец. У 1:1
|
||||
// is_group = false, а данные собеседника лежат в recipient.
|
||||
IsGroup bool `json:"is_group"`
|
||||
Name string `json:"name,omitempty"`
|
||||
MemberCount int `json:"member_count,omitempty"`
|
||||
OwnerID string `json:"owner_id,omitempty"`
|
||||
Recipients []dmRecipientPayload `json:"recipients,omitempty"`
|
||||
}
|
||||
|
||||
// dmRecipientPayload — участник групповой беседы.
|
||||
type dmRecipientPayload struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarFileID string `json:"avatar_file_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
VisibleStatus string `json:"visible_status"`
|
||||
IsOwner bool `json:"is_owner"`
|
||||
IsSelf bool `json:"is_self"`
|
||||
}
|
||||
|
||||
type dmChannelListOutput struct {
|
||||
@@ -407,6 +428,362 @@ func (s *Server) registerSocialRoutes(api huma.API) {
|
||||
}
|
||||
return output, nil
|
||||
})
|
||||
|
||||
// Групповые личные беседы (AGENT.md 7.8, Фаза 7): создание, добавление и
|
||||
// выход участников, переименование. Права простые и явные: добавлять может
|
||||
// любой участник, удалять других и переименовывать — владелец, выйти может
|
||||
// каждый сам.
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "createGroupDirectChannel",
|
||||
Method: http.MethodPost,
|
||||
Path: "/users/@me/channels/group",
|
||||
Summary: "Создать групповую беседу",
|
||||
Tags: []string{"Friends"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *struct {
|
||||
Body struct {
|
||||
Name string `json:"name,omitempty" maxLength:"64"`
|
||||
RecipientIDs []string `json:"recipient_ids"`
|
||||
}
|
||||
},
|
||||
) (*dmChannelOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members, err := s.groupDMMembers(ctx, user.ID, input.Body.RecipientIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Группа — это минимум три участника: двое и меньше остаются обычной
|
||||
// личной беседой (см. store.RemoveDMParticipant).
|
||||
if len(members) < 2 {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed",
|
||||
"a group conversation needs at least two other members")
|
||||
}
|
||||
name := strings.TrimSpace(input.Body.Name)
|
||||
if name == "" {
|
||||
name = s.defaultGroupDMName(ctx, members)
|
||||
}
|
||||
if len([]rune(name)) > maxGroupDMNameLength {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "group name is too long")
|
||||
}
|
||||
channel, err := s.store.CreateGroupDMChannel(ctx, user.ID, name, members)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
s.dispatchDMCreate(channel.ID, append([]uint64{user.ID}, members...))
|
||||
summary, err := s.groupDMSummary(ctx, channel, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output := &dmChannelOutput{}
|
||||
output.Body.Channel = s.dmChannelPayload(summary)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "addDirectChannelRecipient",
|
||||
Method: http.MethodPut,
|
||||
Path: "/channels/{channel_id}/recipients/{user_id}",
|
||||
Summary: "Добавить участника в групповую беседу",
|
||||
Tags: []string{"Friends"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *struct {
|
||||
ChannelID string `path:"channel_id"`
|
||||
UserID string `path:"user_id"`
|
||||
},
|
||||
) (*dmChannelOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetID, err := parseID("user_id", input.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if targetID == user.ID {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you are already a member")
|
||||
}
|
||||
if _, err := s.store.GetUser(ctx, targetID); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
// Блокировка запрещает и добавление в общую беседу (AGENT.md 7.2).
|
||||
if blocked, err := s.store.IsBlocked(ctx, user.ID, targetID); err != nil {
|
||||
return nil, humaError(err)
|
||||
} else if blocked {
|
||||
return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked")
|
||||
}
|
||||
count, err := s.store.DMParticipantCount(ctx, channel.ID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if count >= maxGroupDMMembers {
|
||||
return nil, humaErrorStatus(http.StatusConflict, "dm.members_limit", "group conversation is full")
|
||||
}
|
||||
if err := s.store.AddDMParticipant(ctx, channel.ID, targetID); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
// Новый участник получает событие создания беседы, остальные — обновление
|
||||
// состава (AGENT.md 8.3).
|
||||
if s.gateway != nil {
|
||||
s.gateway.SendToUser(targetID, "DM_CHANNEL_CREATE", map[string]any{
|
||||
"channel_id": formatSnowflake(channel.ID),
|
||||
})
|
||||
}
|
||||
s.dispatchDMUpdate(channel, []uint64{}, targetID)
|
||||
summary, err := s.groupDMSummary(ctx, channel, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output := &dmChannelOutput{}
|
||||
output.Body.Channel = s.dmChannelPayload(summary)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "removeDirectChannelRecipient",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/channels/{channel_id}/recipients/{user_id}",
|
||||
Summary: "Выйти из групповой беседы или удалить участника",
|
||||
Tags: []string{"Friends"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *struct {
|
||||
ChannelID string `path:"channel_id"`
|
||||
UserID string `path:"user_id"`
|
||||
},
|
||||
) (*okOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetID, err := parseID("user_id", input.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if targetID != user.ID {
|
||||
// Удалять других может только владелец беседы.
|
||||
if channel.DMOwnerID == nil || *channel.DMOwnerID != user.ID {
|
||||
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied",
|
||||
"only the group owner can remove other members")
|
||||
}
|
||||
member, err := s.store.IsDMParticipant(ctx, channel.ID, targetID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if !member {
|
||||
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "user is not a member of this conversation")
|
||||
}
|
||||
}
|
||||
if err := s.store.RemoveDMParticipant(ctx, channel.ID, targetID); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
// Удалённый (или вышедший) теряет доступ: ему уходит CHANNEL_DELETE,
|
||||
// остальным — обновление состава.
|
||||
if s.gateway != nil {
|
||||
s.gateway.SendToUser(targetID, "DM_CHANNEL_DELETE", map[string]any{
|
||||
"channel_id": formatSnowflake(channel.ID),
|
||||
})
|
||||
}
|
||||
s.dispatchDMUpdate(channel, []uint64{targetID}, 0)
|
||||
return newOKOutput(), nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "renameDirectChannel",
|
||||
Method: http.MethodPatch,
|
||||
Path: "/channels/{channel_id}",
|
||||
Summary: "Переименовать групповую беседу (владелец)",
|
||||
Tags: []string{"Friends"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *struct {
|
||||
ChannelID string `path:"channel_id"`
|
||||
Body struct {
|
||||
Name string `json:"name" maxLength:"64"`
|
||||
}
|
||||
},
|
||||
) (*dmChannelOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if channel.DMOwnerID == nil || *channel.DMOwnerID != user.ID {
|
||||
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "only the group owner can rename the conversation")
|
||||
}
|
||||
name := strings.TrimSpace(input.Body.Name)
|
||||
if name == "" || len([]rune(name)) > maxGroupDMNameLength {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "group name must be 1-64 characters")
|
||||
}
|
||||
if err := s.store.RenameDMChannel(ctx, channel.ID, name); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
channel.Name = name
|
||||
s.dispatchDMUpdate(channel, []uint64{}, 0)
|
||||
summary, err := s.groupDMSummary(ctx, channel, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output := &dmChannelOutput{}
|
||||
output.Body.Channel = s.dmChannelPayload(summary)
|
||||
return output, nil
|
||||
})
|
||||
}
|
||||
|
||||
// Пределы групповой беседы (AGENT.md 7.8): не больше десяти участников и
|
||||
// 64 символа в названии — группа не должна превращаться в сервер.
|
||||
const (
|
||||
maxGroupDMMembers = 10
|
||||
maxGroupDMNameLength = 64
|
||||
)
|
||||
|
||||
// groupDMMembers проверяет список приглашённых: пользователи существуют, это
|
||||
// не сам приглашающий, никто не заблокирован.
|
||||
func (s *Server) groupDMMembers(ctx context.Context, selfID uint64, rawIDs []string) ([]uint64, error) {
|
||||
if len(rawIDs) > maxGroupDMMembers-1 {
|
||||
return nil, humaErrorStatus(http.StatusConflict, "dm.members_limit", "group conversation is full")
|
||||
}
|
||||
seen := map[uint64]bool{}
|
||||
members := make([]uint64, 0, len(rawIDs))
|
||||
for _, rawID := range rawIDs {
|
||||
targetID, err := parseID("recipient_ids", rawID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if targetID == selfID || seen[targetID] {
|
||||
continue
|
||||
}
|
||||
if _, err := s.store.GetUser(ctx, targetID); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if blocked, err := s.store.IsBlocked(ctx, selfID, targetID); err != nil {
|
||||
return nil, humaError(err)
|
||||
} else if blocked {
|
||||
return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked")
|
||||
}
|
||||
seen[targetID] = true
|
||||
members = append(members, targetID)
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
// defaultGroupDMName собирает имя по участникам: «Алиса, Боб и ещё 2».
|
||||
func (s *Server) defaultGroupDMName(ctx context.Context, members []uint64) string {
|
||||
names := make([]string, 0, len(members))
|
||||
for _, memberID := range members {
|
||||
member, err := s.store.GetUser(ctx, memberID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
name := member.DisplayName
|
||||
if name == "" {
|
||||
name = member.Username
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "Групповая беседа"
|
||||
}
|
||||
// В имя попадает не больше двух имён: длинный список обрезается.
|
||||
if len(names) > 2 {
|
||||
return fmt.Sprintf("%s, %s и ещё %d", names[0], names[1], len(names)-2)
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// groupDMForMember проверяет, что канал — личная беседа, а пользователь в ней
|
||||
// состоит. Посторонним отвечаем 404: существование чужой беседы не подтверждаем.
|
||||
func (s *Server) groupDMForMember(ctx context.Context, rawChannelID string, userID uint64) (*store.Channel, error) {
|
||||
channelID, err := parseID("channel_id", rawChannelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channel, err := s.store.GetChannel(ctx, channelID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if channel.Type != store.ChannelDM || channel.GuildID != nil {
|
||||
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "conversation not found")
|
||||
}
|
||||
participant, err := s.store.IsDMParticipant(ctx, channelID, userID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if !participant {
|
||||
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "conversation not found")
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
// groupDMSummary собирает карточку групповой беседы для ответа API.
|
||||
func (s *Server) groupDMSummary(ctx context.Context, channel *store.Channel, viewerID uint64) (store.DMChannelSummary, error) {
|
||||
participants, err := s.store.DMParticipantProfiles(ctx, channel.ID, viewerID)
|
||||
if err != nil {
|
||||
return store.DMChannelSummary{}, humaError(err)
|
||||
}
|
||||
ownerID := channel.DMOwnerID
|
||||
return store.DMChannelSummary{
|
||||
Channel: *channel,
|
||||
IsGroup: true,
|
||||
GroupName: channel.Name,
|
||||
MemberCount: len(participants),
|
||||
Participants: participants,
|
||||
OwnerID: ownerID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// dispatchDMCreate сообщает участникам о новой беседе (AGENT.md 8.3).
|
||||
func (s *Server) dispatchDMCreate(channelID uint64, userIDs []uint64) {
|
||||
if s.gateway == nil {
|
||||
return
|
||||
}
|
||||
for _, userID := range userIDs {
|
||||
s.gateway.SendToUser(userID, "DM_CHANNEL_CREATE", map[string]any{
|
||||
"channel_id": formatSnowflake(channelID),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchDMUpdate сообщает участникам об изменении состава или имени: сам
|
||||
// канал отдаётся в событии, чтобы клиент не делал лишний запрос.
|
||||
func (s *Server) dispatchDMUpdate(channel *store.Channel, except []uint64, include uint64) {
|
||||
if s.gateway == nil {
|
||||
return
|
||||
}
|
||||
participants, err := s.store.DMParticipants(context.Background(), channel.ID)
|
||||
if err != nil {
|
||||
s.logger.WarnContext(context.Background(), "не удалось прочитать участников беседы",
|
||||
slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
skip := map[uint64]bool{}
|
||||
for _, userID := range except {
|
||||
skip[userID] = true
|
||||
}
|
||||
recipients := make([]uint64, 0, len(participants)+1)
|
||||
for _, userID := range participants {
|
||||
if !skip[userID] {
|
||||
recipients = append(recipients, userID)
|
||||
}
|
||||
}
|
||||
if include != 0 {
|
||||
recipients = append(recipients, include)
|
||||
}
|
||||
payload := map[string]any{"channel_id": formatSnowflake(channel.ID)}
|
||||
for _, userID := range recipients {
|
||||
s.gateway.SendToUser(userID, "DM_CHANNEL_UPDATE", payload)
|
||||
}
|
||||
}
|
||||
|
||||
// relationshipTarget находит пользователя по id или логину.
|
||||
@@ -553,6 +930,37 @@ func (s *Server) dmChannelPayload(summary store.DMChannelSummary) dmChannelPaylo
|
||||
CanSend: true,
|
||||
CanView: true,
|
||||
}
|
||||
if summary.IsGroup {
|
||||
payload.IsGroup = true
|
||||
payload.Name = summary.GroupName
|
||||
payload.MemberCount = summary.MemberCount
|
||||
if summary.OwnerID != nil {
|
||||
payload.OwnerID = formatSnowflake(*summary.OwnerID)
|
||||
}
|
||||
payload.Recipients = make([]dmRecipientPayload, 0, len(summary.Participants))
|
||||
for _, participant := range summary.Participants {
|
||||
item := dmRecipientPayload{
|
||||
UserID: formatSnowflake(participant.UserID),
|
||||
Username: participant.Username,
|
||||
DisplayName: participant.DisplayName,
|
||||
Status: participant.Status,
|
||||
VisibleStatus: visibleStatus(participant.Status, participant.LastSeenAt),
|
||||
IsOwner: participant.IsOwner,
|
||||
IsSelf: participant.IsCurrentUser,
|
||||
}
|
||||
if participant.AvatarFileID != nil {
|
||||
item.AvatarFileID = formatSnowflake(*participant.AvatarFileID)
|
||||
}
|
||||
payload.Recipients = append(payload.Recipients, item)
|
||||
}
|
||||
if summary.LastMessageAt != nil {
|
||||
payload.LastMessageAt = summary.LastMessageAt.UTC().Format(timeLayout)
|
||||
}
|
||||
if summary.LastMessageID != 0 {
|
||||
payload.LastMessageID = formatSnowflake(summary.LastMessageID)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
payload.Recipient.UserID = formatSnowflake(summary.RecipientID)
|
||||
payload.Recipient.Username = summary.RecipientLogin
|
||||
payload.Recipient.DisplayName = summary.RecipientName
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"glchat/internal/gateway"
|
||||
)
|
||||
|
||||
// dmChannelList — ответ GET /users/@me/channels со всеми полями групп.
|
||||
type dmChannelList struct {
|
||||
Channels []struct {
|
||||
ID string `json:"id"`
|
||||
IsGroup bool `json:"is_group"`
|
||||
Name string `json:"name"`
|
||||
MemberCount int `json:"member_count"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Recipient struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
} `json:"recipient"`
|
||||
Recipients []struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IsOwner bool `json:"is_owner"`
|
||||
IsSelf bool `json:"is_self"`
|
||||
} `json:"recipients"`
|
||||
} `json:"channels"`
|
||||
}
|
||||
|
||||
func listDMChannels(t *testing.T, srv *Server, cookie *http.Cookie) dmChannelList {
|
||||
t.Helper()
|
||||
rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/channels", "", cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /users/@me/channels = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return decodeResponse[dmChannelList](t, rec)
|
||||
}
|
||||
|
||||
// TestGroupDirectChannelLifecycle — групповая беседа целиком (AGENT.md 7.8,
|
||||
// Фаза 7): создание, состав в списке у каждого участника, выход и удаление
|
||||
// участника владельцем, переименование, запрет для посторонних.
|
||||
func TestGroupDirectChannelLifecycle(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
aliceCookie := registerAndLogin(t, srv, "alice_group", "alice-group@example.com")
|
||||
bobCookie := registerAndLogin(t, srv, "bob_group", "bob-group@example.com")
|
||||
carolCookie := registerAndLogin(t, srv, "carol_group", "carol-group@example.com")
|
||||
daveCookie := registerAndLogin(t, srv, "dave_group", "dave-group@example.com")
|
||||
|
||||
userID := func(email string) string {
|
||||
t.Helper()
|
||||
user, err := srv.auth.UserByEmail(t.Context(), email)
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail(%s): %v", email, err)
|
||||
}
|
||||
return formatSnowflake(user.ID)
|
||||
}
|
||||
aliceID, bobID, carolID, daveID := userID("alice-group@example.com"),
|
||||
userID("bob-group@example.com"), userID("carol-group@example.com"), userID("dave-group@example.com")
|
||||
|
||||
// Создание: Алиса + Боб + Кэрол.
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels/group",
|
||||
`{"name":"Тестовая группа","recipient_ids":["`+bobID+`","`+carolID+`"]}`, aliceCookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("create group = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
created := decodeResponse[struct {
|
||||
Channel struct {
|
||||
ID string `json:"id"`
|
||||
IsGroup bool `json:"is_group"`
|
||||
Name string `json:"name"`
|
||||
MemberCount int `json:"member_count"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
} `json:"channel"`
|
||||
}](t, rec)
|
||||
channelID := created.Channel.ID
|
||||
if channelID == "" || !created.Channel.IsGroup || created.Channel.Name != "Тестовая группа" {
|
||||
t.Fatalf("unexpected group payload: %s", rec.Body.String())
|
||||
}
|
||||
if created.Channel.MemberCount != 3 || created.Channel.OwnerID != aliceID {
|
||||
t.Fatalf("group must have 3 members and alice as owner: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Каждый участник видит беседу в своём списке, посторонний — нет.
|
||||
for _, cookie := range []*http.Cookie{aliceCookie, bobCookie, carolCookie} {
|
||||
list := listDMChannels(t, srv, cookie)
|
||||
found := false
|
||||
for _, channel := range list.Channels {
|
||||
if channel.ID == channelID {
|
||||
found = true
|
||||
if !channel.IsGroup || channel.MemberCount != 3 {
|
||||
t.Fatalf("group channel in list = %+v", channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("participant does not see the group channel")
|
||||
}
|
||||
}
|
||||
for _, channel := range listDMChannels(t, srv, daveCookie).Channels {
|
||||
if channel.ID == channelID {
|
||||
t.Fatal("stranger must not see the group channel")
|
||||
}
|
||||
}
|
||||
|
||||
// Посторонний не может ни переименовать, ни добавить участника (404).
|
||||
rec = doJSON(t, srv, http.MethodPatch, "/api/v1/channels/"+channelID, `{"name":"Взлом"}`, daveCookie)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("rename by stranger = %d, want 404, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = doJSON(t, srv, http.MethodPut, "/api/v1/channels/"+channelID+"/recipients/"+daveID, "", daveCookie)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("add by stranger = %d, want 404", rec.Code)
|
||||
}
|
||||
|
||||
// Участник (не владелец) добавляет Дейва: состав становится 4.
|
||||
rec = doJSON(t, srv, http.MethodPut, "/api/v1/channels/"+channelID+"/recipients/"+daveID, "", bobCookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("add participant = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
added := decodeResponse[struct {
|
||||
Channel struct {
|
||||
MemberCount int `json:"member_count"`
|
||||
Recipients []struct {
|
||||
UserID string `json:"user_id"`
|
||||
IsOwner bool `json:"is_owner"`
|
||||
} `json:"recipients"`
|
||||
} `json:"channel"`
|
||||
}](t, rec)
|
||||
if added.Channel.MemberCount != 4 {
|
||||
t.Fatalf("member count after add = %d, want 4", added.Channel.MemberCount)
|
||||
}
|
||||
if len(added.Channel.Recipients) != 4 || !added.Channel.Recipients[0].IsOwner {
|
||||
t.Fatalf("recipients payload = %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Не владелец не может удалить другого участника.
|
||||
rec = doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+channelID+"/recipients/"+carolID, "", bobCookie)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("remove by non-owner = %d, want 403, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if code := errorCodeOf(t, rec); code != "perm.denied" {
|
||||
t.Fatalf("error code = %q", code)
|
||||
}
|
||||
|
||||
// Владелец удаляет Дейва, и тот теряет доступ к беседе.
|
||||
rec = doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+channelID+"/recipients/"+daveID, "", aliceCookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("owner removes member = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages", `{"content":"привет"}`, daveCookie)
|
||||
if rec.Code != http.StatusForbidden && rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("removed member can post = %d (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Переименование владельцем.
|
||||
rec = doJSON(t, srv, http.MethodPatch, "/api/v1/channels/"+channelID, `{"name":"Новое имя"}`, aliceCookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("owner rename = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
renamed := decodeResponse[struct {
|
||||
Channel struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"channel"`
|
||||
}](t, rec)
|
||||
if renamed.Channel.Name != "Новое имя" {
|
||||
t.Fatalf("renamed channel = %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Кэрол выходит сама: остаётся трое — беседа по-прежнему групповая.
|
||||
rec = doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+channelID+"/recipients/"+carolID, "", carolCookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("leave group = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, channel := range listDMChannels(t, srv, carolCookie).Channels {
|
||||
if channel.ID == channelID {
|
||||
t.Fatal("member who left still sees the group channel")
|
||||
}
|
||||
}
|
||||
|
||||
// После выхода Кэрол остаются двое: беседа снова обычная личная, имя и
|
||||
// владелец сброшены (AGENT.md 7.8).
|
||||
list := listDMChannels(t, srv, aliceCookie)
|
||||
var pair *int
|
||||
for index := range list.Channels {
|
||||
if list.Channels[index].ID == channelID {
|
||||
pair = &index
|
||||
}
|
||||
}
|
||||
if pair == nil {
|
||||
t.Fatal("two-member conversation disappeared from the list")
|
||||
}
|
||||
plain := list.Channels[*pair]
|
||||
if plain.IsGroup || plain.Name != "" || plain.OwnerID != "" {
|
||||
t.Fatalf("two-member conversation must become a plain DM: %+v", plain)
|
||||
}
|
||||
// В списке Алисы собеседник — Боб (у 1:1 поле recipient одно).
|
||||
if plain.Recipient.UserID != bobID {
|
||||
t.Fatalf("unexpected recipient: %+v", plain.Recipient)
|
||||
}
|
||||
|
||||
// Последний участник выходит — беседа удаляется вместе с перепиской:
|
||||
// разговор с самим собой смысла не имеет.
|
||||
rec = doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+channelID+"/recipients/"+bobID, "", bobCookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("last leave = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, channel := range listDMChannels(t, srv, aliceCookie).Channels {
|
||||
if channel.ID == channelID {
|
||||
t.Fatal("conversation with a single member must be removed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupDirectChannelValidation — ограничения групповой беседы: не меньше
|
||||
// трёх участников, не больше десяти, запрет на заблокированных.
|
||||
func TestGroupDirectChannelValidation(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
aliceCookie := registerAndLogin(t, srv, "alice_gval", "alice-gval@example.com")
|
||||
registerAndLogin(t, srv, "bob_gval", "bob-gval@example.com")
|
||||
bob, err := srv.auth.UserByEmail(t.Context(), "bob-gval@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
bobID := formatSnowflake(bob.ID)
|
||||
|
||||
// Один получатель — это обычная личная беседа, а не группа.
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels/group",
|
||||
`{"recipient_ids":["`+bobID+`"]}`, aliceCookie)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("group with one recipient = %d, want 422, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Себя в списке можно не указывать: дубликаты и self игнорируются, но
|
||||
// вдвоём группы не получается — снова 422.
|
||||
rec = doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels/group",
|
||||
`{"recipient_ids":["`+bobID+`","`+bobID+`"]}`, aliceCookie)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("group with duplicate recipients = %d, want 422", rec.Code)
|
||||
}
|
||||
|
||||
// Заблокированного нельзя добавить в группу (AGENT.md 7.2).
|
||||
rec = doJSON(t, srv, http.MethodPut, "/api/v1/users/@me/blocks/"+bobID, "", aliceCookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("block user = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
registerAndLogin(t, srv, "carol_gval", "carol-gval@example.com")
|
||||
carol, err := srv.auth.UserByEmail(t.Context(), "carol-gval@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
rec = doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels/group",
|
||||
`{"recipient_ids":["`+bobID+`","`+formatSnowflake(carol.ID)+`"]}`, aliceCookie)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("group with blocked user = %d, want 403, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if code := errorCodeOf(t, rec); code != "dm.blocked" {
|
||||
t.Fatalf("error code = %q", code)
|
||||
}
|
||||
|
||||
// Имя по умолчанию собирается из участников, если его не задали.
|
||||
rec = doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels/group",
|
||||
`{"recipient_ids":["`+formatSnowflake(carol.ID)+`"]}`, aliceCookie)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
// Кэрол + Алиса = двое: снова не группа, проверяем именно отказ.
|
||||
t.Fatalf("unexpected status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupDirectChannelInReady — групповая беседа приходит в READY снапшоте
|
||||
// с именем, составом и владельцем (AGENT.md 7.8, 8.3).
|
||||
func TestGroupDirectChannelInReady(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
aliceCookie := registerAndLogin(t, srv, "alice_ready", "alice-ready@example.com")
|
||||
registerAndLogin(t, srv, "bob_ready", "bob-ready@example.com")
|
||||
registerAndLogin(t, srv, "carol_ready", "carol-ready@example.com")
|
||||
alice, err := srv.auth.UserByEmail(t.Context(), "alice-ready@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
bob, err := srv.auth.UserByEmail(t.Context(), "bob-ready@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
carol, err := srv.auth.UserByEmail(t.Context(), "carol-ready@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels/group",
|
||||
`{"name":"Готовая группа","recipient_ids":["`+formatSnowflake(bob.ID)+`","`+formatSnowflake(carol.ID)+`"]}`,
|
||||
aliceCookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("create group = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// READY собирает тот же калькулятор прав, что и Gateway (AGENT.md 8.3).
|
||||
builder := gateway.NewSnapshot(srv.store, srv.perms)
|
||||
snapshot, err := builder.Build(t.Context(), alice)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
payload, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal snapshot: %v", err)
|
||||
}
|
||||
var decoded struct {
|
||||
DMChannels []struct {
|
||||
ID string `json:"id"`
|
||||
IsGroup bool `json:"is_group"`
|
||||
Name string `json:"name"`
|
||||
MemberCount int `json:"member_count"`
|
||||
MemberIDs []string `json:"member_ids"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
} `json:"dm_channels"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("decode snapshot: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, channel := range decoded.DMChannels {
|
||||
if channel.IsGroup && channel.Name == "Готовая группа" {
|
||||
found = true
|
||||
if channel.MemberCount != 3 || len(channel.MemberIDs) != 3 {
|
||||
t.Fatalf("group members in READY = %+v", channel)
|
||||
}
|
||||
if channel.OwnerID != formatSnowflake(alice.ID) {
|
||||
t.Fatalf("group owner in READY = %q", channel.OwnerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("group channel is missing in READY: %s", payload)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user