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 и категорию по имени.
This commit is contained in:
@@ -0,0 +1,158 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGuildTemplatesMigration проверяет миграцию 00023 на чистой базе:
|
||||||
|
// таблица личных шаблонов, индекс владельца, внешние ключи (каскад по
|
||||||
|
// пользователю, обнуление сервера-источника) и откат миграции (AGENT.md 7.3).
|
||||||
|
func TestGuildTemplatesMigration(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
db, err := Open(ctx, Options{
|
||||||
|
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
||||||
|
ReadPool: 2,
|
||||||
|
Migrate: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open() вернул ошибку: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Errorf("Close(): %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
version, err := db.SchemaVersion(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SchemaVersion(): %v", err)
|
||||||
|
}
|
||||||
|
if version < 23 {
|
||||||
|
t.Fatalf("schema_version = %d, ожидалось >= 23", version)
|
||||||
|
}
|
||||||
|
|
||||||
|
columns := map[string]bool{}
|
||||||
|
rows, err := db.Reader.QueryContext(ctx, `SELECT name FROM pragma_table_info('guild_templates')`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pragma_table_info: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
t.Fatalf("scan: %v", err)
|
||||||
|
}
|
||||||
|
columns[name] = true
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
t.Fatalf("rows: %v", err)
|
||||||
|
}
|
||||||
|
for _, expected := range []string{
|
||||||
|
"id", "owner_id", "name", "description", "structure_json", "source_guild_id", "created_at",
|
||||||
|
} {
|
||||||
|
if !columns[expected] {
|
||||||
|
t.Errorf("в guild_templates нет колонки %q", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var indexes int
|
||||||
|
if err := db.Reader.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'guild_templates_owner_idx'`,
|
||||||
|
).Scan(&indexes); err != nil {
|
||||||
|
t.Fatalf("чтение индекса: %v", err)
|
||||||
|
}
|
||||||
|
if indexes != 1 {
|
||||||
|
t.Error("индекс guild_templates_owner_idx не создан")
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerID := int64(2000)
|
||||||
|
otherID := int64(2001)
|
||||||
|
insertUser := `INSERT INTO users (id, username, username_lower, display_name, email_enc, email_index,
|
||||||
|
password_hash, locale, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'enc', ?, 'hash', 'ru', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')`
|
||||||
|
if _, err := db.Writer.ExecContext(ctx, insertUser, ownerID, "tpl", "tpl", "Tpl", "idx-tpl"); err != nil {
|
||||||
|
t.Fatalf("вставка владельца: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Writer.ExecContext(ctx, insertUser, otherID, "tpl2", "tpl2", "Tpl2", "idx-tpl2"); err != nil {
|
||||||
|
t.Fatalf("вставка второго пользователя: %v", err)
|
||||||
|
}
|
||||||
|
guildID := int64(3000)
|
||||||
|
if _, err := db.Writer.ExecContext(ctx,
|
||||||
|
`INSERT INTO guilds (id, name, owner_id, created_at) VALUES (?, 'Источник', ?, '2026-01-01T00:00:00.000Z')`,
|
||||||
|
guildID, ownerID); err != nil {
|
||||||
|
t.Fatalf("вставка сервера: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
insertTemplate := `INSERT INTO guild_templates (id, owner_id, name, structure_json, source_guild_id, created_at)
|
||||||
|
VALUES (?, ?, 'Шаблон', '{"roles":[],"channels":[]}', ?, '2026-01-01T00:00:00.000Z')`
|
||||||
|
if _, err := db.Writer.ExecContext(ctx, insertTemplate, 1, ownerID, guildID); err != nil {
|
||||||
|
t.Fatalf("вставка шаблона: %v", err)
|
||||||
|
}
|
||||||
|
// Владелец обязателен: внешний ключ на users.
|
||||||
|
if _, err := db.Writer.ExecContext(ctx, insertTemplate, 2, 999999, nil); err == nil {
|
||||||
|
t.Fatal("шаблон без пользователя принят, ожидалась ошибка внешнего ключа")
|
||||||
|
}
|
||||||
|
// Удаление сервера-источника не удаляет шаблон, а лишь обнуляет ссылку.
|
||||||
|
if _, err := db.Writer.ExecContext(ctx, `DELETE FROM guilds WHERE id = ?`, guildID); err != nil {
|
||||||
|
t.Fatalf("удаление сервера: %v", err)
|
||||||
|
}
|
||||||
|
var source *int64
|
||||||
|
if err := db.Reader.QueryRowContext(ctx,
|
||||||
|
`SELECT source_guild_id FROM guild_templates WHERE id = 1`).Scan(&source); err != nil {
|
||||||
|
t.Fatalf("чтение шаблона: %v", err)
|
||||||
|
}
|
||||||
|
if source != nil {
|
||||||
|
t.Errorf("source_guild_id = %d, ожидалось NULL", *source)
|
||||||
|
}
|
||||||
|
// Удаление пользователя убирает его шаблоны.
|
||||||
|
if _, err := db.Writer.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, ownerID); err != nil {
|
||||||
|
t.Fatalf("удаление владельца: %v", err)
|
||||||
|
}
|
||||||
|
var remaining int
|
||||||
|
if err := db.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM guild_templates`).Scan(&remaining); err != nil {
|
||||||
|
t.Fatalf("подсчёт шаблонов: %v", err)
|
||||||
|
}
|
||||||
|
if remaining != 0 {
|
||||||
|
t.Errorf("после удаления владельца осталось шаблонов: %d", remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Откат: goose должен вернуть версию 22 и убрать таблицу.
|
||||||
|
goose.SetBaseFS(migrationsFS)
|
||||||
|
goose.SetLogger(goose.NopLogger())
|
||||||
|
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||||
|
t.Fatalf("SetDialect: %v", err)
|
||||||
|
}
|
||||||
|
if err := goose.DownToContext(ctx, db.Writer, "migrations", 22); err != nil {
|
||||||
|
t.Fatalf("откат миграции: %v", err)
|
||||||
|
}
|
||||||
|
var tables int
|
||||||
|
if err := db.Reader.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'guild_templates'`,
|
||||||
|
).Scan(&tables); err != nil {
|
||||||
|
t.Fatalf("чтение sqlite_master: %v", err)
|
||||||
|
}
|
||||||
|
if tables != 0 {
|
||||||
|
t.Error("после отката таблица guild_templates осталась")
|
||||||
|
}
|
||||||
|
if version, err := db.SchemaVersion(ctx); err != nil {
|
||||||
|
t.Fatalf("SchemaVersion после отката: %v", err)
|
||||||
|
} else if version != 22 {
|
||||||
|
t.Errorf("после отката schema_version = %d, ожидалось 22", version)
|
||||||
|
}
|
||||||
|
// Повторное применение возвращает таблицу: миграция идемпотентна по goose.
|
||||||
|
if err := db.Migrate(ctx); err != nil {
|
||||||
|
t.Fatalf("повторная миграция: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Reader.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'guild_templates'`,
|
||||||
|
).Scan(&tables); err != nil {
|
||||||
|
t.Fatalf("чтение sqlite_master: %v", err)
|
||||||
|
}
|
||||||
|
if tables != 1 {
|
||||||
|
t.Error("после повторного применения таблица guild_templates не создана")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Фаза 7: личные шаблоны серверов (AGENT.md 7.3).
|
||||||
|
--
|
||||||
|
-- Шаблон — приватная сущность пользователя: он виден и используется только
|
||||||
|
-- владельцем (owner_id), поэтому у таблицы нет guild_id и связанных с ним
|
||||||
|
-- прав. structure_json хранит структуру в том же виде, что внутренний
|
||||||
|
-- guildTemplate: роли с цветом и правами, категории и комнаты. source_guild_id
|
||||||
|
-- — сервер, с которого структура снята; при удалении сервера ссылка
|
||||||
|
-- обнуляется, а сам шаблон остаётся рабочим. Вместе с пользователем его
|
||||||
|
-- шаблоны удаляются каскадом.
|
||||||
|
CREATE TABLE guild_templates (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
structure_json TEXT NOT NULL,
|
||||||
|
source_guild_id INTEGER REFERENCES guilds (id) ON DELETE SET NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX guild_templates_owner_idx ON guild_templates (owner_id, id DESC);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE guild_templates;
|
||||||
@@ -271,6 +271,9 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
|
|||||||
return output, nil
|
return output, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Личные шаблоны серверов (Фаза 7): сохранить структуру, список и удаление.
|
||||||
|
s.registerUserGuildTemplateRoutes(api, security)
|
||||||
|
|
||||||
huma.Register(api, huma.Operation{
|
huma.Register(api, huma.Operation{
|
||||||
OperationID: "getGuild",
|
OperationID: "getGuild",
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
@@ -1812,11 +1815,12 @@ func (s *Server) recordAudit(ctx context.Context, actor *store.User, guildID uin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createGuild создаёт сервер с ролями по умолчанию и общей комнатой (AGENT.md 6.3).
|
// createGuild создаёт сервер с ролями по умолчанию и структурой выбранного
|
||||||
|
// шаблона — встроенного или личного (AGENT.md 6.3, 7.3).
|
||||||
func (s *Server) createGuild(ctx context.Context, owner *store.User, name, templateID string) (*store.Guild, error) {
|
func (s *Server) createGuild(ctx context.Context, owner *store.User, name, templateID string) (*store.Guild, error) {
|
||||||
template, known := findGuildTemplate(templateID)
|
template, templateReason, err := s.resolveGuildTemplate(ctx, owner, templateID)
|
||||||
if !known {
|
if err != nil {
|
||||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template")
|
return nil, err
|
||||||
}
|
}
|
||||||
settings, err := s.store.InstanceSettings(ctx)
|
settings, err := s.store.InstanceSettings(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1860,7 +1864,8 @@ func (s *Server) createGuild(ctx context.Context, owner *store.User, name, templ
|
|||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.recordAudit(ctx, owner, guild.ID, "guild.create", "guild", &guild.ID, "template="+template.ID)
|
// Причина аудита: встроенный шаблон или personal:<id> у личного (7.3).
|
||||||
|
s.recordAudit(ctx, owner, guild.ID, "guild.create", "guild", &guild.ID, templateReason)
|
||||||
if bypassed {
|
if bypassed {
|
||||||
s.recordAudit(ctx, owner, guild.ID, "limits.bypass", "guild", &guild.ID, "instance admin bypassed guild limit")
|
s.recordAudit(ctx, owner, guild.ID, "limits.bypass", "guild", &guild.ID, "instance admin bypassed guild limit")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,33 +2,50 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
|
||||||
"glchat/internal/permissions"
|
"glchat/internal/permissions"
|
||||||
"glchat/internal/store"
|
"glchat/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Шаблоны серверов (AGENT.md 7.3): при создании можно выбрать встроенный
|
// Шаблоны серверов (AGENT.md 7.3): при создании можно выбрать встроенный
|
||||||
// шаблон — структура комнат и трёх ролей без сообщений. Свои шаблоны — после
|
// шаблон — структура комнат и трёх ролей без сообщений — или свой, снятый с
|
||||||
// MVP, поэтому каталог статический.
|
// существующего сервера. Встроенный каталог статический (guildTemplates),
|
||||||
|
// личные шаблоны живут в БД (guild_templates) и приватны для владельца.
|
||||||
|
|
||||||
// guildTemplateRole — роль, которую создаёт шаблон.
|
// guildTemplateRole — роль, которую создаёт шаблон.
|
||||||
type guildTemplateRole struct {
|
type guildTemplateRole struct {
|
||||||
Name string
|
Name string
|
||||||
Permissions []permissions.Permission
|
Permissions []permissions.Permission
|
||||||
Position int
|
// Color — цвет роли в RGB (0 — по умолчанию).
|
||||||
|
Color int64
|
||||||
|
Position int
|
||||||
// Hoist — показывать участников с этой ролью отдельной группой.
|
// Hoist — показывать участников с этой ролью отдельной группой.
|
||||||
Hoist bool
|
Hoist bool
|
||||||
|
// Mentionable — роль разрешено упоминать.
|
||||||
|
Mentionable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// guildTemplateChannel — комната шаблона; `Category` задаёт категорию, внутри
|
// guildTemplateChannel — комната шаблона; `Category` задаёт категорию, внутри
|
||||||
// которой она создаётся (пусто — верхний уровень).
|
// которой она создаётся (пусто — верхний уровень).
|
||||||
type guildTemplateChannel struct {
|
type guildTemplateChannel struct {
|
||||||
Name string
|
Name string
|
||||||
Type store.ChannelType
|
Type store.ChannelType
|
||||||
Category string
|
Category string
|
||||||
|
SlowmodeSeconds int
|
||||||
|
UserLimit int
|
||||||
}
|
}
|
||||||
|
|
||||||
// guildTemplate — встроенный шаблон сервера.
|
// guildTemplate — шаблон сервера: встроенный из каталога или личный,
|
||||||
|
// разобранный из structure_json (AGENT.md 7.3).
|
||||||
type guildTemplate struct {
|
type guildTemplate struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
@@ -60,8 +77,9 @@ var guildTemplates = []guildTemplate{
|
|||||||
permissions.ViewAuditLog, permissions.MuteMembers, permissions.DeafenMembers,
|
permissions.ViewAuditLog, permissions.MuteMembers, permissions.DeafenMembers,
|
||||||
permissions.MoveMembers,
|
permissions.MoveMembers,
|
||||||
},
|
},
|
||||||
Position: 50,
|
Position: 50,
|
||||||
Hoist: true,
|
Hoist: true,
|
||||||
|
Mentionable: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Channels: []guildTemplateChannel{
|
Channels: []guildTemplateChannel{
|
||||||
@@ -85,8 +103,9 @@ var guildTemplates = []guildTemplate{
|
|||||||
permissions.ConnectVoice, permissions.Speak, permissions.UseSoundboard,
|
permissions.ConnectVoice, permissions.Speak, permissions.UseSoundboard,
|
||||||
permissions.AttachFiles, permissions.AddReactions, permissions.EmbedLinks,
|
permissions.AttachFiles, permissions.AddReactions, permissions.EmbedLinks,
|
||||||
},
|
},
|
||||||
Position: 50,
|
Position: 50,
|
||||||
Hoist: true,
|
Hoist: true,
|
||||||
|
Mentionable: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Channels: []guildTemplateChannel{
|
Channels: []guildTemplateChannel{
|
||||||
@@ -141,10 +160,11 @@ func (s *Server) applyGuildTemplate(ctx context.Context, guild *store.Guild, tem
|
|||||||
if _, err := s.store.CreateRole(ctx, store.CreateRoleParams{
|
if _, err := s.store.CreateRole(ctx, store.CreateRoleParams{
|
||||||
GuildID: guild.ID,
|
GuildID: guild.ID,
|
||||||
Name: role.Name,
|
Name: role.Name,
|
||||||
|
Color: role.Color,
|
||||||
Permissions: uint64(permissionSet(role.Permissions)),
|
Permissions: uint64(permissionSet(role.Permissions)),
|
||||||
Position: role.Position,
|
Position: role.Position,
|
||||||
Hoist: role.Hoist,
|
Hoist: role.Hoist,
|
||||||
Mentionable: true,
|
Mentionable: role.Mentionable,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return humaError(err)
|
return humaError(err)
|
||||||
}
|
}
|
||||||
@@ -177,10 +197,12 @@ func (s *Server) applyGuildTemplate(ctx context.Context, guild *store.Guild, tem
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
params := store.CreateChannelParams{
|
params := store.CreateChannelParams{
|
||||||
GuildID: &guild.ID,
|
GuildID: &guild.ID,
|
||||||
Type: channel.Type,
|
Type: channel.Type,
|
||||||
Name: channel.Name,
|
Name: channel.Name,
|
||||||
Position: position,
|
Position: position,
|
||||||
|
SlowmodeSeconds: channel.SlowmodeSeconds,
|
||||||
|
UserLimit: channel.UserLimit,
|
||||||
}
|
}
|
||||||
if parentID, ok := categories[channel.Category]; ok {
|
if parentID, ok := categories[channel.Category]; ok {
|
||||||
params.ParentID = &parentID
|
params.ParentID = &parentID
|
||||||
@@ -202,6 +224,9 @@ type guildTemplatePayload struct {
|
|||||||
TextCount int `json:"text_channels"`
|
TextCount int `json:"text_channels"`
|
||||||
VoiceCount int `json:"voice_channels"`
|
VoiceCount int `json:"voice_channels"`
|
||||||
Categories []string `json:"categories"`
|
Categories []string `json:"categories"`
|
||||||
|
// CreatedAt и SourceGuildID заполнены только у личных шаблонов.
|
||||||
|
CreatedAt string `json:"created_at,omitempty"`
|
||||||
|
SourceGuildID string `json:"source_guild_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type guildTemplateListOutput struct {
|
type guildTemplateListOutput struct {
|
||||||
@@ -240,3 +265,457 @@ func guildTemplateFromCatalog(template guildTemplate) guildTemplatePayload {
|
|||||||
}
|
}
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Личные шаблоны серверов (AGENT.md 7.3, Фаза 7): владелец сохраняет структуру
|
||||||
|
// своего сервера и создаёт по ней новые. Шаблон приватный — видит и использует
|
||||||
|
// только владелец; чужой шаблон неотличим от несуществующего (AGENT.md 9.7).
|
||||||
|
//
|
||||||
|
// Записи в аудит на CRUD личных шаблонов нет осознанно: шаблон — приватная
|
||||||
|
// сущность пользователя, у неё нет сервера, а журнал аудита живёт на сервере
|
||||||
|
// (AGENT.md 7.10). Единственное действие, попадающее в аудит, — создание
|
||||||
|
// сервера по шаблону: там причина содержит template=<id>.
|
||||||
|
const (
|
||||||
|
// personalTemplatePrefix — личный шаблон в template_id: personal:<id>.
|
||||||
|
personalTemplatePrefix = "personal:"
|
||||||
|
// maxUserGuildTemplateName и maxUserGuildTemplateDescription повторяют
|
||||||
|
// ограничения имён сервера и коротких описаний.
|
||||||
|
maxUserGuildTemplateName = 64
|
||||||
|
maxUserGuildTemplateDescription = 200
|
||||||
|
// maxUserGuildTemplateRoles и maxUserGuildTemplateChannels ограничивают
|
||||||
|
// саму структуру: категории считаются комнатами.
|
||||||
|
maxUserGuildTemplateRoles = 50
|
||||||
|
maxUserGuildTemplateChannels = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// userGuildTemplateStructure — structure_json личного шаблона. Формат повторяет
|
||||||
|
// внутренний guildTemplate: роли (кроме роли по умолчанию) с цветом, позицией
|
||||||
|
// и строкой прав, затем категории и комнаты. Права — строкой как в API ролей
|
||||||
|
// («VIEW_CHANNEL|SEND_MESSAGES», «NONE» — нет прав): маска uint64 не теряет
|
||||||
|
// точность в JSON. Числовая строка при разборе тоже принимается.
|
||||||
|
type userGuildTemplateStructure struct {
|
||||||
|
Roles []userGuildTemplateRole `json:"roles"`
|
||||||
|
Channels []userGuildTemplateChannel `json:"channels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type userGuildTemplateRole struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color int64 `json:"color"`
|
||||||
|
Permissions string `json:"permissions"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
Hoist bool `json:"hoist"`
|
||||||
|
Mentionable bool `json:"mentionable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type userGuildTemplateChannel struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
SlowmodeSeconds int `json:"slowmode_seconds,omitempty"`
|
||||||
|
UserLimit int `json:"user_limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type userGuildTemplateOutput struct {
|
||||||
|
Body struct {
|
||||||
|
Template guildTemplatePayload `json:"template"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type userGuildTemplateListOutput struct {
|
||||||
|
Body struct {
|
||||||
|
Templates []guildTemplatePayload `json:"templates"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type guildTemplateDeleteOutput struct {
|
||||||
|
Body struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerUserGuildTemplateRoutes описывает ручки личных шаблонов серверов.
|
||||||
|
func (s *Server) registerUserGuildTemplateRoutes(api huma.API, security []map[string][]string) {
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "createUserGuildTemplate",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/guild-templates",
|
||||||
|
Summary: "Сохранить структуру сервера личным шаблоном",
|
||||||
|
Tags: []string{"Guilds"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
Body struct {
|
||||||
|
GuildID string `json:"guild_id"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) (*userGuildTemplateOutput, error) {
|
||||||
|
user, _, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
guildID, err := parseID("guild_id", input.Body.GuildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Право MANAGE_GUILD: администратор инстанса проходит всегда
|
||||||
|
// (permissions.Resolved.IsInstanceAdmin).
|
||||||
|
resolved, err := s.guildPermissions(ctx, guildID, user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !resolved.Has(permissions.ManageGuild) {
|
||||||
|
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "permission denied")
|
||||||
|
}
|
||||||
|
guild, err := s.store.GetGuild(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(input.Body.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = guild.Name
|
||||||
|
}
|
||||||
|
if len([]rune(name)) > maxUserGuildTemplateName {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "template name must be 1-64 characters")
|
||||||
|
}
|
||||||
|
description := strings.TrimSpace(input.Body.Description)
|
||||||
|
if len([]rune(description)) > maxUserGuildTemplateDescription {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "template description must be at most 200 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := s.store.CountUserGuildTemplates(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if count >= store.MaxUserGuildTemplates {
|
||||||
|
return nil, humaErrorStatus(http.StatusConflict, "template.limit_reached", "personal guild template limit is reached")
|
||||||
|
}
|
||||||
|
|
||||||
|
structure, err := s.buildUserGuildTemplateStructure(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(structure.Roles) > maxUserGuildTemplateRoles || len(structure.Channels) > maxUserGuildTemplateChannels {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild structure is too large for a template: at most 50 roles and 100 channels")
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(structure)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if len(encoded) > store.MaxUserGuildTemplateBytes {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild template structure is too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := s.store.CreateUserGuildTemplate(ctx, store.CreateUserGuildTemplateParams{
|
||||||
|
OwnerID: user.ID,
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
|
Structure: encoded,
|
||||||
|
SourceGuildID: &guildID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, store.ErrTemplateLimitReached):
|
||||||
|
return nil, humaErrorStatus(http.StatusConflict, "template.limit_reached", "personal guild template limit is reached")
|
||||||
|
case errors.Is(err, store.ErrTemplateTooLarge):
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild template structure is too large")
|
||||||
|
}
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
output := &userGuildTemplateOutput{}
|
||||||
|
output.Body.Template = s.userGuildTemplatePayload(*created)
|
||||||
|
return output, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "listUserGuildTemplates",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/guild-templates/mine",
|
||||||
|
Summary: "Свои шаблоны серверов",
|
||||||
|
Tags: []string{"Guilds"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, _ *struct{}) (*userGuildTemplateListOutput, error) {
|
||||||
|
user, _, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
templates, err := s.store.ListUserGuildTemplates(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
output := &userGuildTemplateListOutput{}
|
||||||
|
output.Body.Templates = make([]guildTemplatePayload, 0, len(templates))
|
||||||
|
for _, template := range templates {
|
||||||
|
output.Body.Templates = append(output.Body.Templates, s.userGuildTemplatePayload(template))
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "deleteUserGuildTemplate",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Path: "/guild-templates/{template_id}",
|
||||||
|
Summary: "Удалить свой шаблон сервера",
|
||||||
|
Tags: []string{"Guilds"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
TemplateID string `path:"template_id"`
|
||||||
|
},
|
||||||
|
) (*guildTemplateDeleteOutput, error) {
|
||||||
|
user, _, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Принимаем и «personal:<id>» (как в списке шаблонов клиента), и сам id.
|
||||||
|
templateID, err := parseID("template_id", strings.TrimPrefix(input.TemplateID, personalTemplatePrefix))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
deleted, err := s.store.DeleteUserGuildTemplate(ctx, templateID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if !deleted {
|
||||||
|
// Чужой шаблон неотличим от несуществующего (AGENT.md 9.7).
|
||||||
|
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "guild template not found")
|
||||||
|
}
|
||||||
|
output := &guildTemplateDeleteOutput{}
|
||||||
|
output.Body.OK = true
|
||||||
|
return output, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildUserGuildTemplateStructure снимает структуру сервера: роли (кроме роли
|
||||||
|
// по умолчанию — «Пользователь» создаётся для каждого сервера сам, AGENT.md 6.3)
|
||||||
|
// и все комнаты вместе с категориями.
|
||||||
|
func (s *Server) buildUserGuildTemplateStructure(ctx context.Context, guildID uint64) (userGuildTemplateStructure, error) {
|
||||||
|
roles, err := s.store.ListGuildRoles(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return userGuildTemplateStructure{}, humaError(err)
|
||||||
|
}
|
||||||
|
structure := userGuildTemplateStructure{
|
||||||
|
Roles: make([]userGuildTemplateRole, 0, len(roles)),
|
||||||
|
Channels: make([]userGuildTemplateChannel, 0, 8),
|
||||||
|
}
|
||||||
|
for _, role := range roles {
|
||||||
|
if role.IsDefault {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
structure.Roles = append(structure.Roles, userGuildTemplateRole{
|
||||||
|
Name: role.Name,
|
||||||
|
Color: role.Color,
|
||||||
|
Permissions: permissions.Permission(role.Permissions).String(),
|
||||||
|
Position: role.Position,
|
||||||
|
Hoist: role.Hoist,
|
||||||
|
Mentionable: role.Mentionable,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
channels, err := s.store.ListGuildChannels(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return userGuildTemplateStructure{}, humaError(err)
|
||||||
|
}
|
||||||
|
// Комната ссылается на категорию по имени: идентификаторы в новом сервере
|
||||||
|
// будут другими (AGENT.md 6.4).
|
||||||
|
categories := make(map[uint64]string, len(channels))
|
||||||
|
for _, channel := range channels {
|
||||||
|
if channel.Type == store.ChannelCategory {
|
||||||
|
categories[channel.ID] = channel.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, channel := range channels {
|
||||||
|
if channel.Type == store.ChannelDM {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := userGuildTemplateChannel{
|
||||||
|
Name: channel.Name,
|
||||||
|
Type: string(channel.Type),
|
||||||
|
Position: channel.Position,
|
||||||
|
SlowmodeSeconds: channel.SlowmodeSeconds,
|
||||||
|
UserLimit: channel.UserLimit,
|
||||||
|
}
|
||||||
|
if channel.ParentID != nil {
|
||||||
|
item.Category = categories[*channel.ParentID]
|
||||||
|
}
|
||||||
|
structure.Channels = append(structure.Channels, item)
|
||||||
|
}
|
||||||
|
return structure, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// structureToGuildTemplate превращает сохранённую структуру во внутренний
|
||||||
|
// шаблон: порядок комнат задаёт position. Битый шаблон — ошибка целиком:
|
||||||
|
// половина сервера хуже, чем отказ.
|
||||||
|
func structureToGuildTemplate(structure userGuildTemplateStructure) (guildTemplate, error) {
|
||||||
|
template := guildTemplate{
|
||||||
|
Roles: make([]guildTemplateRole, 0, len(structure.Roles)),
|
||||||
|
Channels: make([]guildTemplateChannel, 0, len(structure.Channels)),
|
||||||
|
}
|
||||||
|
for _, role := range structure.Roles {
|
||||||
|
name := strings.TrimSpace(role.Name)
|
||||||
|
if name == "" {
|
||||||
|
return guildTemplate{}, errors.New("role name must not be empty")
|
||||||
|
}
|
||||||
|
mask, err := parseTemplatePermissions(role.Permissions)
|
||||||
|
if err != nil {
|
||||||
|
return guildTemplate{}, err
|
||||||
|
}
|
||||||
|
template.Roles = append(template.Roles, guildTemplateRole{
|
||||||
|
Name: name,
|
||||||
|
Permissions: permissionList(mask),
|
||||||
|
Color: role.Color,
|
||||||
|
Position: role.Position,
|
||||||
|
Hoist: role.Hoist,
|
||||||
|
Mentionable: role.Mentionable,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
channels := make([]userGuildTemplateChannel, len(structure.Channels))
|
||||||
|
copy(channels, structure.Channels)
|
||||||
|
sort.SliceStable(channels, func(i, j int) bool { return channels[i].Position < channels[j].Position })
|
||||||
|
for _, channel := range channels {
|
||||||
|
name := strings.TrimSpace(channel.Name)
|
||||||
|
if name == "" {
|
||||||
|
return guildTemplate{}, errors.New("channel name must not be empty")
|
||||||
|
}
|
||||||
|
channelType := store.ChannelType(channel.Type)
|
||||||
|
switch channelType {
|
||||||
|
case store.ChannelText, store.ChannelVoice, store.ChannelCategory:
|
||||||
|
case store.ChannelDM:
|
||||||
|
return guildTemplate{}, errors.New("dm channels are not allowed in a template")
|
||||||
|
default:
|
||||||
|
return guildTemplate{}, fmt.Errorf("unknown channel type %q", channel.Type)
|
||||||
|
}
|
||||||
|
template.Channels = append(template.Channels, guildTemplateChannel{
|
||||||
|
Name: name,
|
||||||
|
Type: channelType,
|
||||||
|
Category: channel.Category,
|
||||||
|
SlowmodeSeconds: channel.SlowmodeSeconds,
|
||||||
|
UserLimit: channel.UserLimit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return template, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// permissionList разворачивает маску в список прав — внутренний вид шаблона.
|
||||||
|
func permissionList(mask permissions.Permission) []permissions.Permission {
|
||||||
|
list := make([]permissions.Permission, 0, 8)
|
||||||
|
for bit := permissions.Permission(1); bit != 0 && bit <= permissions.Administrator; bit <<= 1 {
|
||||||
|
if mask.Has(bit) {
|
||||||
|
list = append(list, bit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTemplatePermissions разбирает права роли из structure_json: строка
|
||||||
|
// названий через «|» (как в API ролей), «NONE» или пустая строка — нет прав;
|
||||||
|
// числовая строка-маска тоже принимается.
|
||||||
|
func parseTemplatePermissions(value string) (permissions.Permission, error) {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" || strings.EqualFold(trimmed, "NONE") {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if mask, err := strconv.ParseUint(trimmed, 10, 64); err == nil {
|
||||||
|
return permissions.Permission(mask), nil
|
||||||
|
}
|
||||||
|
mask, err := permissions.Parse(trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return mask, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveGuildTemplate выбирает шаблон структуры для нового сервера: встроенный
|
||||||
|
// по идентификатору каталога или личный по префиксу personal:<id>. Второе
|
||||||
|
// значение — причина для аудита guild.create (AGENT.md 7.3).
|
||||||
|
func (s *Server) resolveGuildTemplate(ctx context.Context, user *store.User, templateID string) (guildTemplate, string, error) {
|
||||||
|
if !strings.HasPrefix(templateID, personalTemplatePrefix) {
|
||||||
|
template, known := findGuildTemplate(templateID)
|
||||||
|
if !known {
|
||||||
|
return guildTemplate{}, "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template")
|
||||||
|
}
|
||||||
|
return template, "template=" + template.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := parseID("template_id", strings.TrimPrefix(templateID, personalTemplatePrefix))
|
||||||
|
if err != nil {
|
||||||
|
return guildTemplate{}, "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template")
|
||||||
|
}
|
||||||
|
stored, err := s.store.GetUserGuildTemplate(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
return guildTemplate{}, "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template")
|
||||||
|
}
|
||||||
|
return guildTemplate{}, "", humaError(err)
|
||||||
|
}
|
||||||
|
// Чужой личный шаблон неотличим от несуществующего (AGENT.md 9.7).
|
||||||
|
if stored.OwnerID != user.ID {
|
||||||
|
return guildTemplate{}, "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template")
|
||||||
|
}
|
||||||
|
var structure userGuildTemplateStructure
|
||||||
|
if err := json.Unmarshal(stored.Structure, &structure); err != nil {
|
||||||
|
return guildTemplate{}, "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild template structure is invalid")
|
||||||
|
}
|
||||||
|
template, err := structureToGuildTemplate(structure)
|
||||||
|
if err != nil {
|
||||||
|
return guildTemplate{}, "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild template structure is invalid")
|
||||||
|
}
|
||||||
|
return template, "template=" + personalTemplatePrefix + formatSnowflake(stored.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// userGuildTemplatePayload описывает личный шаблон для клиента: тот же
|
||||||
|
// предпросмотр, что у встроенных шаблонов, плюс дата создания и сервер-источник.
|
||||||
|
func (s *Server) userGuildTemplatePayload(template store.UserGuildTemplate) guildTemplatePayload {
|
||||||
|
payload := guildTemplatePayload{
|
||||||
|
ID: formatSnowflake(template.ID),
|
||||||
|
Name: template.Name,
|
||||||
|
Description: template.Description,
|
||||||
|
Roles: []string{"Администратор", "Пользователь"},
|
||||||
|
Categories: []string{},
|
||||||
|
CreatedAt: s.store.Timestamp(template.CreatedAt),
|
||||||
|
}
|
||||||
|
if template.SourceGuildID != nil {
|
||||||
|
payload.SourceGuildID = formatSnowflake(*template.SourceGuildID)
|
||||||
|
}
|
||||||
|
var structure userGuildTemplateStructure
|
||||||
|
if err := json.Unmarshal(template.Structure, &structure); err != nil {
|
||||||
|
// Битый шаблон не должен ломать список: отдаём его без предпросмотра.
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
fillTemplatePreview(&payload, structure)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillTemplatePreview дополняет предпросмотр по структуре шаблона. Роли
|
||||||
|
// «Администратор» и «Пользователь» уже перечислены: они создаются для каждого
|
||||||
|
// сервера (AGENT.md 6.3), поэтому повторно их не добавляем.
|
||||||
|
func fillTemplatePreview(payload *guildTemplatePayload, structure userGuildTemplateStructure) {
|
||||||
|
seen := make(map[string]bool, len(payload.Roles))
|
||||||
|
for _, name := range payload.Roles {
|
||||||
|
seen[name] = true
|
||||||
|
}
|
||||||
|
for _, role := range structure.Roles {
|
||||||
|
name := strings.TrimSpace(role.Name)
|
||||||
|
if name == "" || seen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
payload.Roles = append(payload.Roles, name)
|
||||||
|
}
|
||||||
|
categories := make(map[string]bool, 4)
|
||||||
|
for _, channel := range structure.Channels {
|
||||||
|
switch store.ChannelType(channel.Type) {
|
||||||
|
case store.ChannelText:
|
||||||
|
payload.TextCount++
|
||||||
|
case store.ChannelVoice:
|
||||||
|
payload.VoiceCount++
|
||||||
|
case store.ChannelCategory:
|
||||||
|
if !categories[channel.Name] {
|
||||||
|
categories[channel.Name] = true
|
||||||
|
payload.Categories = append(payload.Categories, channel.Name)
|
||||||
|
}
|
||||||
|
case store.ChannelDM:
|
||||||
|
// Личных бесед в шаблонах нет.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"glchat/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Тесты шаблонов серверов (AGENT.md 7.3): встроенный шаблон создаёт структуру
|
// Тесты шаблонов серверов (AGENT.md 7.3): встроенный шаблон создаёт структуру
|
||||||
// комнат и ролей без сообщений, неизвестный шаблон отклоняется.
|
// комнат и ролей без сообщений, неизвестный шаблон отклоняется, личные шаблоны
|
||||||
|
// приватны и воспроизводят структуру своего сервера (Фаза 7).
|
||||||
|
|
||||||
func TestGuildTemplatesCatalog(t *testing.T) {
|
func TestGuildTemplatesCatalog(t *testing.T) {
|
||||||
srv, _ := newTestServer(t)
|
srv, _ := newTestServer(t)
|
||||||
@@ -190,3 +196,459 @@ func TestGuildFromTemplateCreatesNoDuplicates(t *testing.T) {
|
|||||||
t.Fatalf("комнат %d, ожидалось 7", len(list.Channels))
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Личные шаблоны серверов (AGENT.md 7.3, Фаза 7): владелец сохраняет
|
||||||
|
// структуру своего сервера — роли с правами и цветом, категории и комнаты —
|
||||||
|
// и создаёт по ней новые серверы. Шаблон приватный: список, число и удаление
|
||||||
|
// всегда фильтруются по owner_id, а чужой шаблон для остальных выглядит
|
||||||
|
// несуществующим (AGENT.md 9.7).
|
||||||
|
|
||||||
|
// Пределы хранения личных шаблонов: не больше 20 шаблонов на пользователя и
|
||||||
|
// не больше 16 КиБ на structure_json. Те же значения проверяет HTTP-слой,
|
||||||
|
// чтобы вернуть понятную ошибку (template.limit_reached), а здесь они —
|
||||||
|
// жёсткая защита базы от обхода лимита гонкой двух запросов.
|
||||||
|
const (
|
||||||
|
MaxUserGuildTemplates = 20
|
||||||
|
MaxUserGuildTemplateBytes = 16 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrTemplateLimitReached = errors.New("personal guild template limit reached")
|
||||||
|
ErrTemplateTooLarge = errors.New("personal guild template is too large")
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserGuildTemplate — личный шаблон структуры сервера.
|
||||||
|
type UserGuildTemplate struct {
|
||||||
|
ID uint64
|
||||||
|
OwnerID uint64
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Structure json.RawMessage
|
||||||
|
SourceGuildID *uint64
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUserGuildTemplateParams — параметры нового шаблона; Structure — уже
|
||||||
|
// готовый JSON структуры (формат описан в internal/server/guild_templates.go).
|
||||||
|
type CreateUserGuildTemplateParams struct {
|
||||||
|
ID uint64
|
||||||
|
OwnerID uint64
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Structure json.RawMessage
|
||||||
|
SourceGuildID *uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
const userGuildTemplateColumns = `id, owner_id, name, description, structure_json,
|
||||||
|
source_guild_id, created_at`
|
||||||
|
|
||||||
|
// CreateUserGuildTemplate сохраняет шаблон: проверка лимитов и вставка идут
|
||||||
|
// одной транзакцией, иначе два одновременных запроса обошли бы лимит.
|
||||||
|
func (s *Store) CreateUserGuildTemplate(ctx context.Context, params CreateUserGuildTemplateParams) (*UserGuildTemplate, error) {
|
||||||
|
if len(params.Structure) == 0 {
|
||||||
|
params.Structure = json.RawMessage("{}")
|
||||||
|
}
|
||||||
|
if len(params.Structure) > MaxUserGuildTemplateBytes {
|
||||||
|
return nil, ErrTemplateTooLarge
|
||||||
|
}
|
||||||
|
if params.ID == 0 {
|
||||||
|
params.ID = s.NextID()
|
||||||
|
}
|
||||||
|
err := s.InTx(ctx, func(tx *sql.Tx) error {
|
||||||
|
var count int
|
||||||
|
if err := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM guild_templates WHERE owner_id = ?`, mustID(params.OwnerID),
|
||||||
|
).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count >= MaxUserGuildTemplates {
|
||||||
|
return ErrTemplateLimitReached
|
||||||
|
}
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO guild_templates (id, owner_id, name, description, structure_json, source_guild_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
mustID(params.ID), mustID(params.OwnerID), params.Name, params.Description,
|
||||||
|
string(params.Structure), nullableID(params.SourceGuildID), s.Now())
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
return s.GetUserGuildTemplate(ctx, params.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUserGuildTemplates перечисляет личные шаблоны владельца: свежие первыми
|
||||||
|
// (индекс guild_templates_owner_idx).
|
||||||
|
func (s *Store) ListUserGuildTemplates(ctx context.Context, ownerID uint64) ([]UserGuildTemplate, error) {
|
||||||
|
rows, err := s.reader.QueryContext(ctx,
|
||||||
|
`SELECT `+userGuildTemplateColumns+` FROM guild_templates
|
||||||
|
WHERE owner_id = ? ORDER BY id DESC`, mustID(ownerID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
templates := make([]UserGuildTemplate, 0, 8)
|
||||||
|
for rows.Next() {
|
||||||
|
template, err := scanUserGuildTemplate(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
templates = append(templates, *template)
|
||||||
|
}
|
||||||
|
return templates, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserGuildTemplate отдаёт шаблон по идентификатору. Приватность проверяет
|
||||||
|
// вызывающий код сравнением OwnerID с текущим пользователем: чужой шаблон
|
||||||
|
// должен выглядеть как отсутствующий, а не подтверждать своё существование.
|
||||||
|
func (s *Store) GetUserGuildTemplate(ctx context.Context, id uint64) (*UserGuildTemplate, error) {
|
||||||
|
return scanUserGuildTemplate(s.reader.QueryRowContext(ctx,
|
||||||
|
`SELECT `+userGuildTemplateColumns+` FROM guild_templates WHERE id = ?`, mustID(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUserGuildTemplate удаляет шаблон владельца; false — шаблона нет или он
|
||||||
|
// принадлежит другому пользователю (существование не подтверждаем).
|
||||||
|
func (s *Store) DeleteUserGuildTemplate(ctx context.Context, id, ownerID uint64) (bool, error) {
|
||||||
|
result, err := s.writer.ExecContext(ctx,
|
||||||
|
`DELETE FROM guild_templates WHERE id = ? AND owner_id = ?`, mustID(id), mustID(ownerID))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return affected > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUserGuildTemplates считает личные шаблоны владельца — для проверки
|
||||||
|
// лимита перед сохранением.
|
||||||
|
func (s *Store) CountUserGuildTemplates(ctx context.Context, ownerID uint64) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := s.reader.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM guild_templates WHERE owner_id = ?`, mustID(ownerID)).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanUserGuildTemplate(scanner interface{ Scan(...any) error }) (*UserGuildTemplate, error) {
|
||||||
|
var (
|
||||||
|
template UserGuildTemplate
|
||||||
|
structure string
|
||||||
|
sourceGuildID sql.NullInt64
|
||||||
|
createdAt string
|
||||||
|
)
|
||||||
|
err := scanner.Scan(&template.ID, &template.OwnerID, &template.Name, &template.Description,
|
||||||
|
&structure, &sourceGuildID, &createdAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
template.Structure = json.RawMessage(structure)
|
||||||
|
template.SourceGuildID = optionalID(sourceGuildID)
|
||||||
|
template.CreatedAt = parseTimestamp(createdAt)
|
||||||
|
return &template, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"glchat/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newUserGuildTemplateTestStore открывает временную БД со всеми миграциями и
|
||||||
|
// заводит двух пользователей: шаблоны ссылаются на владельца внешним ключом,
|
||||||
|
// а приватность проверяется на втором аккаунте.
|
||||||
|
func newUserGuildTemplateTestStore(t *testing.T) (*Store, uint64, uint64) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
db, err := database.Open(ctx, database.Options{
|
||||||
|
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
||||||
|
ReadPool: 2,
|
||||||
|
Migrate: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("открыть тестовую БД: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Errorf("закрыть тестовую БД: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
st := New(db)
|
||||||
|
owner, err := st.CreateUser(ctx, CreateUserParams{
|
||||||
|
Username: "tpl_owner",
|
||||||
|
EmailEnc: "enc-owner",
|
||||||
|
EmailIndex: "idx-owner",
|
||||||
|
PasswordHash: "hash",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("создать владельца: %v", err)
|
||||||
|
}
|
||||||
|
other, err := st.CreateUser(ctx, CreateUserParams{
|
||||||
|
Username: "tpl_other",
|
||||||
|
EmailEnc: "enc-other",
|
||||||
|
EmailIndex: "idx-other",
|
||||||
|
PasswordHash: "hash",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("создать второго пользователя: %v", err)
|
||||||
|
}
|
||||||
|
return st, owner.ID, other.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserGuildTemplateLifecycle(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st, ownerID, _ := newUserGuildTemplateTestStore(t)
|
||||||
|
|
||||||
|
guild, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Источник", OwnerID: ownerID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateGuild: %v", err)
|
||||||
|
}
|
||||||
|
structure := json.RawMessage(`{"roles":[{"name":"Модератор","color":16711680,"permissions":"KICK_MEMBERS","position":50,"hoist":true,"mentionable":true}],"channels":[{"name":"Информация","type":"category","position":0}]}`)
|
||||||
|
|
||||||
|
created, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{
|
||||||
|
OwnerID: ownerID,
|
||||||
|
Name: "Мой шаблон",
|
||||||
|
Description: "Структура",
|
||||||
|
Structure: structure,
|
||||||
|
SourceGuildID: &guild.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserGuildTemplate: %v", err)
|
||||||
|
}
|
||||||
|
if created.ID == 0 || created.OwnerID != ownerID || created.Name != "Мой шаблон" {
|
||||||
|
t.Fatalf("неожиданный шаблон: %+v", created)
|
||||||
|
}
|
||||||
|
if created.SourceGuildID == nil || *created.SourceGuildID != guild.ID {
|
||||||
|
t.Fatalf("source_guild_id = %v, ожидался %d", created.SourceGuildID, guild.ID)
|
||||||
|
}
|
||||||
|
if string(created.Structure) != string(structure) {
|
||||||
|
t.Fatalf("structure_json = %s", created.Structure)
|
||||||
|
}
|
||||||
|
if created.CreatedAt.IsZero() {
|
||||||
|
t.Fatal("created_at не заполнен")
|
||||||
|
}
|
||||||
|
|
||||||
|
fetched, err := st.GetUserGuildTemplate(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserGuildTemplate: %v", err)
|
||||||
|
}
|
||||||
|
if fetched.Name != created.Name || fetched.Description != created.Description {
|
||||||
|
t.Fatalf("прочитан другой шаблон: %+v", fetched)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{
|
||||||
|
OwnerID: ownerID,
|
||||||
|
Name: "Второй",
|
||||||
|
Structure: json.RawMessage(`{"roles":[],"channels":[]}`),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserGuildTemplate (второй): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := st.ListUserGuildTemplates(ctx, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListUserGuildTemplates: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Fatalf("шаблонов %d, ожидалось 2", len(list))
|
||||||
|
}
|
||||||
|
// Свежие — первыми: список сортируется по id DESC.
|
||||||
|
if list[0].ID != second.ID || list[1].ID != created.ID {
|
||||||
|
t.Fatalf("порядок шаблонов неверен: %d, %d", list[0].ID, list[1].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := st.CountUserGuildTemplates(ctx, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountUserGuildTemplates: %v", err)
|
||||||
|
}
|
||||||
|
if count != 2 {
|
||||||
|
t.Fatalf("CountUserGuildTemplates = %d, ожидалось 2", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted, err := st.DeleteUserGuildTemplate(ctx, created.ID, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeleteUserGuildTemplate: %v", err)
|
||||||
|
}
|
||||||
|
if !deleted {
|
||||||
|
t.Fatal("свой шаблон не удалён")
|
||||||
|
}
|
||||||
|
if _, err := st.GetUserGuildTemplate(ctx, created.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("удалённый шаблон читается: %v", err)
|
||||||
|
}
|
||||||
|
deleted, err = st.DeleteUserGuildTemplate(ctx, created.ID, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("повторное удаление: %v", err)
|
||||||
|
}
|
||||||
|
if deleted {
|
||||||
|
t.Fatal("повторное удаление вернуло true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Приватность: чужой шаблон не виден в списке и не удаляется, его владелец
|
||||||
|
// продолжает им пользоваться.
|
||||||
|
func TestUserGuildTemplatePrivacy(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st, ownerID, otherID := newUserGuildTemplateTestStore(t)
|
||||||
|
|
||||||
|
created, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{
|
||||||
|
OwnerID: ownerID,
|
||||||
|
Name: "Секретный",
|
||||||
|
Structure: json.RawMessage(`{"roles":[],"channels":[]}`),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserGuildTemplate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := st.ListUserGuildTemplates(ctx, otherID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListUserGuildTemplates: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Fatalf("чужой шаблон виден в списке: %+v", list)
|
||||||
|
}
|
||||||
|
count, err := st.CountUserGuildTemplates(ctx, otherID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountUserGuildTemplates: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("чужой шаблон посчитан: %d", count)
|
||||||
|
}
|
||||||
|
deleted, err := st.DeleteUserGuildTemplate(ctx, created.ID, otherID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeleteUserGuildTemplate: %v", err)
|
||||||
|
}
|
||||||
|
if deleted {
|
||||||
|
t.Fatal("чужой шаблон удалён")
|
||||||
|
}
|
||||||
|
if _, err := st.GetUserGuildTemplate(ctx, created.ID); err != nil {
|
||||||
|
t.Fatalf("шаблон владельца пропал: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserGuildTemplateLimit(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st, ownerID, otherID := newUserGuildTemplateTestStore(t)
|
||||||
|
|
||||||
|
for i := 0; i < MaxUserGuildTemplates; i++ {
|
||||||
|
if _, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{
|
||||||
|
OwnerID: ownerID,
|
||||||
|
Name: "Шаблон",
|
||||||
|
Structure: json.RawMessage(`{"roles":[],"channels":[]}`),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("шаблон %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{
|
||||||
|
OwnerID: ownerID,
|
||||||
|
Name: "Лишний",
|
||||||
|
Structure: json.RawMessage(`{"roles":[],"channels":[]}`),
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrTemplateLimitReached) {
|
||||||
|
t.Fatalf("превышение лимита = %v, ожидалось ErrTemplateLimitReached", err)
|
||||||
|
}
|
||||||
|
count, err := st.CountUserGuildTemplates(ctx, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountUserGuildTemplates: %v", err)
|
||||||
|
}
|
||||||
|
if count != MaxUserGuildTemplates {
|
||||||
|
t.Fatalf("после отказа шаблонов %d, ожидалось %d", count, MaxUserGuildTemplates)
|
||||||
|
}
|
||||||
|
// Лимит считается по владельцу: у второго пользователя место свободно.
|
||||||
|
if _, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{
|
||||||
|
OwnerID: otherID,
|
||||||
|
Name: "Свой",
|
||||||
|
Structure: json.RawMessage(`{"roles":[],"channels":[]}`),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("шаблон второго пользователя: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserGuildTemplateTooLarge(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st, ownerID, _ := newUserGuildTemplateTestStore(t)
|
||||||
|
|
||||||
|
oversized := json.RawMessage(`{"note":"` + strings.Repeat("x", MaxUserGuildTemplateBytes) + `"}`)
|
||||||
|
if _, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{
|
||||||
|
OwnerID: ownerID,
|
||||||
|
Name: "Большой",
|
||||||
|
Structure: oversized,
|
||||||
|
}); !errors.Is(err, ErrTemplateTooLarge) {
|
||||||
|
t.Fatalf("слишком большая структура = %v, ожидалось ErrTemplateTooLarge", err)
|
||||||
|
}
|
||||||
|
count, err := st.CountUserGuildTemplates(ctx, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountUserGuildTemplates: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("отклонённый шаблон сохранён: %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user