package server import ( "bytes" "mime/multipart" "net/http" "net/http/httptest" "net/url" "testing" "glchat/internal/store" ) // Тесты оформления профиля (AGENT.md 7.2, 7.4): галерея сервера, оформление // роли, личные стили и поэлементный приоритет с откатом при потере роли. // uploadCosmetic загружает рамку или иконку в галерею сервера. func uploadCosmetic(t *testing.T, srv *Server, guildID, kind, name string, cookie *http.Cookie) (id string, status int) { t.Helper() var body bytes.Buffer writer := multipart.NewWriter(&body) if err := writer.WriteField("kind", kind); err != nil { t.Fatalf("multipart kind: %v", err) } if err := writer.WriteField("name", name); err != nil { t.Fatalf("multipart name: %v", err) } part, err := writer.CreateFormFile("file", name+".png") if err != nil { t.Fatalf("multipart file: %v", err) } if _, err := part.Write(pngBytes()); err != nil { t.Fatalf("multipart write: %v", err) } if err := writer.Close(); err != nil { t.Fatalf("multipart close: %v", err) } request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/guilds/"+guildID+"/cosmetics", &body) request.Header.Set("Content-Type", writer.FormDataContentType()) request.AddCookie(cookie) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, request) if rec.Code != http.StatusOK { return "", rec.Code } payload := decodeResponse[struct { Cosmetic struct { ID string `json:"id"` } `json:"cosmetic"` }](t, rec) return payload.Cosmetic.ID, rec.Code } type effectiveCosmetics struct { FrameID string `json:"frame_id"` BadgeID string `json:"badge_id"` NickColor *int64 `json:"nick_color"` NickEffect string `json:"nick_effect"` } // memberCosmetics читает вычисленное оформление участника из списка участников. func memberCosmetics(t *testing.T, srv *Server, guildID, userID string, cookie *http.Cookie) effectiveCosmetics { t.Helper() rec := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/members", "", cookie) if rec.Code != http.StatusOK { t.Fatalf("members = %d (%s)", rec.Code, rec.Body.String()) } list := decodeResponse[struct { Members []struct { UserID string `json:"user_id"` Cosmetics *effectiveCosmetics `json:"cosmetics"` } `json:"members"` }](t, rec) for _, member := range list.Members { if member.UserID == userID { if member.Cosmetics == nil { return effectiveCosmetics{} } return *member.Cosmetics } } t.Fatalf("участник %s не найден", userID) return effectiveCosmetics{} } // cosmeticsFixture — сервер с владельцем и участником. func cosmeticsFixture(t *testing.T) (srv *Server, owner, member *http.Cookie, guildID, memberID string) { t.Helper() srv, owner, member, guildID, memberID = banFixture(t) return srv, owner, member, guildID, memberID } // styleScopePath собирает путь личного стиля. func styleScopePath(scope string) string { return "/api/v1/users/@me/styles/" + url.PathEscape(scope) } func TestCosmeticGalleryLifecycle(t *testing.T) { srv, owner, member, guildID, _ := cosmeticsFixture(t) frameID, status := uploadCosmetic(t, srv, guildID, "frame", "Золотая", owner) if status != http.StatusOK || frameID == "" { t.Fatalf("загрузка рамки: статус %d", status) } badgeID, status := uploadCosmetic(t, srv, guildID, "badge", "Щит", owner) if status != http.StatusOK || badgeID == "" { t.Fatalf("загрузка иконки: статус %d", status) } // Галерею видят участники сервера, а не посторонние. list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/cosmetics", "", member) if list.Code != http.StatusOK { t.Fatalf("список галереи = %d (%s)", list.Code, list.Body.String()) } payload := decodeResponse[struct { Cosmetics []struct { ID string `json:"id"` Kind string `json:"kind"` Name string `json:"name"` } `json:"cosmetics"` }](t, list) if len(payload.Cosmetics) != 2 { t.Fatalf("в галерее %d элементов, ожидалось 2", len(payload.Cosmetics)) } // Участник без MANAGE_ROLES не может пополнять галерею. if _, status := uploadCosmetic(t, srv, guildID, "frame", "Чужая", member); status != http.StatusForbidden { t.Fatalf("загрузка участником: статус %d, ожидался 403", status) } // Переименование и удаление. renamed := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID+"/cosmetics/"+badgeID, `{"name":"Новый щит"}`, owner) if renamed.Code != http.StatusOK { t.Fatalf("переименование = %d (%s)", renamed.Code, renamed.Body.String()) } deleted := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guildID+"/cosmetics/"+badgeID, "", owner) if deleted.Code != http.StatusOK { t.Fatalf("удаление = %d (%s)", deleted.Code, deleted.Body.String()) } after := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/cosmetics", "", owner) remaining := decodeResponse[struct { Cosmetics []struct { ID string `json:"id"` } `json:"cosmetics"` }](t, after) if len(remaining.Cosmetics) != 1 || remaining.Cosmetics[0].ID != frameID { t.Fatalf("после удаления осталось %d элементов", len(remaining.Cosmetics)) } } func TestRoleCosmeticsAndPriority(t *testing.T) { srv, owner, member, guildID, memberID := cosmeticsFixture(t) frameID, _ := uploadCosmetic(t, srv, guildID, "frame", "Золотая", owner) badgeID, _ := uploadCosmetic(t, srv, guildID, "badge", "Щит", owner) // Роль с рамкой и цветом ника, вторая роль — с иконкой: элементы // складываются поэлементно (AGENT.md 7.2, 7.4). created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles", `{"name":"Хранитель","cosmetic_frame_id":"`+frameID+`","nick_color":15158332}`, owner) if created.Code != http.StatusOK { t.Fatalf("создание роли = %d (%s)", created.Code, created.Body.String()) } role := decodeResponse[struct { Role struct { ID string `json:"id"` CosmeticFrameID string `json:"cosmetic_frame_id"` NickColor *int64 `json:"nick_color"` } `json:"role"` }](t, created) if role.Role.CosmeticFrameID != frameID || role.Role.NickColor == nil { t.Fatalf("оформление роли не сохранилось: %s", created.Body.String()) } second := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles", `{"name":"Щитоносец","cosmetic_badge_id":"`+badgeID+`"}`, owner) roleWithBadge := decodeResponse[struct { Role struct { ID string `json:"id"` } `json:"role"` }](t, second).Role.ID for _, roleID := range []string{role.Role.ID, roleWithBadge} { granted := doJSON(t, srv, http.MethodPut, "/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+roleID, "", owner) if granted.Code != http.StatusOK { t.Fatalf("выдача роли %s = %d (%s)", roleID, granted.Code, granted.Body.String()) } } // Оформление ролей применяется: рамка, иконка и цвет ника. effective := memberCosmetics(t, srv, guildID, memberID, member) if effective.FrameID != frameID || effective.BadgeID != badgeID { t.Fatalf("оформление ролей: %+v", effective) } if effective.NickColor == nil || *effective.NickColor != 15158332 { t.Fatalf("цвет ника из роли = %v", effective.NickColor) } // Личный стиль добавляет эффект ника, элементы ролей остаются. style := doJSON(t, srv, http.MethodPut, styleScopePath(guildID), `{"frame_id":"`+frameID+`","badge_id":"`+badgeID+`","nick_effect":"glow"}`, member) if style.Code != http.StatusOK { t.Fatalf("личный стиль = %d (%s)", style.Code, style.Body.String()) } effective = memberCosmetics(t, srv, guildID, memberID, member) if effective.FrameID != frameID || effective.BadgeID != badgeID || effective.NickEffect != "glow" { t.Fatalf("после личного стиля: %+v", effective) } // Потеря роли с рамкой: рамка исчезает, иконка и эффект остаются. revoked := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+role.Role.ID, "", owner) if revoked.Code != http.StatusOK { t.Fatalf("снятие роли = %d (%s)", revoked.Code, revoked.Body.String()) } effective = memberCosmetics(t, srv, guildID, memberID, member) if effective.FrameID != "" { t.Fatalf("рамка осталась после снятия роли: %+v", effective) } if effective.BadgeID != badgeID || effective.NickEffect != "glow" { t.Fatalf("иконка или эффект потерялись: %+v", effective) } // Потеря роли с иконкой откатывает и личный элемент: он больше не выдан. if rec := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+roleWithBadge, "", owner); rec.Code != http.StatusOK { t.Fatalf("снятие второй роли = %d", rec.Code) } effective = memberCosmetics(t, srv, guildID, memberID, member) if effective.BadgeID != "" { t.Fatalf("иконка осталась без роли: %+v", effective) } if effective.NickEffect != "glow" { t.Fatalf("эффект ника потерялся: %q", effective.NickEffect) } } func TestPersonalStyleAvailability(t *testing.T) { srv, owner, member, guildID, memberID := cosmeticsFixture(t) frameID, _ := uploadCosmetic(t, srv, guildID, "frame", "Золотая", owner) // Рамка не выдана ролями: выбрать её нельзя. if rec := doJSON(t, srv, http.MethodPut, styleScopePath(guildID), `{"frame_id":"`+frameID+`"}`, member); rec.Code != http.StatusForbidden { t.Fatalf("недоступная рамка = %d (%s)", rec.Code, rec.Body.String()) } // Цвет вне палитры отклоняется. if rec := doJSON(t, srv, http.MethodPut, styleScopePath("global"), `{"nick_color":1}`, member); rec.Code != http.StatusUnprocessableEntity { t.Fatalf("цвет вне палитры = %d (%s)", rec.Code, rec.Body.String()) } // Неизвестный эффект отклоняется. if rec := doJSON(t, srv, http.MethodPut, styleScopePath("global"), `{"nick_effect":"rainbow"}`, member); rec.Code != http.StatusUnprocessableEntity { t.Fatalf("неизвестный эффект = %d (%s)", rec.Code, rec.Body.String()) } // Выдаём роль с рамкой и иконкой. created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles", `{"name":"Хранитель","cosmetic_frame_id":"`+frameID+`"}`, owner) roleID := decodeResponse[struct { Role struct { ID string `json:"id"` } `json:"role"` }](t, created).Role.ID if rec := doJSON(t, srv, http.MethodPut, "/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+roleID, "", owner); rec.Code != http.StatusOK { t.Fatalf("выдача роли = %d", rec.Code) } // Теперь рамка доступна и в пер-серверном, и в глобальном стиле. for _, scope := range []string{guildID, "global"} { rec := doJSON(t, srv, http.MethodPut, styleScopePath(scope), `{"frame_id":"`+frameID+`","nick_effect":"gradient"}`, member) if rec.Code != http.StatusOK { t.Fatalf("стиль %s = %d (%s)", scope, rec.Code, rec.Body.String()) } } // Список стилей отдаёт доступные элементы и встроенный набор эффектов. styles := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/styles", "", member) list := decodeResponse[struct { Styles []struct { Scope string `json:"scope"` FrameIDs []string `json:"frame_ids"` Effects []string `json:"effects"` } `json:"styles"` }](t, styles) if len(list.Styles) != 2 { t.Fatalf("стилей %d, ожидалось 2", len(list.Styles)) } if len(list.Styles[0].FrameIDs) != 1 || list.Styles[0].FrameIDs[0] != frameID { t.Fatalf("доступные рамки: %+v", list.Styles[0].FrameIDs) } if len(list.Styles[0].Effects) != len(store.NickEffects()) { t.Fatalf("эффектов %d", len(list.Styles[0].Effects)) } // Сброс стиля: глобальный стиль больше не применяется. if rec := doJSON(t, srv, http.MethodDelete, styleScopePath("global"), "", member); rec.Code != http.StatusOK { t.Fatalf("сброс стиля = %d", rec.Code) } } func TestSelfRoleAssignmentForCosmetics(t *testing.T) { srv, owner, _, guildID, _ := cosmeticsFixture(t) frameID, status := uploadCosmetic(t, srv, guildID, "frame", "Своя", owner) if status != http.StatusOK { t.Fatalf("загрузка рамки: статус %d", status) } // Свою роль выдать можно: иначе владелец не примерит оформление. created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles", `{"name":"Своя роль","cosmetic_frame_id":"`+frameID+`"}`, owner) roleID := decodeResponse[struct { Role struct { ID string `json:"id"` } `json:"role"` }](t, created).Role.ID me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", owner) ownerID := decodeResponse[struct { User struct { ID string `json:"id"` } `json:"user"` }](t, me).User.ID granted := doJSON(t, srv, http.MethodPut, "/api/v1/guilds/"+guildID+"/members/"+ownerID+"/roles/"+roleID, "", owner) if granted.Code != http.StatusOK { t.Fatalf("выдача роли себе = %d (%s)", granted.Code, granted.Body.String()) } effective := memberCosmetics(t, srv, guildID, ownerID, owner) if effective.FrameID != frameID { t.Fatalf("рамка не применилась: %+v", effective) } // Свою роль можно и снять (владелец остаётся владельцем). revoked := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guildID+"/members/"+ownerID+"/roles/"+roleID, "", owner) if revoked.Code != http.StatusOK { t.Fatalf("снятие своей роли = %d (%s)", revoked.Code, revoked.Body.String()) } // Самомодерация по-прежнему запрещена: кик себя отклоняется. kick := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guildID+"/members/"+ownerID, "", owner) if kick.Code != http.StatusForbidden { t.Fatalf("кик себя = %d, ожидался 403", kick.Code) } }