Files
glchat/internal/server/group_dm_test.go
T
grendervill f9464130cd 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 (сайдбар, шапка, создание,
выключенная кнопка).
2026-09-26 15:40:17 +03:00

337 lines
14 KiB
Go

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