2026-09-21 18:04:52 +03:00
|
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-09-26 16:45:10 +03:00
|
|
|
|
"encoding/json"
|
2026-09-21 18:04:52 +03:00
|
|
|
|
"net/http"
|
2026-09-26 16:45:10 +03:00
|
|
|
|
"strconv"
|
|
|
|
|
|
"strings"
|
2026-09-21 18:04:52 +03:00
|
|
|
|
"testing"
|
2026-09-26 16:45:10 +03:00
|
|
|
|
|
|
|
|
|
|
"glchat/internal/store"
|
2026-09-21 18:04:52 +03:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Тесты шаблонов серверов (AGENT.md 7.3): встроенный шаблон создаёт структуру
|
2026-09-26 16:45:10 +03:00
|
|
|
|
// комнат и ролей без сообщений, неизвестный шаблон отклоняется, личные шаблоны
|
|
|
|
|
|
// приватны и воспроизводят структуру своего сервера (Фаза 7).
|
2026-09-21 18:04:52 +03:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-09-21 18:16:18 +03:00
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-26 16:45:10 +03:00
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|