package server import ( "encoding/json" "net/http" "strconv" "strings" "testing" "glchat/internal/store" ) // Тесты шаблонов серверов (AGENT.md 7.3): встроенный шаблон создаёт структуру // комнат и ролей без сообщений, неизвестный шаблон отклоняется, личные шаблоны // приватны и воспроизводят структуру своего сервера (Фаза 7). func TestGuildTemplatesCatalog(t *testing.T) { srv, _ := newTestServer(t) user := registerAndLogin(t, srv, "tpl_user", "tpl@example.com") rec := doJSON(t, srv, http.MethodGet, "/api/v1/guild-templates", "", user) if rec.Code != http.StatusOK { t.Fatalf("каталог шаблонов = %d (%s)", rec.Code, rec.Body.String()) } payload := decodeResponse[struct { Templates []struct { ID string `json:"id"` Name string `json:"name"` Roles []string `json:"roles"` TextCount int `json:"text_channels"` VoiceCount int `json:"voice_channels"` Categories []string `json:"categories"` } `json:"templates"` }](t, rec) if len(payload.Templates) != 3 { t.Fatalf("шаблонов %d, ожидалось 3", len(payload.Templates)) } found := map[string]bool{} for _, template := range payload.Templates { found[template.ID] = true if len(template.Roles) < 2 { t.Fatalf("шаблон %s без ролей: %+v", template.ID, template.Roles) } } for _, id := range []string{"empty", "community", "gaming"} { if !found[id] { t.Fatalf("шаблон %s отсутствует в каталоге", id) } } } func TestGuildFromTemplateCreatesStructure(t *testing.T) { cases := []struct { template string wantRoles []string wantText []string wantVoice []string wantCategory []string }{ { template: "empty", wantRoles: []string{"Администратор", "Пользователь"}, wantText: []string{"общий"}, }, { template: "community", wantRoles: []string{"Администратор", "Пользователь", "Модератор"}, wantText: []string{"правила", "объявления", "общий", "флудилка"}, wantVoice: []string{"Голосовой"}, wantCategory: []string{"Информация", "Общение"}, }, { template: "gaming", wantRoles: []string{"Администратор", "Пользователь", "Игрок"}, wantText: []string{"общий", "поиск-игр"}, wantVoice: []string{"Игровая 1", "Игровая 2"}, wantCategory: []string{"Текстовые", "Голосовые"}, }, } for _, item := range cases { t.Run(item.template, func(t *testing.T) { srv, _ := newTestServer(t) owner := registerAndLogin(t, srv, "tpl_"+item.template, "tpl-"+item.template+"@example.com") created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Шаблон","template_id":"`+item.template+`"}`, owner) if created.Code != http.StatusOK { t.Fatalf("создание = %d (%s)", created.Code, created.Body.String()) } guildID := decodeResponse[struct { Guild struct { ID string `json:"id"` } `json:"guild"` }](t, created).Guild.ID roles := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/roles", "", owner) roleNames := make([]string, 0, 4) for _, role := range decodeResponse[struct { Roles []struct { Name string `json:"name"` } `json:"roles"` }](t, roles).Roles { roleNames = append(roleNames, role.Name) } for _, want := range item.wantRoles { if !contains(roleNames, want) { t.Fatalf("роль %q не создана: %v", want, roleNames) } } channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", "", owner) list := decodeResponse[struct { Channels []struct { Name string `json:"name"` Type string `json:"type"` ParentID string `json:"parent_id"` } `json:"channels"` }](t, channels) byType := map[string][]string{} for _, channel := range list.Channels { byType[channel.Type] = append(byType[channel.Type], channel.Name) // Комнаты шаблона лежат внутри своих категорий. if len(item.wantCategory) > 0 && channel.Type != "category" && channel.ParentID == "" { t.Fatalf("комната %q не привязана к категории", channel.Name) } } for _, want := range item.wantText { if !contains(byType["text"], want) { t.Fatalf("текстовая комната %q не создана: %v", want, byType["text"]) } } for _, want := range item.wantVoice { if !contains(byType["voice"], want) { t.Fatalf("голосовая комната %q не создана: %v", want, byType["voice"]) } } for _, want := range item.wantCategory { if !contains(byType["category"], want) { t.Fatalf("категория %q не создана: %v", want, byType["category"]) } } }) } } func TestGuildWithUnknownTemplateRejected(t *testing.T) { srv, _ := newTestServer(t) owner := registerAndLogin(t, srv, "tpl_bad", "tpl-bad@example.com") rec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Странный","template_id":"нет-такого"}`, owner) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("неизвестный шаблон = %d (%s)", rec.Code, rec.Body.String()) } } // contains проверяет наличие строки в списке. func contains(list []string, value string) bool { for _, item := range list { if item == value { return true } } return false } func TestGuildFromTemplateCreatesNoDuplicates(t *testing.T) { srv, _ := newTestServer(t) owner := registerAndLogin(t, srv, "tpl_dup", "tpl-dup@example.com") created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Дубли","template_id":"community"}`, owner) guildID := decodeResponse[struct { Guild struct { ID string `json:"id"` } `json:"guild"` }](t, created).Guild.ID channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", "", owner) list := decodeResponse[struct { Channels []struct { Name string `json:"name"` Type string `json:"type"` } `json:"channels"` }](t, channels) seen := map[string]int{} for _, channel := range list.Channels { seen[channel.Type+":"+channel.Name]++ } for key, count := range seen { if count > 1 { t.Fatalf("комната %s создана %d раза", key, count) } } // «Сообщество»: 2 категории, 4 текстовые и 1 голосовая. if len(list.Channels) != 7 { t.Fatalf("комнат %d, ожидалось 7", len(list.Channels)) } } // guildChannels читает комнаты сервера: имя, тип, родитель, медленный режим и // лимит участников — по ним проверяется воспроизведение структуры. type guildChannelView struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` ParentID string `json:"parent_id"` SlowmodeSeconds int `json:"slowmode_seconds"` UserLimit int `json:"user_limit"` } func readGuildChannels(t *testing.T, srv *Server, guildID string, user *http.Cookie) []guildChannelView { t.Helper() rec := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", "", user) if rec.Code != http.StatusOK { t.Fatalf("GET /guilds/%s/channels = %d (%s)", guildID, rec.Code, rec.Body.String()) } return decodeResponse[struct { Channels []guildChannelView `json:"channels"` }](t, rec).Channels } // guildRoleView — роль глазами клиента: цвет и права строкой. type guildRoleView struct { ID string `json:"id"` Name string `json:"name"` Color int64 `json:"color"` Permissions string `json:"permissions"` Mentionable bool `json:"mentionable"` } func readGuildRoles(t *testing.T, srv *Server, guildID string, user *http.Cookie) []guildRoleView { t.Helper() rec := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/roles", "", user) if rec.Code != http.StatusOK { t.Fatalf("GET /guilds/%s/roles = %d (%s)", guildID, rec.Code, rec.Body.String()) } return decodeResponse[struct { Roles []guildRoleView `json:"roles"` }](t, rec).Roles } // TestUserGuildTemplateRoundTrip сохраняет структуру сервера личным шаблоном и // создаёт по нему новый сервер: роли с правами и цветом, категории и комнаты с // типами и настройками воспроизводятся (AGENT.md 7.3, Фаза 7). func TestUserGuildTemplateRoundTrip(t *testing.T) { srv, _ := newTestServer(t) owner := registerAndLogin(t, srv, "tpl_save", "tpl-save@example.com") source := createGuildForTest(t, srv, owner, "Источник") // Своя роль с цветом и правами: в шаблон она должна попасть целиком. roleRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+source+"/roles", `{"name":"Модератор","color":16711680,"permissions":"KICK_MEMBERS|MANAGE_MESSAGES","hoist":true,"mentionable":true}`, owner) if roleRec.Code != http.StatusOK { t.Fatalf("создание роли = %d (%s)", roleRec.Code, roleRec.Body.String()) } category := createChannelForTest(t, srv, owner, source, `{"name":"Информация","type":"category"}`) createChannelForTest(t, srv, owner, source, `{"name":"правила","type":"text","parent_id":"`+category+`","slowmode_seconds":30}`) createChannelForTest(t, srv, owner, source, `{"name":"Голосовой","type":"voice","parent_id":"`+category+`","user_limit":5}`) saved := doJSON(t, srv, http.MethodPost, "/api/v1/guild-templates", `{"guild_id":"`+source+`","name":"Мой шаблон","description":"Структура источника"}`, owner) if saved.Code != http.StatusOK { t.Fatalf("сохранение шаблона = %d (%s)", saved.Code, saved.Body.String()) } templatePayload := decodeResponse[struct { Template struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Roles []string `json:"roles"` TextCount int `json:"text_channels"` VoiceCount int `json:"voice_channels"` Categories []string `json:"categories"` CreatedAt string `json:"created_at"` SourceGuild string `json:"source_guild_id"` } `json:"template"` }](t, saved).Template if templatePayload.ID == "" || templatePayload.Name != "Мой шаблон" { t.Fatalf("неожиданный шаблон: %+v", templatePayload) } if templatePayload.CreatedAt == "" || templatePayload.SourceGuild != source { t.Fatalf("created_at/source_guild_id = %q/%q", templatePayload.CreatedAt, templatePayload.SourceGuild) } if !contains(templatePayload.Roles, "Модератор") { t.Fatalf("роль шаблона потеряна: %v", templatePayload.Roles) } if templatePayload.TextCount != 2 || templatePayload.VoiceCount != 1 { t.Fatalf("комнат в шаблоне: text=%d voice=%d, ожидалось 2/1", templatePayload.TextCount, templatePayload.VoiceCount) } if !contains(templatePayload.Categories, "Информация") { t.Fatalf("категория шаблона потеряна: %v", templatePayload.Categories) } // Формат structure_json: права — строкой как в API ролей, комната ссылается // на категорию по имени (идентификаторы в новом сервере другие). templateID, err := strconv.ParseUint(templatePayload.ID, 10, 64) if err != nil { t.Fatalf("идентификатор шаблона %q: %v", templatePayload.ID, err) } stored, err := srv.store.GetUserGuildTemplate(t.Context(), templateID) if err != nil { t.Fatalf("GetUserGuildTemplate: %v", err) } var structure userGuildTemplateStructure if err := json.Unmarshal(stored.Structure, &structure); err != nil { t.Fatalf("разбор structure_json: %v (%s)", err, stored.Structure) } var storedRole *userGuildTemplateRole for i := range structure.Roles { if structure.Roles[i].Name == "Модератор" { storedRole = &structure.Roles[i] } } if storedRole == nil { t.Fatalf("в structure_json нет роли: %s", stored.Structure) } if storedRole.Color != 16711680 || !strings.Contains(storedRole.Permissions, "KICK_MEMBERS") { t.Errorf("роль в structure_json: %+v", *storedRole) } var storedChannel *userGuildTemplateChannel for i := range structure.Channels { if structure.Channels[i].Name == "правила" { storedChannel = &structure.Channels[i] } } if storedChannel == nil { t.Fatalf("в structure_json нет комнаты: %s", stored.Structure) } if storedChannel.Type != "text" || storedChannel.Category != "Информация" || storedChannel.SlowmodeSeconds != 30 { t.Errorf("комната в structure_json: %+v", *storedChannel) } // Каталог встроенных шаблонов личными не засоряется. catalog := doJSON(t, srv, http.MethodGet, "/api/v1/guild-templates", "", owner) if count := len(decodeResponse[struct { Templates []struct { ID string `json:"id"` } `json:"templates"` }](t, catalog).Templates); count != 3 { t.Fatalf("встроенных шаблонов %d, ожидалось 3", count) } mine := doJSON(t, srv, http.MethodGet, "/api/v1/guild-templates/mine", "", owner) if count := len(decodeResponse[struct { Templates []struct { ID string `json:"id"` } `json:"templates"` }](t, mine).Templates); count != 1 { t.Fatalf("своих шаблонов %d, ожидалось 1", count) } created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Копия","template_id":"personal:`+templatePayload.ID+`"}`, owner) if created.Code != http.StatusOK { t.Fatalf("создание сервера по личному шаблону = %d (%s)", created.Code, created.Body.String()) } copyID := decodeResponse[struct { Guild struct { ID string `json:"id"` Name string `json:"name"` } `json:"guild"` }](t, created).Guild.ID if copyID == "" || copyID == source { t.Fatalf("сервер не создан: %q", copyID) } roles := readGuildRoles(t, srv, copyID, owner) var copied *guildRoleView for i := range roles { if roles[i].Name == "Модератор" { copied = &roles[i] } } if copied == nil { t.Fatalf("роль «Модератор» не создана: %+v", roles) } if copied.Color != 16711680 { t.Errorf("цвет роли = %d, ожидался 16711680", copied.Color) } if !strings.Contains(copied.Permissions, "KICK_MEMBERS") || !strings.Contains(copied.Permissions, "MANAGE_MESSAGES") { t.Errorf("права роли = %q", copied.Permissions) } if !copied.Mentionable { t.Error("роль не упоминаемая") } // Роли по умолчанию остаются на месте и не дублируются. for _, name := range []string{"Администратор", "Пользователь"} { seen := 0 for _, role := range roles { if role.Name == name { seen++ } } if seen != 1 { t.Errorf("роль %q создана %d раз, ожидалось 1", name, seen) } } channels := readGuildChannels(t, srv, copyID, owner) byName := map[string]guildChannelView{} for _, channel := range channels { byName[channel.Name] = channel } copiedCategory, ok := byName["Информация"] if !ok || copiedCategory.Type != "category" { t.Fatalf("категория не создана: %+v", channels) } copiedText, ok := byName["правила"] if !ok || copiedText.Type != "text" { t.Fatalf("текстовая комната не создана: %+v", channels) } if copiedText.ParentID != copiedCategory.ID { t.Errorf("текстовая комната вне категории: %+v", copiedText) } if copiedText.SlowmodeSeconds != 30 { t.Errorf("медленный режим = %d, ожидался 30", copiedText.SlowmodeSeconds) } copiedVoice, ok := byName["Голосовой"] if !ok || copiedVoice.Type != "voice" { t.Fatalf("голосовая комната не создана: %+v", channels) } if copiedVoice.UserLimit != 5 { t.Errorf("лимит участников = %d, ожидался 5", copiedVoice.UserLimit) } // Создание сервера по шаблону пишется в аудит с причиной template=personal:. copyGuildID, err := strconv.ParseUint(copyID, 10, 64) if err != nil { t.Fatalf("идентификатор сервера %q: %v", copyID, err) } entries, err := srv.store.ListAuditLog(t.Context(), copyGuildID, 20) if err != nil { t.Fatalf("ListAuditLog: %v", err) } found := false for _, entry := range entries { if entry.Action == "guild.create" && strings.Contains(entry.Reason, "template=personal:"+templatePayload.ID) { found = true } } if !found { t.Fatalf("в аудите нет причины template=personal:%s: %+v", templatePayload.ID, entries) } } // TestUserGuildTemplatePrivacy: чужой личный шаблон не виден в списке, не // удаляется (404) и не может быть использован при создании сервера (422). func TestUserGuildTemplatePrivacy(t *testing.T) { srv, _ := newTestServer(t) owner := registerAndLogin(t, srv, "tpl_priv_owner", "tpl-priv-owner@example.com") other := registerAndLogin(t, srv, "tpl_priv_other", "tpl-priv-other@example.com") source := createGuildForTest(t, srv, owner, "Закрытый") saved := doJSON(t, srv, http.MethodPost, "/api/v1/guild-templates", `{"guild_id":"`+source+`","name":"Секрет"}`, owner) if saved.Code != http.StatusOK { t.Fatalf("сохранение шаблона = %d (%s)", saved.Code, saved.Body.String()) } templateID := decodeResponse[struct { Template struct { ID string `json:"id"` } `json:"template"` }](t, saved).Template.ID mine := doJSON(t, srv, http.MethodGet, "/api/v1/guild-templates/mine", "", other) if count := len(decodeResponse[struct { Templates []struct { ID string `json:"id"` } `json:"templates"` }](t, mine).Templates); count != 0 { t.Fatalf("чужой шаблон виден в списке: %d", count) } deleted := doJSON(t, srv, http.MethodDelete, "/api/v1/guild-templates/"+templateID, "", other) if deleted.Code != http.StatusNotFound { t.Fatalf("удаление чужого шаблона = %d (%s)", deleted.Code, deleted.Body.String()) } if code := errorCodeOf(t, deleted); code != "not_found" { t.Fatalf("код ошибки = %q, ожидался not_found", code) } created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Чужой","template_id":"personal:`+templateID+`"}`, other) if created.Code != http.StatusUnprocessableEntity { t.Fatalf("создание по чужому шаблону = %d (%s)", created.Code, created.Body.String()) } if code := errorCodeOf(t, created); code != "validation.failed" { t.Fatalf("код ошибки = %q, ожидался validation.failed", code) } // Владелец по-прежнему видит и удаляет свой шаблон. ok := doJSON(t, srv, http.MethodDelete, "/api/v1/guild-templates/"+templateID, "", owner) if ok.Code != http.StatusOK { t.Fatalf("удаление своего шаблона = %d (%s)", ok.Code, ok.Body.String()) } if mine := doJSON(t, srv, http.MethodGet, "/api/v1/guild-templates/mine", "", owner); len(decodeResponse[struct { Templates []struct { ID string `json:"id"` } `json:"templates"` }](t, mine).Templates) != 0 { t.Fatal("шаблон не удалён") } } // TestUserGuildTemplateRequiresManageGuild: участник без MANAGE_GUILD не может // сохранить шаблон с чужого сервера. func TestUserGuildTemplateRequiresManageGuild(t *testing.T) { srv, _ := newTestServer(t) owner := registerAndLogin(t, srv, "tpl_perm_owner", "tpl-perm-owner@example.com") member := registerAndLogin(t, srv, "tpl_perm_member", "tpl-perm-member@example.com") source := createGuildForTest(t, srv, owner, "Права") invite := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+source+"/invites", `{}`, owner) if invite.Code != http.StatusOK { t.Fatalf("создание приглашения = %d (%s)", invite.Code, invite.Body.String()) } code := decodeResponse[struct { Invite struct { Code string `json:"code"` } `json:"invite"` }](t, invite).Invite.Code if joined := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+code, "", member); joined.Code != http.StatusOK { t.Fatalf("вступление по приглашению = %d (%s)", joined.Code, joined.Body.String()) } denied := doJSON(t, srv, http.MethodPost, "/api/v1/guild-templates", `{"guild_id":"`+source+`","name":"Нельзя"}`, member) if denied.Code != http.StatusForbidden { t.Fatalf("сохранение без MANAGE_GUILD = %d (%s)", denied.Code, denied.Body.String()) } if code := errorCodeOf(t, denied); code != "perm.denied" { t.Fatalf("код ошибки = %q, ожидался perm.denied", code) } } // TestUserGuildTemplateLimitReached: двадцать первый личный шаблон отклоняется // с кодом template.limit_reached (409). func TestUserGuildTemplateLimitReached(t *testing.T) { srv, _ := newTestServer(t) owner := registerAndLogin(t, srv, "tpl_limit", "tpl-limit@example.com") source := createGuildForTest(t, srv, owner, "Лимит") account, err := srv.auth.UserByEmail(t.Context(), "tpl-limit@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } for i := 0; i < store.MaxUserGuildTemplates; i++ { if _, err := srv.store.CreateUserGuildTemplate(t.Context(), store.CreateUserGuildTemplateParams{ OwnerID: account.ID, Name: "Заполнение", Structure: json.RawMessage(`{"roles":[],"channels":[]}`), }); err != nil { t.Fatalf("шаблон %d: %v", i, err) } } rec := doJSON(t, srv, http.MethodPost, "/api/v1/guild-templates", `{"guild_id":"`+source+`","name":"Лишний"}`, owner) if rec.Code != http.StatusConflict { t.Fatalf("превышение лимита = %d (%s)", rec.Code, rec.Body.String()) } if code := errorCodeOf(t, rec); code != "template.limit_reached" { t.Fatalf("код ошибки = %q, ожидался template.limit_reached", code) } } // TestInstanceAdminSavesForeignGuildTemplate: у администратора инстанса // MANAGE_GUILD есть на любом сервере (AGENT.md 7.19). func TestInstanceAdminSavesForeignGuildTemplate(t *testing.T) { srv, _ := newTestServer(t) owner := registerAndLogin(t, srv, "tpl_admin_owner", "tpl-admin-owner@example.com") admin := registerAndLogin(t, srv, "tpl_admin", "tpl-admin@example.com") promoteAdmin(t, srv, "tpl-admin@example.com") source := createGuildForTest(t, srv, owner, "Чужой сервер") rec := doJSON(t, srv, http.MethodPost, "/api/v1/guild-templates", `{"guild_id":"`+source+`","name":"От админа"}`, admin) if rec.Code != http.StatusOK { t.Fatalf("админ инстанса не сохранил шаблон = %d (%s)", rec.Code, rec.Body.String()) } template := decodeResponse[struct { Template struct { ID string `json:"id"` SourceGuild string `json:"source_guild_id"` } `json:"template"` }](t, rec).Template if template.SourceGuild != source { t.Fatalf("source_guild_id = %q, ожидался %q", template.SourceGuild, source) } if created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"По чужому","template_id":"personal:`+template.ID+`"}`, admin); created.Code != http.StatusOK { t.Fatalf("создание сервера админом = %d (%s)", created.Code, created.Body.String()) } } // TestUserGuildTemplateStructureLimit: слишком большая структура (больше 100 // комнат) в шаблон не попадает — 422 validation.failed. func TestUserGuildTemplateStructureLimit(t *testing.T) { srv, _ := newTestServer(t) owner := registerAndLogin(t, srv, "tpl_big", "tpl-big@example.com") source := createGuildForTest(t, srv, owner, "Большой") guildID, err := strconv.ParseUint(source, 10, 64) if err != nil { t.Fatalf("идентификатор сервера %q: %v", source, err) } // В сервере уже есть общая комната, поэтому лимит превышен на одну. for i := 0; i < maxUserGuildTemplateChannels; i++ { if _, err := srv.store.CreateChannel(t.Context(), store.CreateChannelParams{ GuildID: &guildID, Type: store.ChannelText, Name: "комната-" + strconv.Itoa(i), Position: i + 1, }); err != nil { t.Fatalf("комната %d: %v", i, err) } } rec := doJSON(t, srv, http.MethodPost, "/api/v1/guild-templates", `{"guild_id":"`+source+`","name":"Слишком большой"}`, owner) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("слишком большая структура = %d (%s)", rec.Code, rec.Body.String()) } if code := errorCodeOf(t, rec); code != "validation.failed" { t.Fatalf("код ошибки = %q, ожидался validation.failed", code) } } // createGuildForTest создаёт сервер без шаблона и возвращает его id. func createGuildForTest(t *testing.T, srv *Server, user *http.Cookie, name string) string { t.Helper() rec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"`+name+`"}`, user) if rec.Code != http.StatusOK { t.Fatalf("создание сервера %q = %d (%s)", name, rec.Code, rec.Body.String()) } return decodeResponse[struct { Guild struct { ID string `json:"id"` } `json:"guild"` }](t, rec).Guild.ID } // createChannelForTest создаёт комнату и возвращает её id. func createChannelForTest(t *testing.T, srv *Server, user *http.Cookie, guildID, body string) string { t.Helper() rec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/channels", body, user) if rec.Code != http.StatusOK { t.Fatalf("создание комнаты = %d (%s)", rec.Code, rec.Body.String()) } return decodeResponse[struct { Channel struct { ID string `json:"id"` } `json:"channel"` }](t, rec).Channel.ID }