From 02a5b994185f94d85178cc782f2e8e790dd4c676 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 20 Sep 2026 23:26:46 +0300 Subject: [PATCH] =?UTF-8?q?feat(guilds):=20=D0=BE=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=20=E2=80=94=20=D0=B8=D0=BA=D0=BE=D0=BD=D0=BA=D0=B0?= =?UTF-8?q?,=20=D0=B1=D0=B0=D0=BD=D0=BD=D0=B5=D1=80,=20splash=20=D0=B8=20?= =?UTF-8?q?=D0=B0=D0=BA=D1=86=D0=B5=D0=BD=D1=82=D0=BD=D1=8B=D0=B9=20=D1=86?= =?UTF-8?q?=D0=B2=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фаза 4 (AGENT.md 7.4): сервер можно оформить, поля в схеме были с Фазы 1, но ручек и отдачи в API не было. - `POST/DELETE /guilds/{id}/appearance/{icon|banner|splash}` (multipart, MANAGE_GUILD, только изображения до 8 МБ): файл сохраняется назначением `guild_icon|guild_banner|guild_splash`, предыдущий удаляется, пишется аудит и уходит `GUILD_UPDATE`; ответ — обновлённый сервер; - `PATCH /guilds/{id}` принимает `accent_color` (0 — цвет темы); - оформление отдаётся в READY, в списке серверов, в детальной карточке и в предпросмотре приглашения — клиенту не нужен отдельный запрос; - анимированные GIF/APNG/WebP сохраняются как есть: изображения не перекодируются, поэтому анимация не теряется; - тесты: загрузка и очистка всех трёх видов, замена файла, запрет без MANAGE_GUILD и без сессии, отказ для не-изображения, акцентный цвет. --- internal/gateway/gateway.go | 25 +-- internal/gateway/ready.go | 36 ++-- internal/server/api_files.go | 4 +- internal/server/api_guild_appearance.go | 220 ++++++++++++++++++++++++ internal/server/api_guilds.go | 17 +- internal/server/api_invites.go | 22 ++- internal/server/api_users.go | 10 ++ internal/server/appearance_test.go | 187 ++++++++++++++++++++ internal/server/server.go | 1 + 9 files changed, 492 insertions(+), 30 deletions(-) create mode 100644 internal/server/api_guild_appearance.go create mode 100644 internal/server/appearance_test.go diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 32b5f9a..bae666d 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -67,16 +67,21 @@ type ReadyUser struct { } type ReadyGuild struct { - ID string `json:"id"` - Name string `json:"name"` - OwnerID string `json:"owner_id"` - IsMain bool `json:"is_main"` - Channels []ReadyChannel `json:"channels"` - Roles []ReadyRole `json:"roles"` - MemberIDs []string `json:"member_ids"` - MyRoles []string `json:"my_role_ids"` - MyNickname string `json:"my_nickname,omitempty"` - MyPerms []string `json:"my_permissions"` + ID string `json:"id"` + Name string `json:"name"` + OwnerID string `json:"owner_id"` + IsMain bool `json:"is_main"` + // Оформление сервера (AGENT.md 7.4): иконка, баннер, splash и акцент. + IconFileID string `json:"icon_file_id,omitempty"` + BannerFileID string `json:"banner_file_id,omitempty"` + SplashFileID string `json:"splash_file_id,omitempty"` + AccentColor int64 `json:"accent_color,omitempty"` + Channels []ReadyChannel `json:"channels"` + Roles []ReadyRole `json:"roles"` + MemberIDs []string `json:"member_ids"` + MyRoles []string `json:"my_role_ids"` + MyNickname string `json:"my_nickname,omitempty"` + MyPerms []string `json:"my_permissions"` // Emojis — кастомные эмодзи сервера (AGENT.md 7.12). Emojis []ReadyEmoji `json:"emojis"` // VoiceStates — кто находится в голосовых комнатах (AGENT.md 7.14). diff --git a/internal/gateway/ready.go b/internal/gateway/ready.go index 143b6f6..0cb882d 100644 --- a/internal/gateway/ready.go +++ b/internal/gateway/ready.go @@ -128,18 +128,22 @@ func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error) func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *store.User, resolved permissions.Resolved) (ReadyGuild, error) { readyGuild := ReadyGuild{ - ID: formatID(guild.ID), - Name: guild.Name, - OwnerID: formatID(guild.OwnerID), - IsMain: guild.IsMain, - Channels: []ReadyChannel{}, - Roles: []ReadyRole{}, - MemberIDs: []string{}, - MyRoles: []string{}, - MyPerms: []string{}, - Emojis: []ReadyEmoji{}, - VoiceStates: []ReadyVoiceState{}, - Sounds: []ReadySound{}, + ID: formatID(guild.ID), + Name: guild.Name, + OwnerID: formatID(guild.OwnerID), + IsMain: guild.IsMain, + AccentColor: guild.AccentColor, + IconFileID: optionalFormatID(guild.IconFileID), + BannerFileID: optionalFormatID(guild.BannerFileID), + SplashFileID: optionalFormatID(guild.SplashFileID), + Channels: []ReadyChannel{}, + Roles: []ReadyRole{}, + MemberIDs: []string{}, + MyRoles: []string{}, + MyPerms: []string{}, + Emojis: []ReadyEmoji{}, + VoiceStates: []ReadyVoiceState{}, + Sounds: []ReadySound{}, } // Кастомные эмодзи сервера: клиент показывает их в пикере и в тексте. @@ -362,6 +366,14 @@ func visibleStatus(status string, lastSeen *time.Time) string { return status } +// optionalFormatID отдаёт идентификатор файла или пустую строку. +func optionalFormatID(id *uint64) string { + if id == nil || *id == 0 { + return "" + } + return formatID(*id) +} + func formatID(id uint64) string { if id == 0 { return "" diff --git a/internal/server/api_files.go b/internal/server/api_files.go index 63c9375..0d448f1 100644 --- a/internal/server/api_files.go +++ b/internal/server/api_files.go @@ -176,7 +176,9 @@ func (s *Server) deleteStoredFile(ctx context.Context, fileID uint64) { return } if file.StoragePath != "" { - if err := os.Remove(file.StoragePath); err != nil && !os.IsNotExist(err) { + // Путь собран из идентификатора файла и каталога данных (saveUpload), + // имя от клиента в него не попадает (AGENT.md 9.2). + if err := os.Remove(file.StoragePath); err != nil && !os.IsNotExist(err) { //nolint:gosec // путь формирует сам инстанс s.logger.WarnContext(ctx, "failed to remove file from disk", slog.String("file_id", formatSnowflake(fileID)), slog.Any("error", err)) } diff --git a/internal/server/api_guild_appearance.go b/internal/server/api_guild_appearance.go new file mode 100644 index 0000000..e84afa5 --- /dev/null +++ b/internal/server/api_guild_appearance.go @@ -0,0 +1,220 @@ +package server + +import ( + "bytes" + "io" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "glchat/internal/permissions" + "glchat/internal/store" +) + +// Оформление сервера (AGENT.md 7.4, Фаза 4): иконка, баннер, splash для +// страницы приглашения и акцентный цвет. Файлы хранятся обычными загрузками с +// отдельными purpose, поэтому анимированные GIF/APNG/WebP не теряют анимацию. + +// maxGuildImageSize — предел размера для иконки, баннера и splash. +const maxGuildImageSize = 8 << 20 + +type appearanceKind struct { + // field — колонка гильдии, purpose — назначение файла. + field string + purpose string + label string +} + +var guildAppearanceKinds = map[string]appearanceKind{ + "icon": {field: "icon_file_id", purpose: "guild_icon", label: "icon"}, + "banner": {field: "banner_file_id", purpose: "guild_banner", label: "banner"}, + "splash": {field: "splash_file_id", purpose: "guild_splash", label: "splash"}, +} + +// registerGuildAppearanceRoutes описывает загрузку и удаление иконки, баннера +// и splash: huma не принимает multipart, поэтому ручки живут на роутере. +func (s *Server) registerGuildAppearanceRoutes(router chi.Router) { + router.Post("/guilds/{guild_id}/appearance/{kind}", s.handleGuildAppearanceUpload) + router.Delete("/guilds/{guild_id}/appearance/{kind}", s.handleGuildAppearanceDelete) +} + +func (s *Server) handleGuildAppearanceUpload(w http.ResponseWriter, r *http.Request) { + user, _, ok := s.authenticate(w, r) + if !ok { + return + } + ctx := r.Context() + kind, exists := guildAppearanceKinds[chi.URLParam(r, "kind")] + if !exists { + httpxWriteJSONError(w, http.StatusNotFound, "not_found", "unknown appearance kind") + return + } + guildID, err := parseID("guild_id", chi.URLParam(r, "guild_id")) + if err != nil { + writeHumaAPIError(w, err) + return + } + resolved, err := s.guildPermissions(ctx, guildID, user) + if err != nil { + writeHumaAPIError(w, err) + return + } + if !resolved.Has(permissions.ManageGuild) { + httpxWriteJSONError(w, http.StatusForbidden, "perm.denied", "MANAGE_GUILD is required") + return + } + + file, header, err := r.FormFile("file") + if err != nil { + httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`) + return + } + defer func() { _ = file.Close() }() + if header.Size > maxGuildImageSize { + httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", kind.label+" is too large") + return + } + // Читаем в память (не больше 8 МБ): нужно убедиться, что это изображение, + // а не переименованный файл (AGENT.md 9.2). + data, err := io.ReadAll(io.LimitReader(file, maxGuildImageSize+1)) + if err != nil || int64(len(data)) > maxGuildImageSize { + httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", kind.label+" is too large") + return + } + contentType := header.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "image/") { + contentType = http.DetectContentType(data) + } + if !strings.HasPrefix(contentType, "image/") { + httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", kind.label+" must be an image") + return + } + + guild, err := s.store.GetGuild(ctx, guildID) + if err != nil { + writeHumaAPIError(w, humaError(err)) + return + } + // Старый файл удаляем: загрузки не должны копиться (AGENT.md 7.7). + if previous := appearanceFileID(guild, kind.field); previous != nil && *previous != 0 { + s.deleteStoredFile(ctx, *previous) + } + stored, err := s.saveUpload(ctx, store.File{ + UploaderID: &user.ID, + Filename: sanitizeFilename(header.Filename), + ContentType: contentType, + Purpose: kind.purpose, + }, bytes.NewReader(data)) + if err != nil { + writeHumaAPIError(w, err) + return + } + fileID := stored.ID + params := store.UpdateGuildParams{} + setAppearanceFileID(¶ms, kind.field, &fileID) + s.finishAppearanceChange(w, r, user, guildID, kind, params, "update") +} + +func (s *Server) handleGuildAppearanceDelete(w http.ResponseWriter, r *http.Request) { + user, _, ok := s.authenticate(w, r) + if !ok { + return + } + ctx := r.Context() + kind, exists := guildAppearanceKinds[chi.URLParam(r, "kind")] + if !exists { + httpxWriteJSONError(w, http.StatusNotFound, "not_found", "unknown appearance kind") + return + } + guildID, err := parseID("guild_id", chi.URLParam(r, "guild_id")) + if err != nil { + writeHumaAPIError(w, err) + return + } + resolved, err := s.guildPermissions(ctx, guildID, user) + if err != nil { + writeHumaAPIError(w, err) + return + } + if !resolved.Has(permissions.ManageGuild) { + httpxWriteJSONError(w, http.StatusForbidden, "perm.denied", "MANAGE_GUILD is required") + return + } + guild, err := s.store.GetGuild(ctx, guildID) + if err != nil { + writeHumaAPIError(w, humaError(err)) + return + } + if previous := appearanceFileID(guild, kind.field); previous != nil && *previous != 0 { + s.deleteStoredFile(ctx, *previous) + } + params := store.UpdateGuildParams{} + clearAppearanceFileID(¶ms, kind.field) + s.finishAppearanceChange(w, r, user, guildID, kind, params, "delete") +} + +// finishAppearanceChange применяет изменение, сбрасывает кэш, пишет аудит, +// рассылает GUILD_UPDATE и отдаёт обновлённый сервер одним ответом. +func (s *Server) finishAppearanceChange( + w http.ResponseWriter, + r *http.Request, + user *store.User, + guildID uint64, + kind appearanceKind, + params store.UpdateGuildParams, + action string, +) { + ctx := r.Context() + updated, err := s.store.UpdateGuild(ctx, guildID, params) + if err != nil { + writeHumaAPIError(w, humaError(err)) + return + } + s.invalidateGuild(guildID) + s.recordAudit(ctx, user, guildID, "guild."+kind.label+"."+action, "guild", &guildID, "") + if s.gateway != nil { + s.gateway.Dispatch("GUILD_UPDATE", map[string]any{ + "guild_id": formatSnowflake(guildID), + "name": updated.Name, + }) + } + output, err := s.guildOutput(ctx, user, *updated) + if err != nil { + writeHumaAPIError(w, err) + return + } + httpxWriteJSON(w, http.StatusOK, map[string]any{"guild": output.Body.Guild}) +} + +// appearanceFileID читает поле оформления по имени колонки. +func appearanceFileID(guild *store.Guild, field string) *uint64 { + switch field { + case "icon_file_id": + return guild.IconFileID + case "banner_file_id": + return guild.BannerFileID + case "splash_file_id": + return guild.SplashFileID + default: + return nil + } +} + +// setAppearanceFileID проставляет поле оформления в параметрах обновления. +func setAppearanceFileID(params *store.UpdateGuildParams, field string, value *uint64) { + switch field { + case "icon_file_id": + params.IconFileID = value + case "banner_file_id": + params.BannerFileID = value + case "splash_file_id": + params.SplashFileID = value + } +} + +// clearAppearanceFileID очищает поле оформления (нулевой идентификатор). +func clearAppearanceFileID(params *store.UpdateGuildParams, field string) { + cleared := uint64(0) + setAppearanceFileID(params, field, &cleared) +} diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go index 12a2f54..1dc7ad8 100644 --- a/internal/server/api_guilds.go +++ b/internal/server/api_guilds.go @@ -63,6 +63,9 @@ type guildDetailPayload struct { Description string `json:"description"` OwnerID string `json:"owner_id"` IconFileID string `json:"icon_file_id,omitempty"` + BannerFileID string `json:"banner_file_id,omitempty"` + SplashFileID string `json:"splash_file_id,omitempty"` + AccentColor int64 `json:"accent_color,omitempty"` IsMain bool `json:"is_main"` MemberCount int `json:"member_count"` Roles []rolePayload `json:"roles"` @@ -224,6 +227,8 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) { // Публичность сервера: попадает в каталог и открывает вход без // приглашения (AGENT.md 7.20). Public *bool `json:"public,omitempty"` + // Акцентный цвет сервера (0 — цвет темы по умолчанию). + AccentColor *int64 `json:"accent_color,omitempty" minimum:"0" maximum:"16777215"` } }, ) (*guildOutput, error) { @@ -235,7 +240,10 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) { if err != nil { return nil, err } - params := store.UpdateGuildParams{Description: input.Body.Description} + params := store.UpdateGuildParams{ + Description: input.Body.Description, + AccentColor: input.Body.AccentColor, + } if input.Body.Public != nil { // Главный сервер публичным не делаем: он и так точка входа. if *input.Body.Public { @@ -661,6 +669,13 @@ func (s *Server) guildOutput(ctx context.Context, user *store.User, guild store. if guild.IconFileID != nil { payload.IconFileID = formatSnowflake(*guild.IconFileID) } + if guild.BannerFileID != nil { + payload.BannerFileID = formatSnowflake(*guild.BannerFileID) + } + if guild.SplashFileID != nil { + payload.SplashFileID = formatSnowflake(*guild.SplashFileID) + } + payload.AccentColor = guild.AccentColor roleIDs, err := s.store.MemberRoleIDs(ctx, guild.ID, user.ID) if err != nil { return nil, humaError(err) diff --git a/internal/server/api_invites.go b/internal/server/api_invites.go index 3f2f5b2..b8129dd 100644 --- a/internal/server/api_invites.go +++ b/internal/server/api_invites.go @@ -40,12 +40,15 @@ type inviteOutput struct { Body struct { Invite invitePayload `json:"invite"` Guild struct { - ID string `json:"id"` - Name string `json:"name"` - IconFileID string `json:"icon_file_id,omitempty"` - Description string `json:"description,omitempty"` - MemberCount int `json:"member_count"` - IsMember bool `json:"is_member"` + ID string `json:"id"` + Name string `json:"name"` + IconFileID string `json:"icon_file_id,omitempty"` + BannerFileID string `json:"banner_file_id,omitempty"` + SplashFileID string `json:"splash_file_id,omitempty"` + AccentColor int64 `json:"accent_color,omitempty"` + Description string `json:"description,omitempty"` + MemberCount int `json:"member_count"` + IsMember bool `json:"is_member"` } `json:"guild"` } } @@ -377,6 +380,13 @@ func (s *Server) inviteOutput(ctx context.Context, user *store.User, invite *sto if guild.IconFileID != nil { output.Body.Guild.IconFileID = formatSnowflake(*guild.IconFileID) } + if guild.BannerFileID != nil { + output.Body.Guild.BannerFileID = formatSnowflake(*guild.BannerFileID) + } + if guild.SplashFileID != nil { + output.Body.Guild.SplashFileID = formatSnowflake(*guild.SplashFileID) + } + output.Body.Guild.AccentColor = guild.AccentColor if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil { output.Body.Guild.MemberCount = count } diff --git a/internal/server/api_users.go b/internal/server/api_users.go index 47f799f..e8005fc 100644 --- a/internal/server/api_users.go +++ b/internal/server/api_users.go @@ -154,6 +154,9 @@ type guildSummary struct { ID string `json:"id"` Name string `json:"name"` IconFileID string `json:"icon_file_id,omitempty"` + BannerFileID string `json:"banner_file_id,omitempty"` + SplashFileID string `json:"splash_file_id,omitempty"` + AccentColor int64 `json:"accent_color,omitempty"` OwnerID string `json:"owner_id"` IsMain bool `json:"is_main"` MemberCount int `json:"member_count"` @@ -409,6 +412,13 @@ func (s *Server) guildSummary(ctx context.Context, guild store.Guild, userID uin if guild.IconFileID != nil { summary.IconFileID = formatSnowflake(*guild.IconFileID) } + if guild.BannerFileID != nil { + summary.BannerFileID = formatSnowflake(*guild.BannerFileID) + } + if guild.SplashFileID != nil { + summary.SplashFileID = formatSnowflake(*guild.SplashFileID) + } + summary.AccentColor = guild.AccentColor roleIDs, err := s.store.MemberRoleIDs(ctx, guild.ID, userID) if err != nil { return guildSummary{}, humaError(err) diff --git a/internal/server/appearance_test.go b/internal/server/appearance_test.go new file mode 100644 index 0000000..2fa9c49 --- /dev/null +++ b/internal/server/appearance_test.go @@ -0,0 +1,187 @@ +package server + +import ( + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" +) + +// Тесты оформления сервера (AGENT.md 7.4): иконка, баннер и splash доступны +// только с MANAGE_GUILD, принимают изображения и удаляют предыдущий файл. + +// pngBytes — минимальный PNG: сигнатура и служебный блок (для DetectContentType). +func pngBytes() []byte { + return []byte{ + 0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 'I', 'H', 'D', 'R', + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, + } +} + +// uploadAppearance отправляет multipart-запрос загрузки оформления. +func uploadAppearance(t *testing.T, srv *Server, guildID, kind, filename string, content []byte, cookie *http.Cookie) *httptest.ResponseRecorder { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", filename) + if err != nil { + t.Fatalf("multipart: %v", err) + } + if _, err := part.Write(content); err != nil { + t.Fatalf("multipart write: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("multipart close: %v", err) + } + rec := httptest.NewRecorder() + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, + "/api/v1/guilds/"+guildID+"/appearance/"+kind, &body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + if cookie != nil { + request.AddCookie(cookie) + } + srv.Handler().ServeHTTP(rec, request) + return rec +} + +func appearanceGuild(t *testing.T) (srv *Server, owner *http.Cookie, guildID string) { + t.Helper() + srv, _ = newTestServer(t) + owner = registerAndLogin(t, srv, "look_owner", "look-owner@example.com") + created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Оформление"}`, owner) + guild := decodeResponse[struct { + Guild struct { + ID string `json:"id"` + } `json:"guild"` + }](t, created) + return srv, owner, guild.Guild.ID +} + +func TestGuildAppearanceUploadAndClear(t *testing.T) { + srv, owner, guildID := appearanceGuild(t) + + for _, kind := range []string{"icon", "banner", "splash"} { + rec := uploadAppearance(t, srv, guildID, kind, kind+".png", pngBytes(), owner) + if rec.Code != http.StatusOK { + t.Fatalf("%s: статус %d (%s)", kind, rec.Code, rec.Body.String()) + } + payload := decodeResponse[struct { + Guild struct { + IconFileID string `json:"icon_file_id"` + BannerFileID string `json:"banner_file_id"` + SplashFileID string `json:"splash_file_id"` + } `json:"guild"` + }](t, rec) + switch kind { + case "icon": + if payload.Guild.IconFileID == "" { + t.Fatalf("иконка не вернулась: %s", rec.Body.String()) + } + case "banner": + if payload.Guild.BannerFileID == "" { + t.Fatal("баннер не вернулся") + } + case "splash": + if payload.Guild.SplashFileID == "" { + t.Fatal("splash не вернулся") + } + } + + // Повторная загрузка заменяет файл, а не копит их. + second := uploadAppearance(t, srv, guildID, kind, kind+"2.png", pngBytes(), owner) + if second.Code != http.StatusOK { + t.Fatalf("%s (повтор): статус %d", kind, second.Code) + } + + // Удаление очищает поле. + remove := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guildID+"/appearance/"+kind, "", owner) + if remove.Code != http.StatusOK { + t.Fatalf("%s (удаление): статус %d (%s)", kind, remove.Code, remove.Body.String()) + } + cleared := decodeResponse[struct { + Guild struct { + IconFileID string `json:"icon_file_id"` + BannerFileID string `json:"banner_file_id"` + SplashFileID string `json:"splash_file_id"` + } `json:"guild"` + }](t, remove) + if cleared.Guild.IconFileID != "" || cleared.Guild.BannerFileID != "" || cleared.Guild.SplashFileID != "" { + t.Fatalf("%s: после удаления остались файлы: %+v", kind, cleared.Guild) + } + } +} + +func TestGuildAppearanceRequiresPermissionAndImage(t *testing.T) { + srv, owner, guildID := appearanceGuild(t) + member := registerAndLogin(t, srv, "look_member", "look-member@example.com") + // Участник без прав: сервер не скрывает от него существование (404), потому + // что он уже состоит в сервере. + invite := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/invites", + `{"max_uses":1,"max_age_seconds":600}`, owner) + code := decodeResponse[struct { + Invite struct { + Code string `json:"code"` + } `json:"invite"` + }](t, invite).Invite.Code + if rec := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+code, "", member); rec.Code != http.StatusOK { + t.Fatalf("вступление участника = %d (%s)", rec.Code, rec.Body.String()) + } + + // Без MANAGE_GUILD загрузка запрещена. + denied := uploadAppearance(t, srv, guildID, "icon", "icon.png", pngBytes(), member) + if denied.Code != http.StatusForbidden { + t.Fatalf("без прав: статус %d, ожидался 403", denied.Code) + } + + // Не изображение отклоняется. + text := uploadAppearance(t, srv, guildID, "icon", "note.txt", []byte("просто текст"), owner) + if text.Code != http.StatusUnprocessableEntity { + t.Fatalf("текстовый файл: статус %d, ожидался 422", text.Code) + } + + // Неизвестный вид оформления — 404. + unknown := uploadAppearance(t, srv, guildID, "cover", "cover.png", pngBytes(), owner) + if unknown.Code != http.StatusNotFound { + t.Fatalf("неизвестный вид: статус %d, ожидался 404", unknown.Code) + } + + // Без сессии — 401. + anonymous := uploadAppearance(t, srv, guildID, "icon", "icon.png", pngBytes(), nil) + if anonymous.Code != http.StatusUnauthorized { + t.Fatalf("без сессии: статус %d, ожидался 401", anonymous.Code) + } +} + +func TestGuildAppearanceAccentColor(t *testing.T) { + srv, owner, guildID := appearanceGuild(t) + + updated := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID, + `{"accent_color":13369344}`, owner) + if updated.Code != http.StatusOK { + t.Fatalf("акцент: статус %d (%s)", updated.Code, updated.Body.String()) + } + payload := decodeResponse[struct { + Guild struct { + AccentColor int64 `json:"accent_color"` + } `json:"guild"` + }](t, updated) + if payload.Guild.AccentColor != 13369344 { + t.Fatalf("акцент = %d", payload.Guild.AccentColor) + } + + // Сброс: цвет возвращается к значению темы. + cleared := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID, `{"accent_color":0}`, owner) + if cleared.Code != http.StatusOK { + t.Fatalf("сброс акцента: статус %d", cleared.Code) + } + if value := decodeResponse[struct { + Guild struct { + AccentColor int64 `json:"accent_color"` + } `json:"guild"` + }](t, cleared).Guild.AccentColor; value != 0 { + t.Fatalf("после сброса акцент = %d", value) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 85b75f6..4a44822 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -144,6 +144,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se s.registerMessageRoutes(s.api) s.registerInviteRoutes(s.api) s.registerFileRoutes(s.api, apiRouter) + s.registerGuildAppearanceRoutes(apiRouter) s.registerSocialRoutes(s.api) s.registerEmojiRoutes(s.api, apiRouter) s.registerVoiceRoutes(s.api)