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 нет")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user