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,126 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
// channelColumns читает имена колонок таблицы channels.
|
||||
func channelColumns(ctx context.Context, t *testing.T, db *DB) map[string]bool {
|
||||
t.Helper()
|
||||
columns := map[string]bool{}
|
||||
rows, err := db.Reader.QueryContext(ctx, `SELECT name FROM pragma_table_info('channels')`)
|
||||
if err != nil {
|
||||
t.Fatalf("pragma_table_info('channels'): %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
columns[name] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("rows: %v", err)
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
// TestDMIconMigration проверяет миграцию 00024 на чистой базе (D-070, D-069):
|
||||
// колонку icon_file_id у channels, внешний ключ на files с ON DELETE SET NULL,
|
||||
// откат и повторный накат.
|
||||
func TestDMIconMigration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := Open(ctx, Options{
|
||||
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
||||
ReadPool: 2,
|
||||
Migrate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open() вернул ошибку: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Errorf("Close(): %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
version, err := db.SchemaVersion(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("SchemaVersion(): %v", err)
|
||||
}
|
||||
if version < 24 {
|
||||
t.Fatalf("schema_version = %d, ожидалось >= 24", version)
|
||||
}
|
||||
if !channelColumns(ctx, t, db)["icon_file_id"] {
|
||||
t.Fatal("в channels нет колонки icon_file_id")
|
||||
}
|
||||
|
||||
// Внешний ключ: беседа ссылается на файл, удаление файла обнуляет ссылку.
|
||||
if _, err := db.Writer.ExecContext(ctx,
|
||||
`INSERT INTO users (id, username, username_lower, display_name, email_enc, email_index,
|
||||
password_hash, locale, created_at, updated_at)
|
||||
VALUES (42, 'dm_owner', 'dm_owner', 'Владелец', 'enc', 'idx', 'hash', 'ru',
|
||||
'2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')`); err != nil {
|
||||
t.Fatalf("вставка пользователя: %v", err)
|
||||
}
|
||||
if _, err := db.Writer.ExecContext(ctx,
|
||||
`INSERT INTO files (id, filename, content_type, size_bytes, storage_path)
|
||||
VALUES (700, 'icon.png', 'image/png', 10, '/tmp/icon.png')`); err != nil {
|
||||
t.Fatalf("вставка файла: %v", err)
|
||||
}
|
||||
if _, err := db.Writer.ExecContext(ctx,
|
||||
`INSERT INTO channels (id, guild_id, type, name, dm_owner_id, icon_file_id)
|
||||
VALUES (701, NULL, 'dm', 'Группа', 42, 700)`); err != nil {
|
||||
t.Fatalf("вставка канала: %v", err)
|
||||
}
|
||||
var iconID *int64
|
||||
if err := db.Reader.QueryRowContext(ctx,
|
||||
`SELECT icon_file_id FROM channels WHERE id = 701`).Scan(&iconID); err != nil {
|
||||
t.Fatalf("чтение иконки: %v", err)
|
||||
}
|
||||
if iconID == nil || *iconID != 700 {
|
||||
t.Fatalf("icon_file_id = %v, ожидалось 700", iconID)
|
||||
}
|
||||
if _, err := db.Writer.ExecContext(ctx, `DELETE FROM files WHERE id = 700`); err != nil {
|
||||
t.Fatalf("удаление файла: %v", err)
|
||||
}
|
||||
iconID = nil
|
||||
if err := db.Reader.QueryRowContext(ctx,
|
||||
`SELECT icon_file_id FROM channels WHERE id = 701`).Scan(&iconID); err != nil {
|
||||
t.Fatalf("чтение иконки после удаления файла: %v", err)
|
||||
}
|
||||
if iconID != nil {
|
||||
t.Fatalf("после удаления файла icon_file_id = %v, ожидалось NULL", *iconID)
|
||||
}
|
||||
|
||||
// Откат: goose знает версию 24, Down убирает колонку (D-069).
|
||||
goose.SetBaseFS(migrationsFS)
|
||||
goose.SetLogger(goose.NopLogger())
|
||||
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||
t.Fatalf("SetDialect: %v", err)
|
||||
}
|
||||
if err := goose.DownToContext(ctx, db.Writer, "migrations", 23); err != nil {
|
||||
t.Fatalf("откат миграции 00024: %v", err)
|
||||
}
|
||||
if channelColumns(ctx, t, db)["icon_file_id"] {
|
||||
t.Fatal("после отката колонка icon_file_id осталась")
|
||||
}
|
||||
if version, err = db.SchemaVersion(ctx); err != nil {
|
||||
t.Fatalf("SchemaVersion() после отката: %v", err)
|
||||
} else if version >= 24 {
|
||||
t.Fatalf("schema_version после отката = %d, ожидалось < 24", version)
|
||||
}
|
||||
|
||||
// Повторный накат: миграция применима к уже откатанной базе.
|
||||
if err := goose.UpContext(ctx, db.Writer, "migrations"); err != nil {
|
||||
t.Fatalf("повторный накат: %v", err)
|
||||
}
|
||||
if !channelColumns(ctx, t, db)["icon_file_id"] {
|
||||
t.Fatal("после повторного наката колонки icon_file_id нет")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
-- Фаза 7: иконка групповой беседы (AGENT.md 7.8, D-070).
|
||||
--
|
||||
-- Иконка — обычная загрузка (D-042): содержимое лежит в files, а канал
|
||||
-- ссылается на запись. Колонка одна на все каналы, но заполняется только у
|
||||
-- групповых бесед (channels.type = 'dm' с владельцем): у 1:1 иконка — аватар
|
||||
-- собеседника, у комнат сервера — своё оформление.
|
||||
-- ON DELETE SET NULL: удаление файла не должно ломать беседу.
|
||||
ALTER TABLE channels ADD COLUMN icon_file_id INTEGER REFERENCES files (id) ON DELETE SET NULL;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE channels DROP COLUMN icon_file_id;
|
||||
@@ -93,7 +93,8 @@ func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error)
|
||||
Status: visibleStatus(summary.Status, summary.LastSeenAt),
|
||||
}
|
||||
if summary.IsGroup {
|
||||
// Групповая беседа: имя и состав вместо собеседника.
|
||||
// Групповая беседа: имя и состав вместо собеседника, иконка — своя
|
||||
// загрузка (D-070), а не аватар участника.
|
||||
channel.IsGroup = true
|
||||
channel.Name = summary.GroupName
|
||||
channel.MemberCount = summary.MemberCount
|
||||
@@ -104,8 +105,11 @@ func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error)
|
||||
if summary.OwnerID != nil {
|
||||
channel.OwnerID = formatID(*summary.OwnerID)
|
||||
}
|
||||
if summary.Channel.IconFileID != nil {
|
||||
channel.IconFileID = formatID(*summary.Channel.IconFileID)
|
||||
}
|
||||
if summary.AvatarFileID != nil {
|
||||
} else if summary.AvatarFileID != nil {
|
||||
// У 1:1 иконка беседы — аватар собеседника (AGENT.md 7.8).
|
||||
channel.IconFileID = formatID(*summary.AvatarFileID)
|
||||
}
|
||||
if summary.LastMessageID != 0 {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// Иконка групповой беседы (AGENT.md 7.8, D-070): обычная загрузка с
|
||||
// назначением dm_icon, поэтому анимированные GIF/APNG/WebP не перекодируются и
|
||||
// не теряют анимацию (D-042). Менять иконку может только владелец беседы; у
|
||||
// 1:1 своей иконки нет — в интерфейсе показывается аватар собеседника.
|
||||
|
||||
// registerDMIconRoutes описывает загрузку и снятие иконки беседы: huma не
|
||||
// принимает multipart, поэтому ручки живут на роутере.
|
||||
func (s *Server) registerDMIconRoutes(router chi.Router) {
|
||||
router.Post("/channels/{channel_id}/icon", s.handleDMChannelIconUpload)
|
||||
router.Delete("/channels/{channel_id}/icon", s.handleDMChannelIconDelete)
|
||||
}
|
||||
|
||||
// groupDMForIcon проверяет, что канал — групповая беседа, а пользователь —
|
||||
// её владелец. Посторонним отвечаем 404 (существование чужой беседы не
|
||||
// подтверждаем, D-070), участнику-не-владельцу — 403, у 1:1 своей иконки нет.
|
||||
func (s *Server) groupDMForIcon(ctx context.Context, rawChannelID string, userID uint64) (*store.Channel, error) {
|
||||
channelID, err := parseID("channel_id", rawChannelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channel, err := s.store.GetChannel(ctx, channelID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if channel.Type != store.ChannelDM || channel.GuildID != nil {
|
||||
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "conversation not found")
|
||||
}
|
||||
participant, err := s.store.IsDMParticipant(ctx, channelID, userID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if !participant {
|
||||
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "conversation not found")
|
||||
}
|
||||
if channel.DMOwnerID == nil {
|
||||
// 1:1: иконка беседы — аватар собеседника, своей у неё нет.
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "dm.not_group",
|
||||
"a direct conversation shows the recipient's avatar and has no icon")
|
||||
}
|
||||
if *channel.DMOwnerID != userID {
|
||||
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied",
|
||||
"only the group owner can change the conversation icon")
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
// handleDMChannelIconUpload принимает иконку групповой беседы (владелец).
|
||||
func (s *Server) handleDMChannelIconUpload(w http.ResponseWriter, r *http.Request) {
|
||||
user, _, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
channel, err := s.groupDMForIcon(ctx, chi.URLParam(r, "channel_id"), user.ID)
|
||||
if err != nil {
|
||||
writeHumaAPIError(w, err)
|
||||
return
|
||||
}
|
||||
// Загрузки: 10 в минуту и 100 в сутки на пользователя (AGENT.md 8.6).
|
||||
if err := s.checkUploadLimit(user); err != nil {
|
||||
writeHumaAPIError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Иконка — медиа аватарного размера (AGENT.md 7.7): лимит тот же, что у
|
||||
// аватаров и оформления. Тело ограничено и по объёму, и по памяти.
|
||||
limit := s.mediaLimit(ctx, mediaAvatar)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit+maxMultipartOverhead)
|
||||
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader выше
|
||||
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if r.MultipartForm != nil {
|
||||
_ = r.MultipartForm.RemoveAll()
|
||||
}
|
||||
}()
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
if header.Size > limit {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "conversation icon is too large")
|
||||
return
|
||||
}
|
||||
// Картинку читаем в память: нужно убедиться, что это изображение, а не
|
||||
// переименованный файл (AGENT.md 9.2).
|
||||
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
||||
if err != nil || int64(len(data)) > limit {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "conversation icon is too large")
|
||||
return
|
||||
}
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(contentType, "image/") {
|
||||
contentType = http.DetectContentType(data)
|
||||
}
|
||||
if !strings.HasPrefix(contentType, "image/") {
|
||||
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "conversation icon must be an image")
|
||||
return
|
||||
}
|
||||
|
||||
previous := channel.IconFileID
|
||||
channelID := channel.ID
|
||||
stored, err := s.saveUpload(ctx, store.File{
|
||||
UploaderID: &user.ID,
|
||||
ChannelID: &channelID,
|
||||
Filename: sanitizeFilename(header.Filename),
|
||||
ContentType: contentType,
|
||||
Purpose: "dm_icon",
|
||||
}, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
writeHumaAPIError(w, err)
|
||||
return
|
||||
}
|
||||
fileID := stored.ID
|
||||
updated, err := s.store.UpdateChannel(ctx, channel.ID, store.UpdateChannelParams{IconFileID: &fileID})
|
||||
if err != nil {
|
||||
// Новая иконка не прижилась: убираем загруженный файл, старая остаётся.
|
||||
s.deleteStoredFile(ctx, stored.ID)
|
||||
writeHumaAPIError(w, humaError(err))
|
||||
return
|
||||
}
|
||||
// Предыдущий файл удаляем только после успешной замены: загрузки не должны
|
||||
// копиться (AGENT.md 7.7), но и терять старую иконку при сбое нельзя.
|
||||
if previous != nil && *previous != 0 {
|
||||
s.deleteStoredFile(ctx, *previous)
|
||||
}
|
||||
s.dispatchDMUpdate(updated, []uint64{}, 0)
|
||||
s.writeDMChannel(ctx, w, updated, user.ID)
|
||||
}
|
||||
|
||||
// handleDMChannelIconDelete снимает иконку групповой беседы (владелец).
|
||||
func (s *Server) handleDMChannelIconDelete(w http.ResponseWriter, r *http.Request) {
|
||||
user, _, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
channel, err := s.groupDMForIcon(ctx, chi.URLParam(r, "channel_id"), user.ID)
|
||||
if err != nil {
|
||||
writeHumaAPIError(w, err)
|
||||
return
|
||||
}
|
||||
previous := channel.IconFileID
|
||||
updated, err := s.store.UpdateChannel(ctx, channel.ID, store.UpdateChannelParams{ClearIcon: true})
|
||||
if err != nil {
|
||||
writeHumaAPIError(w, humaError(err))
|
||||
return
|
||||
}
|
||||
if previous != nil && *previous != 0 {
|
||||
s.deleteStoredFile(ctx, *previous)
|
||||
}
|
||||
s.dispatchDMUpdate(updated, []uint64{}, 0)
|
||||
s.writeDMChannel(ctx, w, updated, user.ID)
|
||||
}
|
||||
|
||||
// writeDMChannel отдаёт обновлённую беседу в формате `dmChannelPayload` — так
|
||||
// же, как переименование: клиенту не нужен отдельный запрос за иконкой.
|
||||
func (s *Server) writeDMChannel(ctx context.Context, w http.ResponseWriter, channel *store.Channel, viewerID uint64) {
|
||||
summary, err := s.groupDMSummary(ctx, channel, viewerID)
|
||||
if err != nil {
|
||||
writeHumaAPIError(w, err)
|
||||
return
|
||||
}
|
||||
httpxWriteJSON(w, http.StatusOK, map[string]any{"channel": s.dmChannelPayload(summary)})
|
||||
}
|
||||
@@ -378,6 +378,18 @@ func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user *
|
||||
file.Purpose == "invite_background" {
|
||||
return file, nil
|
||||
}
|
||||
// Иконка групповой беседы видна только её участникам: личные беседы
|
||||
// приватны, посторонним файл не отдаём (AGENT.md 7.8, 7.19, 9.2).
|
||||
if file.Purpose == "dm_icon" && file.ChannelID != nil {
|
||||
participant, err := s.store.IsDMParticipant(ctx, *file.ChannelID, user.ID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if !participant {
|
||||
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "file not found")
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
// Фон комнаты виден тем, кто видит саму комнату (проверка ниже по каналу).
|
||||
if file.ChannelID != nil {
|
||||
if _, _, _, err := s.requireChannelPermission(ctx, formatSnowflake(*file.ChannelID), user, permissions.ViewChannel); err != nil {
|
||||
|
||||
@@ -68,6 +68,9 @@ type dmChannelPayload struct {
|
||||
} `json:"recipient"`
|
||||
LastMessageID string `json:"last_message_id,omitempty"`
|
||||
LastMessageAt string `json:"last_message_at,omitempty"`
|
||||
// IconFileID — иконка беседы: у группы своя загрузка (D-070), у 1:1 —
|
||||
// аватар собеседника. Совпадает с полем READY-снапшота (AGENT.md 8.3).
|
||||
IconFileID string `json:"icon_file_id,omitempty"`
|
||||
// Групповая беседа (AGENT.md 7.8): имя, участники и владелец. У 1:1
|
||||
// is_group = false, а данные собеседника лежат в recipient.
|
||||
IsGroup bool `json:"is_group"`
|
||||
@@ -934,6 +937,9 @@ func (s *Server) dmChannelPayload(summary store.DMChannelSummary) dmChannelPaylo
|
||||
payload.IsGroup = true
|
||||
payload.Name = summary.GroupName
|
||||
payload.MemberCount = summary.MemberCount
|
||||
if summary.Channel.IconFileID != nil {
|
||||
payload.IconFileID = formatSnowflake(*summary.Channel.IconFileID)
|
||||
}
|
||||
if summary.OwnerID != nil {
|
||||
payload.OwnerID = formatSnowflake(*summary.OwnerID)
|
||||
}
|
||||
@@ -977,6 +983,8 @@ func (s *Server) dmChannelPayload(summary store.DMChannelSummary) dmChannelPaylo
|
||||
}
|
||||
if summary.AvatarFileID != nil {
|
||||
payload.Recipient.AvatarFileID = formatSnowflake(*summary.AvatarFileID)
|
||||
// У 1:1 иконка беседы — аватар собеседника (как в READY).
|
||||
payload.IconFileID = formatSnowflake(*summary.AvatarFileID)
|
||||
}
|
||||
if summary.LastSeenAt != nil {
|
||||
payload.Recipient.LastSeenAt = summary.LastSeenAt.UTC().Format(timeLayout)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
||||
s.registerFileRoutes(s.api, apiRouter)
|
||||
s.registerGuildAppearanceRoutes(apiRouter)
|
||||
s.registerSocialRoutes(s.api)
|
||||
s.registerDMIconRoutes(apiRouter)
|
||||
s.registerEmojiRoutes(s.api, apiRouter)
|
||||
s.registerVoiceRoutes(s.api)
|
||||
s.registerSoundsRoutes(s.api, apiRouter)
|
||||
|
||||
@@ -34,6 +34,10 @@ type Channel struct {
|
||||
// DMOwnerID — владелец групповой личной беседы (AGENT.md 7.8): только он
|
||||
// добавляет и удаляет участников и переименовывает беседу. У 1:1 — NULL.
|
||||
DMOwnerID *uint64
|
||||
// IconFileID — иконка групповой беседы (AGENT.md 7.8, D-070): обычная
|
||||
// загрузка, менять её может только владелец. У 1:1 иконка — аватар
|
||||
// собеседника, поэтому колонка пустая.
|
||||
IconFileID *uint64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -59,7 +63,8 @@ type CreateChannelParams struct {
|
||||
}
|
||||
|
||||
const channelColumns = `id, guild_id, type, name, description, position, parent_id,
|
||||
slowmode_seconds, nsfw, background, background_file_id, voice_status, user_limit, dm_owner_id, created_at`
|
||||
slowmode_seconds, nsfw, background, background_file_id, voice_status, user_limit, dm_owner_id,
|
||||
created_at, icon_file_id`
|
||||
|
||||
func (s *Store) CreateChannel(ctx context.Context, params CreateChannelParams) (*Channel, error) {
|
||||
if params.ID == 0 {
|
||||
@@ -105,6 +110,9 @@ type UpdateChannelParams struct {
|
||||
ClearBackground bool
|
||||
VoiceStatus *string
|
||||
UserLimit *int
|
||||
// IconFileID задаёт иконку групповой беседы, ClearIcon снимает её (D-070).
|
||||
IconFileID *uint64
|
||||
ClearIcon bool
|
||||
}
|
||||
|
||||
func (s *Store) UpdateChannel(ctx context.Context, id uint64, params UpdateChannelParams) (*Channel, error) {
|
||||
@@ -156,6 +164,13 @@ func (s *Store) UpdateChannel(ctx context.Context, id uint64, params UpdateChann
|
||||
sets = append(sets, "user_limit = ?")
|
||||
args = append(args, *params.UserLimit)
|
||||
}
|
||||
if params.IconFileID != nil {
|
||||
sets = append(sets, "icon_file_id = ?")
|
||||
args = append(args, int64(*params.IconFileID))
|
||||
}
|
||||
if params.ClearIcon {
|
||||
sets = append(sets, "icon_file_id = NULL")
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return s.GetChannel(ctx, id)
|
||||
}
|
||||
@@ -274,10 +289,11 @@ func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) {
|
||||
backgroundID sql.NullInt64
|
||||
dmOwnerID sql.NullInt64
|
||||
createdAt string
|
||||
iconID sql.NullInt64
|
||||
)
|
||||
err := scanner.Scan(&channel.ID, &guildID, &channel.Type, &channel.Name, &channel.Description,
|
||||
&channel.Position, &parentID, &channel.SlowmodeSeconds, &nsfw, &background,
|
||||
&backgroundID, &channel.VoiceStatus, &channel.UserLimit, &dmOwnerID, &createdAt)
|
||||
&backgroundID, &channel.VoiceStatus, &channel.UserLimit, &dmOwnerID, &createdAt, &iconID)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
@@ -289,6 +305,7 @@ func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) {
|
||||
}
|
||||
channel.BackgroundFileID = optionalID(backgroundID)
|
||||
channel.DMOwnerID = optionalID(dmOwnerID)
|
||||
channel.IconFileID = optionalID(iconID)
|
||||
channel.CreatedAt = parseTimestamp(createdAt)
|
||||
return &channel, nil
|
||||
}
|
||||
|
||||
@@ -93,8 +93,12 @@ func (s *Store) RemoveDMParticipant(ctx context.Context, channelID, userID uint6
|
||||
}
|
||||
}
|
||||
case remaining == 2:
|
||||
// Беседа снова обычная личная: имя, владелец и иконка группы
|
||||
// сбрасываются (AGENT.md 7.8, D-070). Файл иконки после этого
|
||||
// становится сиротой и удаляется уборкой (AGENT.md 7.7).
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE channels SET name = '', dm_owner_id = NULL WHERE id = ?`, int64(channelID)); err != nil {
|
||||
`UPDATE channels SET name = '', dm_owner_id = NULL, icon_file_id = NULL WHERE id = ?`,
|
||||
int64(channelID)); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"glchat/internal/database"
|
||||
)
|
||||
|
||||
// newDMIconStore открывает временную БД и создаёт трёх пользователей: владельца
|
||||
// группы и двух участников (AGENT.md 7.8).
|
||||
func newDMIconStore(t *testing.T) (*Store, []uint64) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
db, err := database.Open(ctx, database.Options{
|
||||
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
||||
ReadPool: 2,
|
||||
Migrate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("открыть тестовую БД: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Errorf("закрыть тестовую БД: %v", err)
|
||||
}
|
||||
})
|
||||
st := New(db)
|
||||
users := make([]uint64, 0, 3)
|
||||
for index, name := range []string{"icon_owner", "icon_bob", "icon_carol"} {
|
||||
user, err := st.CreateUser(ctx, CreateUserParams{
|
||||
Username: name,
|
||||
EmailEnc: "enc",
|
||||
EmailIndex: name + "-idx",
|
||||
PasswordHash: "hash",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("создать пользователя %d: %v", index, err)
|
||||
}
|
||||
users = append(users, user.ID)
|
||||
}
|
||||
return st, users
|
||||
}
|
||||
|
||||
// dmIconFile регистрирует файл иконки так же, как это делает сервер: обычная
|
||||
// загрузка с назначением dm_icon, привязанная к беседе.
|
||||
func dmIconFile(ctx context.Context, t *testing.T, st *Store, uploaderID, channelID uint64, name string) *File {
|
||||
t.Helper()
|
||||
file, err := st.CreateFile(ctx, CreateFileParams{
|
||||
UploaderID: uploaderID,
|
||||
ChannelID: &channelID,
|
||||
Filename: name,
|
||||
ContentType: "image/png",
|
||||
SizeBytes: 128,
|
||||
StoragePath: filepath.Join(t.TempDir(), name),
|
||||
SHA256: name,
|
||||
Purpose: "dm_icon",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("создать файл иконки: %v", err)
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
// groupIconSummary ищет беседу в списке пользователя.
|
||||
func groupIconSummary(ctx context.Context, t *testing.T, st *Store, userID, channelID uint64) DMChannelSummary {
|
||||
t.Helper()
|
||||
channels, err := st.ListDMChannels(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDMChannels: %v", err)
|
||||
}
|
||||
for _, summary := range channels {
|
||||
if summary.Channel.ID == channelID {
|
||||
return summary
|
||||
}
|
||||
}
|
||||
t.Fatalf("беседа %d не найдена в списке пользователя %d", channelID, userID)
|
||||
return DMChannelSummary{}
|
||||
}
|
||||
|
||||
// TestDMChannelIconLifecycle проверяет иконку групповой беседы (D-070):
|
||||
// установку и снятие через UpdateChannel, видимость в списке бесед у всех
|
||||
// участников и ON DELETE SET NULL при удалении файла.
|
||||
func TestDMChannelIconLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, users := newDMIconStore(t)
|
||||
channel, err := st.CreateGroupDMChannel(ctx, users[0], "Игровая", users[1:])
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGroupDMChannel: %v", err)
|
||||
}
|
||||
file := dmIconFile(ctx, t, st, users[0], channel.ID, "icon.png")
|
||||
|
||||
updated, err := st.UpdateChannel(ctx, channel.ID, UpdateChannelParams{IconFileID: &file.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateChannel(IconFileID): %v", err)
|
||||
}
|
||||
if updated.IconFileID == nil || *updated.IconFileID != file.ID {
|
||||
t.Fatalf("icon_file_id = %v, ожидался %d", updated.IconFileID, file.ID)
|
||||
}
|
||||
|
||||
// Иконка видна каждому участнику: список бесед читает ту же колонку.
|
||||
for _, userID := range users {
|
||||
summary := groupIconSummary(ctx, t, st, userID, channel.ID)
|
||||
if !summary.IsGroup {
|
||||
t.Fatalf("беседа не групповая для пользователя %d: %+v", userID, summary)
|
||||
}
|
||||
if summary.Channel.IconFileID == nil || *summary.Channel.IconFileID != file.ID {
|
||||
t.Fatalf("иконка в списке пользователя %d = %v", userID, summary.Channel.IconFileID)
|
||||
}
|
||||
// У группы нет собеседника: аватар 1:1 остаётся пустым.
|
||||
if summary.AvatarFileID != nil {
|
||||
t.Fatalf("у группы появился аватар собеседника: %v", *summary.AvatarFileID)
|
||||
}
|
||||
}
|
||||
|
||||
// Снятие иконки очищает колонку.
|
||||
cleared, err := st.UpdateChannel(ctx, channel.ID, UpdateChannelParams{ClearIcon: true})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateChannel(ClearIcon): %v", err)
|
||||
}
|
||||
if cleared.IconFileID != nil {
|
||||
t.Fatalf("после снятия icon_file_id = %v, ожидался NULL", *cleared.IconFileID)
|
||||
}
|
||||
|
||||
// Удаление файла обнуляет ссылку (ON DELETE SET NULL), а не ломает беседу.
|
||||
if _, err := st.UpdateChannel(ctx, channel.ID, UpdateChannelParams{IconFileID: &file.ID}); err != nil {
|
||||
t.Fatalf("повторная установка иконки: %v", err)
|
||||
}
|
||||
if err := st.DeleteFile(ctx, file.ID); err != nil {
|
||||
t.Fatalf("DeleteFile: %v", err)
|
||||
}
|
||||
after, err := st.GetChannel(ctx, channel.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChannel после удаления файла: %v", err)
|
||||
}
|
||||
if after.IconFileID != nil {
|
||||
t.Fatalf("после удаления файла icon_file_id = %v, ожидался NULL", *after.IconFileID)
|
||||
}
|
||||
if after.Name != "Игровая" || after.DMOwnerID == nil {
|
||||
t.Fatalf("беседа пострадала при удалении файла: %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDMChannelIconResetOnDemotion: когда в беседе остаётся два участника, она
|
||||
// снова обычная личная — иконка группы сбрасывается вместе с именем и
|
||||
// владельцем (AGENT.md 7.8, D-070).
|
||||
func TestDMChannelIconResetOnDemotion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, users := newDMIconStore(t)
|
||||
channel, err := st.CreateGroupDMChannel(ctx, users[0], "Игровая", users[1:])
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGroupDMChannel: %v", err)
|
||||
}
|
||||
file := dmIconFile(ctx, t, st, users[0], channel.ID, "icon.png")
|
||||
if _, err := st.UpdateChannel(ctx, channel.ID, UpdateChannelParams{IconFileID: &file.ID}); err != nil {
|
||||
t.Fatalf("UpdateChannel(IconFileID): %v", err)
|
||||
}
|
||||
|
||||
if err := st.RemoveDMParticipant(ctx, channel.ID, users[2]); err != nil {
|
||||
t.Fatalf("RemoveDMParticipant: %v", err)
|
||||
}
|
||||
demoted, err := st.GetChannel(ctx, channel.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChannel: %v", err)
|
||||
}
|
||||
if demoted.IconFileID != nil || demoted.DMOwnerID != nil || demoted.Name != "" {
|
||||
t.Fatalf("беседа с двумя участниками не сброшена: %+v", demoted)
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@ const orphanFilesQuery = `
|
||||
AND f.message_id IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.avatar_file_id = f.id OR u.banner_file_id = f.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM guilds g WHERE g.icon_file_id = f.id OR g.banner_file_id = f.id OR g.splash_file_id = f.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM channels c WHERE c.background_file_id = f.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM channels c WHERE c.background_file_id = f.id OR c.icon_file_id = f.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM invites i WHERE i.background_file_id = f.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM guild_cosmetics gc WHERE gc.file_id = f.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM webhooks w WHERE w.avatar_file_id = f.id)
|
||||
|
||||
@@ -284,7 +284,7 @@ func (s *Store) listDirectChannels(ctx context.Context, userID uint64) ([]DMChan
|
||||
// пользователя немного, а список нужен интерфейсу для карточки беседы.
|
||||
func (s *Store) listGroupChannels(ctx context.Context, userID uint64) ([]DMChannelSummary, error) {
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT c.id, c.name, c.dm_owner_id, c.created_at,
|
||||
SELECT c.id, c.name, c.dm_owner_id, c.created_at, c.icon_file_id,
|
||||
(SELECT COUNT(*) FROM dm_participants everyone WHERE everyone.channel_id = c.id),
|
||||
(SELECT m.id FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1),
|
||||
(SELECT m.created_at FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1),
|
||||
@@ -305,11 +305,12 @@ func (s *Store) listGroupChannels(ctx context.Context, userID uint64) ([]DMChann
|
||||
summary DMChannelSummary
|
||||
ownerID sql.NullInt64
|
||||
createdAt string
|
||||
iconID sql.NullInt64
|
||||
lastMessageID sql.NullInt64
|
||||
lastMessageAt sql.NullString
|
||||
lastAuthorID sql.NullInt64
|
||||
)
|
||||
if err := rows.Scan(&summary.Channel.ID, &summary.GroupName, &ownerID, &createdAt,
|
||||
if err := rows.Scan(&summary.Channel.ID, &summary.GroupName, &ownerID, &createdAt, &iconID,
|
||||
&summary.MemberCount, &lastMessageID, &lastMessageAt, &lastAuthorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -318,6 +319,9 @@ func (s *Store) listGroupChannels(ctx context.Context, userID uint64) ([]DMChann
|
||||
summary.Channel.Type = ChannelDM
|
||||
summary.Channel.Name = summary.GroupName
|
||||
summary.Channel.DMOwnerID = summary.OwnerID
|
||||
// Иконка группы — своя (D-070), в отличие от 1:1, где показывается
|
||||
// аватар собеседника (AvatarFileID).
|
||||
summary.Channel.IconFileID = optionalID(iconID)
|
||||
summary.Channel.CreatedAt = parseTimestamp(createdAt)
|
||||
if lastMessageID.Valid {
|
||||
summary.LastMessageID = uint64(lastMessageID.Int64)
|
||||
|
||||
Reference in New Issue
Block a user