305 lines
13 KiB
Go
305 lines
13 KiB
Go
|
|
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)
|
||
|
|
}
|
||
|
|
}
|