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) } } func TestFriendsListCarriesCosmetics(t *testing.T) { srv, owner, member, guildID, memberID := cosmeticsFixture(t) // Стиль «для друзей» с эффектом и цветом из палитры. style := doJSON(t, srv, http.MethodPut, styleScopePath("global"), `{"nick_color":3066993,"nick_effect":"gradient"}`, member) if style.Code != http.StatusOK { t.Fatalf("стиль «для друзей» = %d (%s)", style.Code, style.Body.String()) } // Дружба в обе стороны. request := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", `{"user_id":"`+memberID+`"}`, owner) if request.Code != http.StatusOK && request.Code != http.StatusCreated { t.Fatalf("заявка в друзья = %d (%s)", request.Code, request.Body.String()) } 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 accept := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships/"+ownerID+"/accept", "", member) if accept.Code != http.StatusOK { t.Fatalf("принятие заявки = %d (%s)", accept.Code, accept.Body.String()) } friends := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", owner) list := decodeResponse[struct { Friends []struct { UserID string `json:"user_id"` Cosmetics *effectiveCosmetics `json:"cosmetics"` } `json:"friends"` }](t, friends) if len(list.Friends) != 1 { t.Fatalf("друзей %d, ожидался 1 (%s)", len(list.Friends), friends.Body.String()) } got := list.Friends[0].Cosmetics if got == nil || got.NickEffect != "gradient" || got.NickColor == nil || *got.NickColor != 3066993 { t.Fatalf("оформление друга = %+v", got) } _ = guildID } func TestChannelBackgroundUploadAndDelete(t *testing.T) { srv, owner, member, guildID, _ := cosmeticsFixture(t) channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", "", owner) channelID := decodeResponse[struct { Channels []struct { ID string `json:"id"` Type string `json:"type"` } `json:"channels"` }](t, channels).Channels[0].ID // Загрузка фона владельцем: multipart, картинка. var body bytes.Buffer writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile("file", "bg.png") if err != nil { t.Fatalf("multipart: %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/channels/"+channelID+"/background", &body) request.Header.Set("Content-Type", writer.FormDataContentType()) request.AddCookie(owner) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, request) if rec.Code != http.StatusOK { t.Fatalf("загрузка фона = %d (%s)", rec.Code, rec.Body.String()) } uploaded := decodeResponse[struct { Channel struct { BackgroundFileID string `json:"background_file_id"` } `json:"channel"` }](t, rec) if uploaded.Channel.BackgroundFileID == "" { t.Fatalf("фон не применился: %s", rec.Body.String()) } // Участник без MANAGE_CHANNEL_BACKGROUND фон не меняет. request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/channels/"+channelID+"/background", &body) request.Header.Set("Content-Type", writer.FormDataContentType()) request.AddCookie(member) denied := httptest.NewRecorder() srv.Handler().ServeHTTP(denied, request) if denied.Code != http.StatusForbidden { t.Fatalf("фон участником = %d, ожидался 403 (%s)", denied.Code, denied.Body.String()) } // Фон виден в списке комнат сервера. list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", "", member) payload := decodeResponse[struct { Channels []struct { ID string `json:"id"` BackgroundFileID string `json:"background_file_id"` } `json:"channels"` }](t, list) found := false for _, channel := range payload.Channels { if channel.ID == channelID && channel.BackgroundFileID == uploaded.Channel.BackgroundFileID { found = true } } if !found { t.Fatalf("фон не виден участнику: %s", list.Body.String()) } // Снятие фона. removed := doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+channelID+"/background", "", owner) if removed.Code != http.StatusOK { t.Fatalf("снятие фона = %d (%s)", removed.Code, removed.Body.String()) } after := decodeResponse[struct { Channel struct { BackgroundFileID string `json:"background_file_id"` } `json:"channel"` }](t, removed) if after.Channel.BackgroundFileID != "" { t.Fatalf("фон не снят: %s", removed.Body.String()) } } func TestMembersCarrySystemBadges(t *testing.T) { srv, owner, _, guildID, _ := cosmeticsFixture(t) 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 // Бейдж назначает система (AGENT.md 7.2): в тесте ставим его напрямую. id, err := parseID("user_id", ownerID) if err != nil { t.Fatalf("parse user id: %v", err) } if err := srv.store.SetUserBadges(t.Context(), id, []string{"instance_admin"}); err != nil { t.Fatalf("set badges: %v", err) } list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/members", "", owner) members := decodeResponse[struct { Members []struct { UserID string `json:"user_id"` Badges []string `json:"badges"` } `json:"members"` }](t, list) var found bool for _, entry := range members.Members { if entry.Badges == nil { t.Fatalf("бейджи должны быть списком, а не null") } if entry.UserID != ownerID { continue } found = true if !contains(entry.Badges, "instance_admin") { t.Fatalf("бейджи владельца = %v, ожидался instance_admin", entry.Badges) } } if !found { t.Fatalf("участник %s не найден: %s", ownerID, list.Body.String()) } }