Files
glchat/internal/server/cosmetics_test.go
T
grendervill 0f0060e8a4 feat(channels): фон комнаты и статичные превью при reduced-motion
- миграция 00014: background_file_id у комнаты (AGENT.md 7.5, 7.7)
- API: POST/DELETE /channels/{id}/background под MANAGE_CHANNEL_BACKGROUND,
  фон отдаётся в списке комнат и рассылается событием CHANNEL_UPDATE
- клиент: фон рисуется за лентой с затемнением, блок «Фон комнаты» в настройках
  комнаты (загрузка, замена, удаление)
- AnimatedImage: при «уменьшить движение» показывает статичный первый кадр,
  нарисованный на canvas, вместо анимации
- тесты: Go (загрузка, права, снятие) и Vitest (лента, настройки, reduced-motion)
2026-09-21 18:08:21 +03:00

481 lines
20 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)
}
}
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())
}
}