Files
grendervill 8696d85514 feat(dm): иконка групповой беседы в хранилище и API (Фаза 7)
D-070: у групповой беседы не было своей иконки — показывались имя и первая
буква аватара, как у 1:1 без аватара. Колонки под файл у канала не было.

Миграция 00024 добавляет `channels.icon_file_id` (REFERENCES files ON DELETE
SET NULL): NULL у 1:1 и комнат сервера, файл — у группы. Store читает и пишет
поле через `Channel`/`UpdateChannelParams` (`IconFileID`/`ClearIcon`), отдаёт
его в `ListDMChannels` и в READY, а уборка сирот больше не считает иконку
беседы мусором. Когда в группе остаётся два участника, беседа снова обычная
личная — иконка сбрасывается вместе с именем и владельцем.

Ручки `POST/DELETE /channels/{id}/icon` принимают multipart (назначение файла
`dm_icon`, аватарный лимит, проверка `image/`) и доступны только владельцу
беседы: участнику-не-владельцу 403 `perm.denied`, посторонним 404 (существование
чужой беседы не подтверждаем), у 1:1 своей иконки нет — 422 `dm.not_group`.
Файл иконки отдаётся только участникам беседы. После изменения участникам
уходит `DM_CHANNEL_UPDATE`, поэтому иконка меняется без перезагрузки
(AGENT.md 7.7, 7.8, 8.3, 11.6; D-042, D-070).
2026-09-26 16:48:01 +03:00

