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)
This commit is contained in:
2026-09-21 18:08:21 +03:00
parent 91f415e36d
commit 0f0060e8a4
19 changed files with 733 additions and 16 deletions
+85
View File
@@ -393,3 +393,88 @@ func TestFriendsListCarriesCosmetics(t *testing.T) {
}
_ = 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())
}
}