From 91f415e36d3a4c4af27d549fadd19bc376b042bc Mon Sep 17 00:00:00 2001 From: grendervill Date: Mon, 21 Sep 2026 18:04:52 +0300 Subject: [PATCH] =?UTF-8?q?feat(guilds):=20=D0=B2=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - каталог шаблонов «Пустой», «Сообщество», «Игровой» (AGENT.md 7.3): роли, категории, текстовые и голосовые комнаты без сообщений - POST /guilds принимает template_id, неизвестный шаблон отклоняется; аудит фиксирует выбранный шаблон - GET /guild-templates отдаёт каталог с предпросмотром структуры - клиент: выбор шаблона в окне создания сервера - тесты: Go (структура каждого шаблона) и Vitest (выбор шаблона) --- internal/server/api_guilds.go | 39 +++- internal/server/guild_templates.go | 237 ++++++++++++++++++++++++ internal/server/guild_templates_test.go | 159 ++++++++++++++++ web/src/api/guilds.ts | 27 ++- web/src/i18n/locales/en.json | 4 +- web/src/i18n/locales/ru.json | 4 +- web/src/pages/app/CreateGuildModal.tsx | 53 +++++- web/tests/profileCosmetics.test.tsx | 61 ++++++ web/tests/shell.test.tsx | 3 +- 9 files changed, 572 insertions(+), 15 deletions(-) create mode 100644 internal/server/guild_templates.go create mode 100644 internal/server/guild_templates_test.go diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go index 32f340a..c8e793f 100644 --- a/internal/server/api_guilds.go +++ b/internal/server/api_guilds.go @@ -186,6 +186,9 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) { }, func(ctx context.Context, input *struct { Body struct { Name string `json:"name" minLength:"1" maxLength:"64"` + // TemplateID — встроенный шаблон структуры (AGENT.md 7.3); + // пусто — «Пустой». + TemplateID string `json:"template_id,omitempty"` } }, ) (*guildOutput, error) { @@ -197,13 +200,32 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) { if name == "" { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild name must not be empty") } - guild, err := s.createGuild(ctx, user, name) + guild, err := s.createGuild(ctx, user, name, input.Body.TemplateID) if err != nil { return nil, err } return s.guildOutput(ctx, user, *guild) }) + huma.Register(api, huma.Operation{ + OperationID: "listGuildTemplates", + Method: http.MethodGet, + Path: "/guild-templates", + Summary: "Встроенные шаблоны серверов", + Tags: []string{"Guilds"}, + Security: security, + }, func(ctx context.Context, _ *struct{}) (*guildTemplateListOutput, error) { + if _, _, err := requireUser(ctx); err != nil { + return nil, err + } + output := &guildTemplateListOutput{} + output.Body.Templates = make([]guildTemplatePayload, 0, len(guildTemplates)) + for _, template := range guildTemplates { + output.Body.Templates = append(output.Body.Templates, guildTemplateFromCatalog(template)) + } + return output, nil + }) + huma.Register(api, huma.Operation{ OperationID: "getGuild", Method: http.MethodGet, @@ -1518,7 +1540,11 @@ func (s *Server) recordAudit(ctx context.Context, actor *store.User, guildID uin } // createGuild создаёт сервер с ролями по умолчанию и общей комнатой (AGENT.md 6.3). -func (s *Server) createGuild(ctx context.Context, owner *store.User, name string) (*store.Guild, error) { +func (s *Server) createGuild(ctx context.Context, owner *store.User, name, templateID string) (*store.Guild, error) { + template, known := findGuildTemplate(templateID) + if !known { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template") + } settings, err := s.store.InstanceSettings(ctx) if err != nil { return nil, humaError(err) @@ -1552,17 +1578,16 @@ func (s *Server) createGuild(ctx context.Context, owner *store.User, name string if _, err := bootstrap.SeedGuildDefaults(ctx, s.store, guild, owner); err != nil { return nil, humaError(err) } - if _, err := s.store.CreateChannel(ctx, store.CreateChannelParams{ - GuildID: &guild.ID, Type: store.ChannelText, Name: "общий", Position: 0, - }); err != nil { - return nil, humaError(err) + // Структура комнат и ролей — из выбранного шаблона (AGENT.md 7.3). + if err := s.applyGuildTemplate(ctx, guild, template); err != nil { + return nil, err } if guild.IsMain { if err := s.store.SetInstanceSetting(ctx, "main_guild_id", strconv.FormatUint(guild.ID, 10)); err != nil { return nil, humaError(err) } } - s.recordAudit(ctx, owner, guild.ID, "guild.create", "guild", &guild.ID, "") + s.recordAudit(ctx, owner, guild.ID, "guild.create", "guild", &guild.ID, "template="+template.ID) if bypassed { s.recordAudit(ctx, owner, guild.ID, "limits.bypass", "guild", &guild.ID, "instance admin bypassed guild limit") } diff --git a/internal/server/guild_templates.go b/internal/server/guild_templates.go new file mode 100644 index 0000000..ba74d99 --- /dev/null +++ b/internal/server/guild_templates.go @@ -0,0 +1,237 @@ +package server + +import ( + "context" + + "glchat/internal/permissions" + "glchat/internal/store" +) + +// Шаблоны серверов (AGENT.md 7.3): при создании можно выбрать встроенный +// шаблон — структура комнат и трёх ролей без сообщений. Свои шаблоны — после +// MVP, поэтому каталог статический. + +// guildTemplateRole — роль, которую создаёт шаблон. +type guildTemplateRole struct { + Name string + Permissions []permissions.Permission + Position int + // Hoist — показывать участников с этой ролью отдельной группой. + Hoist bool +} + +// guildTemplateChannel — комната шаблона; `Category` задаёт категорию, внутри +// которой она создаётся (пусто — верхний уровень). +type guildTemplateChannel struct { + Name string + Type store.ChannelType + Category string +} + +// guildTemplate — встроенный шаблон сервера. +type guildTemplate struct { + ID string + Name string + Description string + Roles []guildTemplateRole + Channels []guildTemplateChannel +} + +// guildTemplates — каталог шаблонов; первый — пустой сервер (поведение по +// умолчанию: одна общая текстовая комната). +var guildTemplates = []guildTemplate{ + { + ID: "empty", + Name: "Пустой", + Description: "Одна общая текстовая комната и роли по умолчанию.", + Channels: []guildTemplateChannel{ + {Name: "общий", Type: store.ChannelText}, + }, + }, + { + ID: "community", + Name: "Сообщество", + Description: "Информация и общение: правила, объявления, флудилка и голосовая комната.", + Roles: []guildTemplateRole{ + { + Name: "Модератор", + Permissions: []permissions.Permission{ + permissions.KickMembers, permissions.TimeoutMembers, permissions.ManageMessages, + permissions.ViewAuditLog, permissions.MuteMembers, permissions.DeafenMembers, + permissions.MoveMembers, + }, + Position: 50, + Hoist: true, + }, + }, + Channels: []guildTemplateChannel{ + {Name: "Информация", Type: store.ChannelCategory}, + {Name: "правила", Type: store.ChannelText, Category: "Информация"}, + {Name: "объявления", Type: store.ChannelText, Category: "Информация"}, + {Name: "Общение", Type: store.ChannelCategory}, + {Name: "общий", Type: store.ChannelText, Category: "Общение"}, + {Name: "флудилка", Type: store.ChannelText, Category: "Общение"}, + {Name: "Голосовой", Type: store.ChannelVoice, Category: "Общение"}, + }, + }, + { + ID: "gaming", + Name: "Игровой", + Description: "Текстовые комнаты для сбора и две голосовые под команды.", + Roles: []guildTemplateRole{ + { + Name: "Игрок", + Permissions: []permissions.Permission{ + permissions.ConnectVoice, permissions.Speak, permissions.UseSoundboard, + permissions.AttachFiles, permissions.AddReactions, permissions.EmbedLinks, + }, + Position: 50, + Hoist: true, + }, + }, + Channels: []guildTemplateChannel{ + {Name: "Текстовые", Type: store.ChannelCategory}, + {Name: "общий", Type: store.ChannelText, Category: "Текстовые"}, + {Name: "поиск-игр", Type: store.ChannelText, Category: "Текстовые"}, + {Name: "Голосовые", Type: store.ChannelCategory}, + {Name: "Игровая 1", Type: store.ChannelVoice, Category: "Голосовые"}, + {Name: "Игровая 2", Type: store.ChannelVoice, Category: "Голосовые"}, + }, + }, +} + +// permissionSet собирает маску прав из списка. +func permissionSet(list []permissions.Permission) permissions.Permission { + var result permissions.Permission + for _, permission := range list { + result |= permission + } + return result +} + +// findGuildTemplate ищет шаблон по идентификатору; пустой id — «Пустой». +func findGuildTemplate(id string) (guildTemplate, bool) { + if id == "" { + id = guildTemplates[0].ID + } + for _, template := range guildTemplates { + if template.ID == id { + return template, true + } + } + return guildTemplate{}, false +} + +// applyGuildTemplate создаёт роли и комнаты шаблона. Администратор и +// «Пользователь» уже созданы SeedGuildDefaults, поэтому шаблон добавляет +// только свои роли (AGENT.md 6.3, 7.3). +func (s *Server) applyGuildTemplate(ctx context.Context, guild *store.Guild, template guildTemplate) error { + existing, err := s.store.ListGuildRoles(ctx, guild.ID) + if err != nil { + return humaError(err) + } + known := make(map[string]bool, len(existing)) + for _, role := range existing { + known[role.Name] = true + } + for _, role := range template.Roles { + if known[role.Name] { + continue + } + if _, err := s.store.CreateRole(ctx, store.CreateRoleParams{ + GuildID: guild.ID, + Name: role.Name, + Permissions: uint64(permissionSet(role.Permissions)), + Position: role.Position, + Hoist: role.Hoist, + Mentionable: true, + }); err != nil { + return humaError(err) + } + } + + // Категории создаются до комнат: комнаты ссылаются на них через parent_id. + categories := map[string]uint64{} + position := 0 + for _, channel := range template.Channels { + parentID := uint64(0) + if channel.Category != "" { + id, ok := categories[channel.Category] + if !ok { + created, err := s.store.CreateChannel(ctx, store.CreateChannelParams{ + GuildID: &guild.ID, Type: store.ChannelCategory, + Name: channel.Category, Position: position, + }) + if err != nil { + return humaError(err) + } + categories[channel.Category] = created.ID + position++ + id = created.ID + } + parentID = id + } + params := store.CreateChannelParams{ + GuildID: &guild.ID, + Type: channel.Type, + Name: channel.Name, + Position: position, + } + if parentID != 0 && channel.Type != store.ChannelCategory { + params.ParentID = &parentID + } + if _, err := s.store.CreateChannel(ctx, params); err != nil { + return humaError(err) + } + position++ + } + return nil +} + +// guildTemplatePayload — шаблон для клиента: предпросмотр структуры. +type guildTemplatePayload 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"` +} + +type guildTemplateListOutput struct { + Body struct { + Templates []guildTemplatePayload `json:"templates"` + } +} + +// guildTemplateFromCatalog собирает описание шаблона для клиента. +func guildTemplateFromCatalog(template guildTemplate) guildTemplatePayload { + payload := guildTemplatePayload{ + ID: template.ID, + Name: template.Name, + Description: template.Description, + Roles: []string{"Администратор", "Пользователь"}, + Categories: []string{}, + } + for _, role := range template.Roles { + payload.Roles = append(payload.Roles, role.Name) + } + categories := map[string]bool{} + for _, channel := range template.Channels { + switch 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: + // Личных бесед в шаблонах нет. + } + } + return payload +} diff --git a/internal/server/guild_templates_test.go b/internal/server/guild_templates_test.go new file mode 100644 index 0000000..901805d --- /dev/null +++ b/internal/server/guild_templates_test.go @@ -0,0 +1,159 @@ +package server + +import ( + "net/http" + "testing" +) + +// Тесты шаблонов серверов (AGENT.md 7.3): встроенный шаблон создаёт структуру +// комнат и ролей без сообщений, неизвестный шаблон отклоняется. + +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 +} diff --git a/web/src/api/guilds.ts b/web/src/api/guilds.ts index d5a0d52..a564258 100644 --- a/web/src/api/guilds.ts +++ b/web/src/api/guilds.ts @@ -17,10 +17,33 @@ export const guildChannelsQueryKey = (guildId: string) => ['guilds', guildId, 'c export const guildMembersQueryKey = (guildId: string) => ['guilds', guildId, 'members'] as const; export const guildRolesQueryKey = (guildId: string) => ['guilds', guildId, 'roles'] as const; -export async function createGuild(name: string): Promise { +/** Встроенный шаблон структуры сервера (AGENT.md 7.3). */ +export interface GuildTemplate { + id: string; + name: string; + description: string; + roles: string[]; + text_channels: number; + voice_channels: number; + categories: string[]; +} + +export const guildTemplatesQueryKey = ['guild-templates'] as const; + +/** `GET /guild-templates` — встроенные шаблоны серверов. */ +export async function fetchGuildTemplates(signal?: AbortSignal): Promise { + const payload = await request<{ templates: GuildTemplate[] }>( + '/guild-templates', + signal === undefined ? {} : { signal }, + ); + return payload.templates; +} + +/** `POST /guilds` — создать сервер по шаблону (пусто — «Пустой»). */ +export async function createGuild(name: string, templateId = ''): Promise { const payload = await request<{ guild: GuildSummary }>('/guilds', { method: 'POST', - body: { name }, + body: templateId === '' ? { name } : { name, template_id: templateId }, }); return payload.guild; } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index a3879b6..6b54862 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -194,7 +194,9 @@ "hint": "You can rename it later in the server settings.", "submit": "Create", "limit": "You can create at most {{count}} servers on this instance.", - "limitReached": "You have reached the server limit — remove one of your servers first." + "limitReached": "You have reached the server limit — remove one of your servers first.", + "template": "Server template", + "templateCounts": "{{text}} text · {{voice}} voice · {{roles}} roles" }, "empty": { "title": "You have no servers yet", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 73e877b..cb94441 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -194,7 +194,9 @@ "hint": "Название можно изменить позже в настройках сервера.", "submit": "Создать", "limit": "На инстансе можно создать не более {{count}} серверов.", - "limitReached": "Достигнут лимит серверов — создайте новый после удаления одного из текущих." + "limitReached": "Достигнут лимит серверов — создайте новый после удаления одного из текущих.", + "template": "Шаблон сервера", + "templateCounts": "{{text}} текст. · {{voice}} голос. · {{roles}} роли" }, "empty": { "title": "У вас пока нет серверов", diff --git a/web/src/pages/app/CreateGuildModal.tsx b/web/src/pages/app/CreateGuildModal.tsx index 15d5374..d67fc21 100644 --- a/web/src/pages/app/CreateGuildModal.tsx +++ b/web/src/pages/app/CreateGuildModal.tsx @@ -1,9 +1,9 @@ import { useState } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; -import { createGuild } from '@/api/guilds'; +import { createGuild, fetchGuildTemplates, guildTemplatesQueryKey } from '@/api/guilds'; import { instanceQueryKey } from '@/api/instance'; import { myGuildsQueryKey } from '@/api/users'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; @@ -31,9 +31,19 @@ export function CreateGuildModal() { const upsertGuild = useSessionStore((state) => state.upsertGuild); const [name, setName] = useState(''); + const [templateId, setTemplateId] = useState('empty'); + + // Шаблоны структуры: список небольшой и статический, кэшируется на сессию. + const templates = useQuery({ + queryKey: guildTemplatesQueryKey, + queryFn: ({ signal }) => fetchGuildTemplates(signal), + enabled: open, + staleTime: 300_000, + retry: 0, + }); const create = useMutation({ - mutationFn: (guildName: string) => createGuild(guildName), + mutationFn: (guildName: string) => createGuild(guildName, templateId), onSuccess: (guild) => { close(); setName(''); @@ -83,6 +93,43 @@ export function CreateGuildModal() { maxLength={64} hint={t('guilds.create.hint')} /> + {/* Шаблон задаёт структуру комнат и ролей (AGENT.md 7.3). */} +
+ {t('guilds.create.template')} + {(templates.data ?? []).map((template) => ( + + ))} + {templates.isError ? ( + void templates.refetch()} /> + ) : null} +
{instance.data === undefined ? null : (

{t('guilds.create.limit', { count: instance.data.max_guilds_per_user })} diff --git a/web/tests/profileCosmetics.test.tsx b/web/tests/profileCosmetics.test.tsx index 4209f83..a7455fe 100644 --- a/web/tests/profileCosmetics.test.tsx +++ b/web/tests/profileCosmetics.test.tsx @@ -322,3 +322,64 @@ describe('оформление «для друзей»', () => { expect(within(row).getByTestId('nick-name')).toHaveStyle({ color: '#2ecc71' }); }); }); + +describe('шаблоны серверов', () => { + it('создаёт сервер по выбранному шаблону', async () => { + const templates = [ + { + id: 'empty', + name: 'Пустой', + description: 'Одна общая текстовая комната.', + roles: ['Администратор', 'Пользователь'], + text_channels: 1, + voice_channels: 0, + categories: [], + }, + { + id: 'gaming', + name: 'Игровой', + description: 'Текстовые комнаты и две голосовые.', + roles: ['Администратор', 'Пользователь', 'Игрок'], + text_channels: 2, + voice_channels: 2, + categories: ['Текстовые', 'Голосовые'], + }, + ]; + const fetchMock = installFetch([ + { match: '/api/v1/guild-templates', response: () => json({ templates }) }, + { + match: '/api/v1/guilds', + method: 'POST', + response: () => + json({ + guild: { + id: 'g-new', + name: 'Новый', + owner_id: 'user-1', + is_main: false, + member_count: 1, + my_role_ids: [], + my_permissions: ['ADMINISTRATOR'], + }, + }), + }, + { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, + { match: '/api/v1/users/@me', response: () => json({ user }) }, + { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, + ]); + renderApp('/app'); + await userEvent.click(await screen.findByLabelText('Создать сервер')); + + const picker = await screen.findByTestId('guild-templates'); + await userEvent.click(within(picker).getByTestId('guild-template-gaming')); + await userEvent.type(screen.getByLabelText('Название сервера'), 'Новый'); + await userEvent.click(screen.getByRole('button', { name: 'Создать' })); + + await waitFor(() => { + expect(bodyOf(findRequest(fetchMock, { url: '/guilds', method: 'POST' }))).toMatchObject({ + name: 'Новый', + template_id: 'gaming', + }); + }); + }); +}); diff --git a/web/tests/shell.test.tsx b/web/tests/shell.test.tsx index f20a77a..061b983 100644 --- a/web/tests/shell.test.tsx +++ b/web/tests/shell.test.tsx @@ -337,6 +337,7 @@ describe('основной экран', () => { const createCall = recordedRequests(fetchMock).find( (request) => request.url.endsWith('/api/v1/guilds') && request.method === 'POST', ); - expect(createCall?.body).toEqual({ name: 'Новый' }); + // Шаблон по умолчанию — «Пустой»: структура комнат и ролей (AGENT.md 7.3). + expect(createCall?.body).toEqual({ name: 'Новый', template_id: 'empty' }); }); });