Files
grendervill 02a5b99418 feat(guilds): оформление сервера — иконка, баннер, splash и акцентный цвет
Фаза 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 и без сессии, отказ для не-изображения, акцентный цвет.
2026-09-20 23:26:46 +03:00

188 lines
7.0 KiB
Go
Raw Permalink Blame History

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