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).
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user