433 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"testing"
"glchat/internal/gateway"
)
// Тесты иконки групповой беседы (AGENT.md 7.8, D-070): загрузка владельцем,
// отдача в списке бесед и READY, права владельца и событие DM_CHANNEL_UPDATE.
// dmIconFixture — групповая беседа Алиса (владелец) + Боб + Кэрол и посторонний
// Дейв: на нём проверяются запреты.
type dmIconFixture struct {
srv *Server
owner, member, stranger *http.Cookie
ownerID, memberID string
strangerID string
channelID string
}
func newDMIconFixture(t *testing.T) dmIconFixture {
t.Helper()
srv, _ := newTestServer(t)
owner := registerAndLogin(t, srv, "icon_alice", "icon-alice@example.com")
member := registerAndLogin(t, srv, "icon_bob", "icon-bob@example.com")
registerAndLogin(t, srv, "icon_carol", "icon-carol@example.com")
stranger := registerAndLogin(t, srv, "icon_dave", "icon-dave@example.com")
userID := func(email string) string {
t.Helper()
user, err := srv.auth.UserByEmail(t.Context(), email)
if err != nil {
t.Fatalf("UserByEmail(%s): %v", email, err)
}
return formatSnowflake(user.ID)
}
bobID := userID("icon-bob@example.com")
carolID := userID("icon-carol@example.com")
created := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels/group",
`{"name":"С иконкой","recipient_ids":["`+bobID+`","`+carolID+`"]}`, owner)
if created.Code != http.StatusOK {
t.Fatalf("создание группы = %d, body = %s", created.Code, created.Body.String())
}
channel := decodeResponse[struct {
Channel struct {
ID string `json:"id"`
} `json:"channel"`
}](t, created)
return dmIconFixture{
srv: srv,
owner: owner,
member: member,
stranger: stranger,
ownerID: userID("icon-alice@example.com"),
memberID: bobID,
strangerID: userID("icon-dave@example.com"),
channelID: channel.Channel.ID,
}
}
// postMultipartFile отправляет multipart-запрос с полем file на указанный путь.
func postMultipartFile(t *testing.T, srv *Server, path, filename string, content []byte, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("multipart: %v", err)
}
if _, err := part.Write(content); 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, path, &body)
request.Header.Set("Content-Type", writer.FormDataContentType())
if cookie != nil {
request.AddCookie(cookie)
}
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, request)
return rec
}
// uploadDMIcon отправляет multipart-запрос загрузки иконки беседы.
func uploadDMIcon(t *testing.T, srv *Server, channelID, filename string, content []byte, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
return postMultipartFile(t, srv, "/api/v1/channels/"+channelID+"/icon", filename, content, cookie)
}
// channelIconField читает `icon_file_id` беседы из REST-списка: то же поле,
// которое видит клиент в сайдбаре и шапке (D-070).
func channelIconField(t *testing.T, srv *Server, cookie *http.Cookie, channelID string) string {
t.Helper()
rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/channels", "", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("GET /users/@me/channels = %d, body = %s", rec.Code, rec.Body.String())
}
var payload struct {
Channels []struct {
ID string `json:"id"`
IconFileID string `json:"icon_file_id"`
} `json:"channels"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode channels: %v (raw: %s)", err, rec.Body.String())
}
for _, channel := range payload.Channels {
if channel.ID == channelID {
return channel.IconFileID
}
}
t.Fatalf("беседа %s не найдена в списке", channelID)
return ""
}
// TestDMChannelIconLifecycle — загрузка иконки владельцем: ответ, список бесед
// у всех участников, READY, событие DM_CHANNEL_UPDATE и снятие иконки.
func TestDMChannelIconLifecycle(t *testing.T) {
fixture := newDMIconFixture(t)
srv := fixture.srv
// Событие об изменении иконки должно доехать до остальных участников без
// перезагрузки (AGENT.md 11.6, D-070).
httpServer := httptest.NewServer(srv.Handler())
t.Cleanup(httpServer.Close)
bob := dialGateway(t, httpServer, fixture.member)
rec := uploadDMIcon(t, srv, fixture.channelID, "icon.png", pngBytes(), fixture.owner)
if rec.Code != http.StatusOK {
t.Fatalf("загрузка иконки владельцем = %d, body = %s", rec.Code, rec.Body.String())
}
uploaded := decodeResponse[struct {
Channel struct {
ID string `json:"id"`
IsGroup bool `json:"is_group"`
IconFileID string `json:"icon_file_id"`
OwnerID string `json:"owner_id"`
} `json:"channel"`
}](t, rec)
if uploaded.Channel.IconFileID == "" {
t.Fatalf("в ответе нет icon_file_id: %s", rec.Body.String())
}
if uploaded.Channel.ID != fixture.channelID || !uploaded.Channel.IsGroup ||
uploaded.Channel.OwnerID != fixture.ownerID {
t.Fatalf("неожиданная карточка беседы: %s", rec.Body.String())
}
iconID := uploaded.Channel.IconFileID
fileID, err := parseID("file_id", iconID)
if err != nil {
t.Fatalf("parseID(icon): %v", err)
}
// Файл — обычная загрузка с назначением dm_icon (D-042).
file, err := srv.store.GetFile(t.Context(), fileID)
if err != nil {
t.Fatalf("файл иконки не найден: %v", err)
}
if file.Purpose != "dm_icon" {
t.Fatalf("purpose = %q, ожидался dm_icon", file.Purpose)
}
if _, err := os.Stat(file.StoragePath); err != nil {
t.Fatalf("содержимое иконки не сохранено: %v", err)
}
// Иконка видна каждому участнику, постороннему беседа не видна вовсе.
for _, cookie := range []*http.Cookie{fixture.owner, fixture.member} {
if icon := channelIconField(t, srv, cookie, fixture.channelID); icon != iconID {
t.Fatalf("иконка в списке = %q, ожидалась %q", icon, iconID)
}
}
for _, channel := range listDMChannels(t, srv, fixture.stranger).Channels {
if channel.ID == fixture.channelID {
t.Fatal("посторонний видит чужую групповую беседу")
}
}
// READY отдаёт иконку группы тем же полем (AGENT.md 8.3, 11.6): после
// перезагрузки страницы иконка не должна пропадать.
owner, err := srv.auth.UserByEmail(t.Context(), "icon-alice@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
builder := gateway.NewSnapshot(srv.store, srv.perms)
snapshot, err := builder.Build(t.Context(), owner)
if err != nil {
t.Fatalf("snapshot: %v", err)
}
payload, err := json.Marshal(snapshot)
if err != nil {
t.Fatalf("marshal snapshot: %v", err)
}
var ready struct {
DMChannels []struct {
ID string `json:"id"`
IsGroup bool `json:"is_group"`
IconFileID string `json:"icon_file_id"`
} `json:"dm_channels"`
}
if err := json.Unmarshal(payload, &ready); err != nil {
t.Fatalf("decode snapshot: %v", err)
}
found := false
for _, channel := range ready.DMChannels {
if channel.ID == fixture.channelID {
found = true
if !channel.IsGroup || channel.IconFileID != iconID {
t.Fatalf("иконка группы в READY = %+v", channel)
}
}
}
if !found {
t.Fatalf("группа не попала в READY: %s", payload)
}
// Участник получает событие обновления беседы.
event := bob.expectEvent("DM_CHANNEL_UPDATE")
var updated struct {
ChannelID string `json:"channel_id"`
}
if err := json.Unmarshal(event.D, &updated); err != nil {
t.Fatalf("decode DM_CHANNEL_UPDATE: %v", err)
}
if updated.ChannelID != fixture.channelID {
t.Fatalf("channel_id в событии = %q", updated.ChannelID)
}
// Снятие иконки владельцем: поле очищается, файл удаляется, событие уходит.
rec = doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+fixture.channelID+"/icon", "", fixture.owner)
if rec.Code != http.StatusOK {
t.Fatalf("снятие иконки = %d, body = %s", rec.Code, rec.Body.String())
}
cleared := decodeResponse[struct {
Channel struct {
IconFileID string `json:"icon_file_id"`
} `json:"channel"`
}](t, rec)
if cleared.Channel.IconFileID != "" {
t.Fatalf("после снятия icon_file_id = %q", cleared.Channel.IconFileID)
}
if _, err := srv.store.GetFile(t.Context(), fileID); err == nil {
t.Fatal("после снятия иконки файл остался в базе")
}
if _, err := os.Stat(file.StoragePath); !os.IsNotExist(err) {
t.Fatalf("после снятия иконки файл остался на диске: %v", err)
}
if icon := channelIconField(t, srv, fixture.member, fixture.channelID); icon != "" {
t.Fatalf("иконка осталась в списке у участника: %q", icon)
}
bob.expectEvent("DM_CHANNEL_UPDATE")
}
// TestDMChannelIconPermissions — иконку меняет только владелец групповой
// беседы; у 1:1 своей иконки нет (AGENT.md 7.8, D-070).
func TestDMChannelIconPermissions(t *testing.T) {
fixture := newDMIconFixture(t)
srv := fixture.srv
// Участник-не-владелец получает 403.
rec := uploadDMIcon(t, srv, fixture.channelID, "icon.png", pngBytes(), fixture.member)
if rec.Code != http.StatusForbidden {
t.Fatalf("загрузка участником = %d, ожидалось 403, body = %s", rec.Code, rec.Body.String())
}
if code := errorCodeOf(t, rec); code != "perm.denied" {
t.Fatalf("код ошибки = %q", code)
}
rec = doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+fixture.channelID+"/icon", "", fixture.member)
if rec.Code != http.StatusForbidden {
t.Fatalf("снятие участником = %d, ожидалось 403", rec.Code)
}
// Посторонний не должен даже узнать о существовании беседы: 404.
rec = uploadDMIcon(t, srv, fixture.channelID, "icon.png", pngBytes(), fixture.stranger)
if rec.Code != http.StatusNotFound {
t.Fatalf("загрузка посторонним = %d, ожидалось 404, body = %s", rec.Code, rec.Body.String())
}
if code := errorCodeOf(t, rec); code != "not_found" {
t.Fatalf("код ошибки = %q", code)
}
rec = doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+fixture.channelID+"/icon", "", fixture.stranger)
if rec.Code != http.StatusNotFound {
t.Fatalf("снятие посторонним = %d, ожидалось 404", rec.Code)
}
// Беседа 1:1: иконка — аватар собеседника, своей у неё нет.
opened := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels",
`{"recipient_id":"`+fixture.strangerID+`"}`, fixture.owner)
if opened.Code != http.StatusOK {
t.Fatalf("открытие 1:1 = %d, body = %s", opened.Code, opened.Body.String())
}
direct := decodeResponse[struct {
Channel struct {
ID string `json:"id"`
} `json:"channel"`
}](t, opened)
rec = uploadDMIcon(t, srv, direct.Channel.ID, "icon.png", pngBytes(), fixture.owner)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("загрузка иконки в 1:1 = %d, ожидалось 422, body = %s", rec.Code, rec.Body.String())
}
if code := errorCodeOf(t, rec); code != "dm.not_group" {
t.Fatalf("код ошибки = %q", code)
}
rec = doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+direct.Channel.ID+"/icon", "", fixture.owner)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("снятие иконки в 1:1 = %d, ожидалось 422", rec.Code)
}
// Посторонний в 1:1 тоже получает 404, а не 422: существование не подтверждаем.
rec = doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+direct.Channel.ID+"/icon", "", fixture.member)
if rec.Code != http.StatusNotFound {
t.Fatalf("снятие иконки посторонним в 1:1 = %d, ожидалось 404", rec.Code)
}
// Комната сервера — не беседа: иконка беседы к ней не применяется.
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Иконки"}`, fixture.owner)
guild := decodeResponse[struct {
Guild struct {
ID string `json:"id"`
} `json:"guild"`
}](t, created)
channel := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels",
`{"name":"общая","type":"text"}`, fixture.owner)
guildChannel := decodeResponse[struct {
Channel struct {
ID string `json:"id"`
} `json:"channel"`
}](t, channel)
rec = uploadDMIcon(t, srv, guildChannel.Channel.ID, "icon.png", pngBytes(), fixture.owner)
if rec.Code != http.StatusNotFound {
t.Fatalf("загрузка иконки в комнату сервера = %d, ожидалось 404", rec.Code)
}
}
// TestDMChannelIconValidation — иконка принимается только картинкой в пределах
// аватарного лимита (AGENT.md 7.7, 9.2).
func TestDMChannelIconValidation(t *testing.T) {
fixture := newDMIconFixture(t)
srv := fixture.srv
// Не картинка: переименованный текстовый файл отклоняется.
rec := uploadDMIcon(t, srv, fixture.channelID, "icon.txt", []byte("это не картинка"), fixture.owner)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("не-картинка = %d, ожидалось 422, body = %s", rec.Code, rec.Body.String())
}
if code := errorCodeOf(t, rec); code != "validation.failed" {
t.Fatalf("код ошибки = %q", code)
}
// Поле file обязательно.
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.WriteField("other", "значение"); err != nil {
t.Fatalf("multipart: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("multipart close: %v", err)
}
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost,
"/api/v1/channels/"+fixture.channelID+"/icon", &body)
request.Header.Set("Content-Type", writer.FormDataContentType())
request.AddCookie(fixture.owner)
missing := httptest.NewRecorder()
srv.Handler().ServeHTTP(missing, request)
if missing.Code != http.StatusBadRequest {
t.Fatalf("без поля file = %d, ожидалось 400, body = %s", missing.Code, missing.Body.String())
}
// Аватарный лимит: файл сверх него — 413 (AGENT.md 7.7).
if err := srv.store.SetInstanceSetting(t.Context(), "max_avatar_size", "1024"); err != nil {
t.Fatalf("SetInstanceSetting: %v", err)
}
large := append(pngBytes(), make([]byte, 2048)...)
rec = uploadDMIcon(t, srv, fixture.channelID, "big.png", large, fixture.owner)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("файл сверх лимита = %d, ожидалось 413, body = %s", rec.Code, rec.Body.String())
}
if code := errorCodeOf(t, rec); code != "file.too_large" {
t.Fatalf("код ошибки = %q", code)
}
}
// TestDMChannelIconPlacedInReady — в READY у 1:1 в icon_file_id приходит аватар
// собеседника, а у группы — своя иконка (AGENT.md 7.8, 8.3, D-070).
func TestDMChannelIconPlacedInReady(t *testing.T) {
fixture := newDMIconFixture(t)
srv := fixture.srv
// Боб загружает аватар, Алиса открывает с ним 1:1.
avatar := postMultipartFile(t, srv, "/api/v1/users/@me/avatar", "bob.png", pngBytes(), fixture.member)
if avatar.Code != http.StatusOK {
t.Fatalf("загрузка аватара = %d, body = %s", avatar.Code, avatar.Body.String())
}
profile := decodeResponse[struct {
User struct {
AvatarFileID string `json:"avatar_file_id"`
} `json:"user"`
}](t, avatar)
if profile.User.AvatarFileID == "" {
t.Fatalf("аватар не сохранился: %s", avatar.Body.String())
}
opened := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels",
`{"recipient_id":"`+fixture.memberID+`"}`, fixture.owner)
direct := decodeResponse[struct {
Channel struct {
ID string `json:"id"`
IconFileID string `json:"icon_file_id"`
Recipient struct {
AvatarFileID string `json:"avatar_file_id"`
} `json:"recipient"`
} `json:"channel"`
}](t, opened)
if direct.Channel.IconFileID != profile.User.AvatarFileID ||
direct.Channel.Recipient.AvatarFileID != profile.User.AvatarFileID {
t.Fatalf("иконка 1:1 = %+v, аватар = %q", direct.Channel, profile.User.AvatarFileID)
}
// Группа без иконки: поле пустое, но у 1:1 оно заполнено — клиент рисует
// иконку группы из своего поля (D-070).
if icon := channelIconField(t, srv, fixture.owner, fixture.channelID); icon != "" {
t.Fatalf("у новой группы появилась иконка: %q", icon)
}
}