Files
glchat/internal/server/guild_templates_test.go
T
grendervill 605b2038f1 feat(templates): личные шаблоны серверов в хранилище и API (Фаза 7)
AGENT.md 7.3: свои шаблоны серверов — сохранить структуру сервера
(роли с правами и цветом, категории и комнаты) и создать по ней новый.
Миграция 00023 создаёт guild_templates (structure_json, source_guild_id,
каскад по владельцу) с индексом владельца.

- store: CRUD личных шаблонов с фильтром по owner_id и защитой лимитов
  (20 шаблонов, 16 КиБ structure_json);
- server: POST /guild-templates, GET /guild-templates/mine и
  DELETE /guild-templates/{id} (MANAGE_GUILD, чужой шаблон — 404),
  создание сервера по template_id=personal:<id> (аудит template=personal:<id>);
- структура шаблона повторяет API ролей (права строкой) и хранит
  slowmode_seconds, user_limit и категорию по имени.
2026-09-26 16:48:01 +03:00

655 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:<id>.
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
}