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