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) } }