From 8696d855147aa39c98c8e61b306061782c135e86 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 26 Sep 2026 16:43:51 +0300 Subject: [PATCH] =?UTF-8?q?feat(dm):=20=D0=B8=D0=BA=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D0=BE=D0=B2=D0=BE=D0=B9?= =?UTF-8?q?=20=D0=B1=D0=B5=D1=81=D0=B5=D0=B4=D1=8B=20=D0=B2=20=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8=D1=89=D0=B5=20=D0=B8=20API=20(?= =?UTF-8?q?=D0=A4=D0=B0=D0=B7=D0=B0=207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- internal/database/dm_icon_migration_test.go | 126 +++++ .../migrations/00024_phase7_dm_icon.sql | 12 + internal/gateway/ready.go | 10 +- internal/server/api_dm_icon.go | 182 ++++++++ internal/server/api_files.go | 12 + internal/server/api_social.go | 8 + internal/server/dm_icon_test.go | 432 ++++++++++++++++++ internal/server/server.go | 1 + internal/store/channels.go | 23 +- internal/store/dm_groups.go | 6 +- internal/store/dm_icon_test.go | 170 +++++++ internal/store/files.go | 2 +- internal/store/social.go | 8 +- 13 files changed, 982 insertions(+), 10 deletions(-) create mode 100644 internal/database/dm_icon_migration_test.go create mode 100644 internal/database/migrations/00024_phase7_dm_icon.sql create mode 100644 internal/server/api_dm_icon.go create mode 100644 internal/server/dm_icon_test.go create mode 100644 internal/store/dm_icon_test.go diff --git a/internal/database/dm_icon_migration_test.go b/internal/database/dm_icon_migration_test.go new file mode 100644 index 0000000..edb3c44 --- /dev/null +++ b/internal/database/dm_icon_migration_test.go @@ -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 нет") + } +} diff --git a/internal/database/migrations/00024_phase7_dm_icon.sql b/internal/database/migrations/00024_phase7_dm_icon.sql new file mode 100644 index 0000000..69ba3eb --- /dev/null +++ b/internal/database/migrations/00024_phase7_dm_icon.sql @@ -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; diff --git a/internal/gateway/ready.go b/internal/gateway/ready.go index 15b1468..4ebb57e 100644 --- a/internal/gateway/ready.go +++ b/internal/gateway/ready.go @@ -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.AvatarFileID != nil { + if summary.Channel.IconFileID != nil { + channel.IconFileID = formatID(*summary.Channel.IconFileID) + } + } else if summary.AvatarFileID != nil { + // У 1:1 иконка беседы — аватар собеседника (AGENT.md 7.8). channel.IconFileID = formatID(*summary.AvatarFileID) } if summary.LastMessageID != 0 { diff --git a/internal/server/api_dm_icon.go b/internal/server/api_dm_icon.go new file mode 100644 index 0000000..30519fa --- /dev/null +++ b/internal/server/api_dm_icon.go @@ -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)}) +} diff --git a/internal/server/api_files.go b/internal/server/api_files.go index 6a15cf0..cbb9edc 100644 --- a/internal/server/api_files.go +++ b/internal/server/api_files.go @@ -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 { diff --git a/internal/server/api_social.go b/internal/server/api_social.go index 3a8fcd8..d907e7c 100644 --- a/internal/server/api_social.go +++ b/internal/server/api_social.go @@ -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) diff --git a/internal/server/dm_icon_test.go b/internal/server/dm_icon_test.go new file mode 100644 index 0000000..d40f3e6 --- /dev/null +++ b/internal/server/dm_icon_test.go @@ -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) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index b9dc871..27b96ad 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/store/channels.go b/internal/store/channels.go index b2bc7ed..abe0a18 100644 --- a/internal/store/channels.go +++ b/internal/store/channels.go @@ -34,7 +34,11 @@ type Channel struct { // DMOwnerID — владелец групповой личной беседы (AGENT.md 7.8): только он // добавляет и удаляет участников и переименовывает беседу. У 1:1 — NULL. DMOwnerID *uint64 - CreatedAt time.Time + // IconFileID — иконка групповой беседы (AGENT.md 7.8, D-070): обычная + // загрузка, менять её может только владелец. У 1:1 иконка — аватар + // собеседника, поэтому колонка пустая. + IconFileID *uint64 + CreatedAt time.Time } type ChannelOverride struct { @@ -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 } diff --git a/internal/store/dm_groups.go b/internal/store/dm_groups.go index 055ea66..53391bc 100644 --- a/internal/store/dm_groups.go +++ b/internal/store/dm_groups.go @@ -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: diff --git a/internal/store/dm_icon_test.go b/internal/store/dm_icon_test.go new file mode 100644 index 0000000..97cbe6c --- /dev/null +++ b/internal/store/dm_icon_test.go @@ -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) + } +} diff --git a/internal/store/files.go b/internal/store/files.go index 01627b1..55e891b 100644 --- a/internal/store/files.go +++ b/internal/store/files.go @@ -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) diff --git a/internal/store/social.go b/internal/store/social.go index 3f4d3c3..7a480d0 100644 --- a/internal/store/social.go +++ b/internal/store/social.go @@ -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)